Compare commits

..

1 commit

Author SHA1 Message Date
dependabot[bot]
f6f4e3105c
Bump actions/checkout from 5 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-21 03:21:50 +00:00
32 changed files with 9476 additions and 1743 deletions

View file

@ -1,26 +0,0 @@
name: Deploy to Pages
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
workflow_dispatch:
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
deploy:
runs-on: docker
steps:
- uses: actions/checkout@v4
- name: Copy frontend to host pages mount
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: http://forgejo:3000/andras/ci-actions/deploy-pages@main
with:
source: frontend
target: fizika

View file

@ -1,24 +0,0 @@
name: Build and Publish Docker Image
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
workflow_dispatch:
jobs:
build-and-push:
runs-on: docker
steps:
- name: Checkout repository
uses: https://code.forgejo.org/actions/checkout@v4
- name: Build and publish admin image
uses: http://forgejo:3000/andras/ci-actions/docker-publish@main
with:
context: ./backend
image-suffix: -admin
push: ${{ github.event_name != 'pull_request' }}
token: ${{ secrets.FORGEJO_PACKAGE_TOKEN }}

View file

@ -1,30 +0,0 @@
name: Test
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
workflow_dispatch:
jobs:
test:
runs-on: docker
steps:
- name: Checkout repository
uses: https://code.forgejo.org/actions/checkout@v4
- name: Install root tooling
run: npm ci
- name: Check formatting
run: npm run format:check
- name: Install backend dependencies
run: npm ci
working-directory: backend
- name: Run backend tests
run: npm test
working-directory: backend

6
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"

36
.github/workflows/deploy.yaml vendored Normal file
View file

@ -0,0 +1,36 @@
name: Deploy to Pages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
with:
path: "frontend"
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

86
.github/workflows/docker-publish.yml vendored Normal file
View file

@ -0,0 +1,86 @@
name: Build and Publish Docker Image
on:
push:
branches: ["main"]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}/fizika-admin
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
# This is used to complete the identity challenge
# with sigstore/fulcio when running outside of PRs.
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v3
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha,prefix={{branch}}-
# set latest tag for default branch
type=raw,value=latest,enable={{is_default_branch}}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v5
with:
context: ./backend
file: ./backend/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Security scanning
sbom: true
provenance: true
# Sign the resulting Docker image digest.
# This will only write to the public Rekor transparency log when the Docker
# repository is public to avoid leaking data. If you would like to publish
# transparency data even for private images, pass --force to cosign below.
# https://github.com/sigstore/cosign
- name: Sign the published Docker image
if: ${{ github.ref_type == 'tag' }}
env:
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
TAGS: ${{ steps.meta.outputs.tags }}
DIGEST: ${{ steps.build-and-push.outputs.digest }}
# This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}

View file

@ -1,11 +0,0 @@
node_modules
backend/node_modules
# Lockfiles are managed by npm
package-lock.json
backend/package-lock.json
# Vendored / generated assets
frontend/js/jquery.min.js
frontend/js/vendored.js
*.min.js

View file

@ -1,6 +0,0 @@
{
"printWidth": 80,
"tabWidth": 2,
"semi": true,
"singleQuote": false
}

105
README.md
View file

@ -1,106 +1 @@
# Fizika # Fizika
Practice web app for the Hungarian **emelt szintű fizika** (advanced-level
physics) written school-leaving exam. Students generate quizzes from a bank of
past exam questions — by topic, by exam paper, or as a random "érettségi"
set — answer them, and review their past results (stored locally in the
browser).
Live at <https://fizika.schmelczer.dev>.
## Architecture
```
frontend/ Static site (HTML + CSS + jQuery, no build step).
Deployed to Pages. Fetches questions from the backend API.
backend/ Express server. Serves the admin panel and a small REST API for
editing questions and images. Reads/writes a single JSON file plus
an images directory.
```
The **backend is the single source of truth** for question data. The frontend
always fetches it from `GET /api/fizika` (see `frontend/js/load.js`); the
question JSON is no longer shipped with the static site.
### Access control
Authentication is handled at the reverse proxy (nginx + basic auth), not in the
app. The proxy exposes only two endpoints publicly; everything else (the admin
panel and all `/api/admin/*` routes) requires a login:
| Path | Access | Purpose |
| --------------- | ------- | -------------------------------- |
| `/api/fizika` | public | question data for the public app |
| `/api/pics/*` | public | question images |
| everything else | private | admin panel + edit API |
The app trusts the proxy, so **do not expose the backend port directly** without
adding authentication in front of it.
## API
| Method | Route | Description |
| -------- | ----------------------------- | --------------------- |
| `GET` | `/api/fizika` | All questions |
| `GET` | `/api/images` | List image filenames |
| `GET` | `/api/pics/:file` | Serve an image |
| `GET` | `/api/admin/questions` | All questions (admin) |
| `POST` | `/api/admin/questions` | Create a question |
| `PUT` | `/api/admin/questions/:id` | Update a question |
| `DELETE` | `/api/admin/questions/:id` | Delete a question |
| `POST` | `/api/admin/images/upload` | Upload an image |
| `DELETE` | `/api/admin/images/:filename` | Delete an image |
## Configuration
The backend is configured via environment variables:
| Variable | Default | Description |
| -------------- | ------------------------- | --------------------------------- |
| `PORT` | `3001` | Port to listen on |
| `DATA_PATH` | `../frontend/fizika.json` | Path to the questions JSON file |
| `PICS_PATH` | `../frontend/pics` | Directory holding question images |
| `FRONTEND_URL` | `*` | Allowed CORS origin |
In production `DATA_PATH` and `PICS_PATH` point at mounted volumes that hold the
canonical data. The defaults are dev conveniences only — set both explicitly to
a JSON file and a directory that exist before starting.
## Development
```sh
cd backend
npm install
DATA_PATH=/path/to/fizika.json PICS_PATH=/path/to/pics npm run dev
```
Then open `frontend/index.html` (or serve `frontend/` statically). On
`localhost` the frontend talks to the backend on port 3001 automatically.
## Tests
Backend tests use Node's built-in test runner (no extra dependencies):
```sh
cd backend
npm test
```
## Formatting
[Prettier](https://prettier.io) formats the whole repo. Run from the repo root:
```sh
npm install # once, to install Prettier
npm run format # write
npm run format:check # verify (also run in CI)
```
## CI / Deployment
Forgejo Actions workflows in `.forgejo/workflows/`:
- `test.yml` — formatting check + backend tests on every push/PR.
- `docker-publish.yml` — builds and publishes the backend image from `backend/`.
- `deploy.yml` — deploys `frontend/` to Pages.

View file

@ -1,4 +1,3 @@
node_modules node_modules
Dockerfile Dockerfile
.dockerignore .dockerignore
*.test.js

File diff suppressed because it is too large Load diff

View file

@ -5,18 +5,16 @@
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js",
"dev": "nodemon server.js", "dev": "nodemon server.js"
"test": "node --test"
}, },
"dependencies": { "dependencies": {
"@plausible-analytics/tracker": "^0.4.5", "express": "^4.18.2",
"express": "^5.2.1", "multer": "^1.4.5-lts.1",
"multer": "^2.1.1", "cors": "^2.8.5",
"cors": "^2.8.6", "helmet": "^7.1.0"
"helmet": "^8.2.0"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.1.14" "nodemon": "^3.0.2"
}, },
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"

View file

@ -1,19 +1,10 @@
const API_BASE = window.location.origin; const API_BASE = window.location.origin;
// Escape user-supplied text before interpolating it into HTML, to prevent XSS. window.plausible =
function escapeHtml(value) { window.plausible ||
return String(value).replace( function () {
/[&<>"']/g, (window.plausible.q = window.plausible.q || []).push(arguments);
(ch) => };
({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[ch],
);
}
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
loadQuestions(); loadQuestions();
@ -22,31 +13,23 @@ document.addEventListener("DOMContentLoaded", function () {
}); });
function setupEventListeners() { function setupEventListeners() {
document.querySelectorAll(".tab[data-tab]").forEach((tab) => { document.querySelectorAll('.tab[data-tab]').forEach(tab => {
tab.addEventListener("click", (e) => { tab.addEventListener('click', (e) => {
switchTab(e.target.dataset.tab); switchTab(e.target.dataset.tab);
}); });
}); });
document document.getElementById('addQuestionBtn').addEventListener('click', showAddQuestionModal);
.getElementById("addQuestionBtn")
.addEventListener("click", showAddQuestionModal);
document document.getElementById('imageUpload').addEventListener('change', uploadImage);
.getElementById("imageUpload")
.addEventListener("change", uploadImage);
document document.getElementById('closeModalBtn').addEventListener('click', closeModal);
.getElementById("closeModalBtn") document.getElementById('cancelBtn').addEventListener('click', closeModal);
.addEventListener("click", closeModal);
document.getElementById("cancelBtn").addEventListener("click", closeModal);
document document.getElementById('questionForm').addEventListener('submit', saveQuestion);
.getElementById("questionForm")
.addEventListener("submit", saveQuestion);
document.getElementById("questionModal").addEventListener("click", (e) => { document.getElementById('questionModal').addEventListener('click', (e) => {
if (e.target.id === "questionModal") { if (e.target.id === 'questionModal') {
closeModal(); closeModal();
} }
}); });
@ -87,37 +70,39 @@ function displayQuestions(questions) {
.map( .map(
(q) => ` (q) => `
<div class="question-item"> <div class="question-item">
<h4>ID: ${q.id} - ${escapeHtml(q.source)}</h4> <h4>ID: ${q.id} - ${q.source}</h4>
<p><strong>Kérdés:</strong> ${escapeHtml( <p><strong>Kérdés:</strong> ${q.description.substring(
q.description.substring(0, 100), 0,
100
)}...</p> )}...</p>
<p><strong>Típus:</strong> ${escapeHtml(q.type)} | <strong>Helyes válasz:</strong> ${ <p><strong>Típus:</strong> ${q.type
["A", "B", "C", "D"][q.correct - 1] } | <strong>Helyes válasz:</strong> ${["A", "B", "C", "D"][q.correct - 1]
}</p> }</p>
<div class="question-actions"> <div class="question-actions">
<button data-edit-id="${q.id}">Szerkesztés</button> <button data-edit-id="${q.id}">Szerkesztés</button>
<button class="danger" data-delete-id="${q.id}">Törlés</button> <button class="danger" data-delete-id="${q.id}">Törlés</button>
</div> </div>
</div> </div>
`, `
) )
.join(""); .join("");
container.querySelectorAll("[data-edit-id]").forEach((btn) => { container.querySelectorAll('[data-edit-id]').forEach(btn => {
btn.addEventListener("click", (e) => { btn.addEventListener('click', (e) => {
editQuestion(parseInt(e.target.dataset.editId)); editQuestion(parseInt(e.target.dataset.editId));
}); });
}); });
container.querySelectorAll("[data-delete-id]").forEach((btn) => { container.querySelectorAll('[data-delete-id]').forEach(btn => {
btn.addEventListener("click", (e) => { btn.addEventListener('click', (e) => {
deleteQuestion(parseInt(e.target.dataset.deleteId)); deleteQuestion(parseInt(e.target.dataset.deleteId));
}); });
}); });
} }
function showAddQuestionModal() { function showAddQuestionModal() {
document.getElementById("modalTitle").textContent = "Új kérdés hozzáadása"; document.getElementById("modalTitle").textContent =
"Új kérdés hozzáadása";
document.getElementById("questionForm").reset(); document.getElementById("questionForm").reset();
document.getElementById("questionId").value = ""; document.getElementById("questionId").value = "";
document.getElementById("questionModal").style.display = "block"; document.getElementById("questionModal").style.display = "block";
@ -141,9 +126,11 @@ async function editQuestion(id) {
document.getElementById("questionB").value = question.b; document.getElementById("questionB").value = question.b;
document.getElementById("questionC").value = question.c; document.getElementById("questionC").value = question.c;
document.getElementById("questionD").value = question.d; document.getElementById("questionD").value = question.d;
document.getElementById("questionCorrect").value = question.correct; document.getElementById("questionCorrect").value =
question.correct;
document.getElementById("questionType").value = question.type; document.getElementById("questionType").value = question.type;
document.getElementById("questionImage").value = question.image || ""; document.getElementById("questionImage").value =
question.image || "";
document.getElementById("questionModal").style.display = "block"; document.getElementById("questionModal").style.display = "block";
} }
} }
@ -179,7 +166,7 @@ async function saveQuestion(event) {
method: isEdit ? "PUT" : "POST", method: isEdit ? "PUT" : "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(data), body: JSON.stringify(data),
}, }
); );
if (response.ok) { if (response.ok) {
@ -191,7 +178,7 @@ async function saveQuestion(event) {
showAlert( showAlert(
"questionsAlert", "questionsAlert",
error.error || "Hiba a mentés során", error.error || "Hiba a mentés során",
"danger", "danger"
); );
} }
} catch (error) { } catch (error) {
@ -203,9 +190,10 @@ async function deleteQuestion(id) {
if (!confirm("Biztosan törölni szeretnéd ezt a kérdést?")) return; if (!confirm("Biztosan törölni szeretnéd ezt a kérdést?")) return;
try { try {
const response = await fetch(`${API_BASE}/api/admin/questions/${id}`, { const response = await fetch(
method: "DELETE", `${API_BASE}/api/admin/questions/${id}`,
}); { method: "DELETE" }
);
if (response.ok) { if (response.ok) {
loadQuestions(); loadQuestions();
showAlert("questionsAlert", "Kérdés törölve!", "success"); showAlert("questionsAlert", "Kérdés törölve!", "success");
@ -238,20 +226,16 @@ function displayImages(images) {
.map( .map(
(image) => ` (image) => `
<div class="image-item"> <div class="image-item">
<img src="${API_BASE}/api/pics/${encodeURIComponent( <img src="${API_BASE}/api/pics/${image}" alt="${image}">
image, <p>${image}</p>
)}" alt="${escapeHtml(image)}"> <button class="danger" data-delete-image="${image}">Törlés</button>
<p>${escapeHtml(image)}</p>
<button class="danger" data-delete-image="${escapeHtml(
image,
)}">Törlés</button>
</div> </div>
`, `
) )
.join(""); .join("");
container.querySelectorAll("[data-delete-image]").forEach((btn) => { container.querySelectorAll('[data-delete-image]').forEach(btn => {
btn.addEventListener("click", (e) => { btn.addEventListener('click', (e) => {
deleteImage(e.target.dataset.deleteImage); deleteImage(e.target.dataset.deleteImage);
}); });
}); });
@ -281,7 +265,7 @@ async function uploadImage() {
showAlert( showAlert(
"imagesAlert", "imagesAlert",
error.error || "Hiba a feltöltés során", error.error || "Hiba a feltöltés során",
"danger", "danger"
); );
} }
} catch (error) { } catch (error) {
@ -297,7 +281,7 @@ async function deleteImage(filename) {
`${API_BASE}/api/admin/images/${encodeURIComponent(filename)}`, `${API_BASE}/api/admin/images/${encodeURIComponent(filename)}`,
{ {
method: "DELETE", method: "DELETE",
}, }
); );
if (response.ok) { if (response.ok) {

View file

@ -1,10 +1,15 @@
<!doctype html> <!DOCTYPE html>
<html lang="hu"> <html lang="hu">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fizika Admin - Kérdések és képek kezelése</title> <title>Fizika Admin - Kérdések és képek kezelése</title>
<script type="module" src="stats.js"></script> <script
defer
data-domain="fizika.schmelczer.dev"
data-api="https://stats.schmelczer.dev/status"
src="https://stats.schmelczer.dev/js/script.file-downloads.hash.outbound-links.js"
></script>
<style> <style>
* { * {
margin: 0; margin: 0;

View file

@ -1,12 +0,0 @@
import { init } from "/vendor/plausible.js";
// Plausible analytics via the official npm tracker (@plausible-analytics/tracker).
// server.js serves /vendor/plausible.js straight from node_modules.
init({
domain: "fizika.schmelczer.dev",
endpoint: "https://stats.schmelczer.dev/status",
autoCapturePageviews: true,
outboundLinks: true,
fileDownloads: true,
hashBasedRouting: true,
});

View file

@ -1,16 +1,15 @@
const express = require("express"); const express = require('express');
const cors = require("cors"); const cors = require('cors');
const multer = require("multer"); const multer = require('multer');
const path = require("path"); const path = require('path');
const fs = require("fs").promises; const fs = require('fs').promises;
const helmet = require("helmet"); const helmet = require('helmet');
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
// Security middleware // Security middleware
app.use( app.use(helmet({
helmet({
contentSecurityPolicy: { contentSecurityPolicy: {
directives: { directives: {
defaultSrc: [ defaultSrc: [
@ -25,42 +24,18 @@ app.use(
], ],
}, },
}, },
}), }));
); app.use(cors({
app.use( origin: process.env.FRONTEND_URL || '*',
cors({ credentials: true
origin: process.env.FRONTEND_URL || "*", }));
credentials: true,
}),
);
app.use(express.json({ limit: "100mb" })); app.use(express.json({ limit: '100mb' }));
app.use(express.static("public")); app.use(express.static('public'));
// Serve the official Plausible tracker (npm: @plausible-analytics/tracker) from node_modules.
const plausibleTrackerPath =
require.resolve("@plausible-analytics/tracker/plausible.js");
app.get("/vendor/plausible.js", (req, res) => {
res.sendFile(plausibleTrackerPath);
});
// File paths // File paths
const DATA_PATH = const DATA_PATH = process.env.DATA_PATH || path.join(__dirname, '../frontend/fizika.json');
process.env.DATA_PATH || path.join(__dirname, "../frontend/fizika.json"); const PICS_PATH = process.env.PICS_PATH || path.join(__dirname, '../frontend/pics');
const PICS_PATH =
process.env.PICS_PATH || path.join(__dirname, "../frontend/pics");
// Reject any name that could escape the target directory (path traversal).
const isUnsafeFilename = (name) =>
typeof name !== "string" ||
name.length === 0 ||
name.includes("/") ||
name.includes("\\") ||
name.includes("\0") ||
name === "." ||
name === ".." ||
path.isAbsolute(name);
// Multer configuration for image uploads // Multer configuration for image uploads
const storage = multer.diskStorage({ const storage = multer.diskStorage({
@ -68,31 +43,25 @@ const storage = multer.diskStorage({
cb(null, PICS_PATH); cb(null, PICS_PATH);
}, },
filename: (req, file, cb) => { filename: (req, file, cb) => {
// Strip any directory components the client may have sent so an upload cb(null, file.originalname);
// named e.g. "../../server.js" can never escape PICS_PATH.
const name = path.basename(file.originalname);
if (isUnsafeFilename(name)) {
return cb(new Error("Invalid filename"));
} }
cb(null, name);
},
}); });
const upload = multer({ const upload = multer({
storage: storage, storage: storage,
limits: { fileSize: 50 * 1024 * 1024 }, // 50MB limits: { fileSize: 50 * 1024 * 1024 }, // 5MB
fileFilter: (req, file, cb) => { fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith("image/")) { if (file.mimetype.startsWith('image/')) {
cb(null, true); cb(null, true);
} else { } else {
cb(new Error("Only images allowed")); cb(new Error('Only images allowed'));
}
} }
},
}); });
// Utility functions // Utility functions
const readData = async () => { const readData = async () => {
const data = await fs.readFile(DATA_PATH, "utf8"); const data = await fs.readFile(DATA_PATH, 'utf8');
return JSON.parse(data); return JSON.parse(data);
}; };
@ -100,42 +69,44 @@ const writeData = async (data) => {
await fs.writeFile(DATA_PATH, JSON.stringify(data, null, 2)); await fs.writeFile(DATA_PATH, JSON.stringify(data, null, 2));
}; };
// Public routes // Public routes
app.get("/api/fizika", async (req, res) => { app.get('/api/fizika', async (req, res) => {
try { try {
const data = await readData(); const data = await readData();
res.json(data); res.json(data);
} catch (error) { } catch (error) {
res.status(500).json({ error: "Failed to read data" }); res.status(500).json({ error: 'Failed to read data' });
} }
}); });
app.get("/api/images", async (req, res) => { app.get('/api/images', async (req, res) => {
try { try {
const files = await fs.readdir(PICS_PATH); const files = await fs.readdir(PICS_PATH);
const images = files.filter((f) => /\.(jpg|jpeg|png|gif|bmp)$/i.test(f)); const images = files.filter(f => /\.(jpg|jpeg|png|gif|bmp)$/i.test(f));
res.json(images); res.json(images);
} catch (error) { } catch (error) {
res.status(500).json({ error: "Failed to read images" }); res.status(500).json({ error: 'Failed to read images' });
} }
}); });
app.use("/api/pics", express.static(PICS_PATH)); app.use('/api/pics', express.static(PICS_PATH));
// Admin routes (no auth required) // Admin routes (no auth required)
app.get("/api/admin/questions", async (req, res) => { app.get('/api/admin/questions', async (req, res) => {
try { try {
const data = await readData(); const data = await readData();
res.json(data); res.json(data);
} catch (error) { } catch (error) {
res.status(500).json({ error: "Failed to read questions" }); res.status(500).json({ error: 'Failed to read questions' });
} }
}); });
app.post("/api/admin/questions", async (req, res) => { app.post('/api/admin/questions', async (req, res) => {
try { try {
const data = await readData(); const data = await readData();
const maxId = Math.max(...data.map((q) => q.id), 0); const maxId = Math.max(...data.map(q => q.id), 0);
const newQuestion = { id: maxId + 1, ...req.body }; const newQuestion = { id: maxId + 1, ...req.body };
data.push(newQuestion); data.push(newQuestion);
@ -143,17 +114,17 @@ app.post("/api/admin/questions", async (req, res) => {
res.status(201).json(newQuestion); res.status(201).json(newQuestion);
} catch (error) { } catch (error) {
res.status(500).json({ error: "Failed to create question" }); res.status(500).json({ error: 'Failed to create question' });
} }
}); });
app.put("/api/admin/questions/:id", async (req, res) => { app.put('/api/admin/questions/:id', async (req, res) => {
try { try {
const data = await readData(); const data = await readData();
const index = data.findIndex((q) => q.id === parseInt(req.params.id)); const index = data.findIndex(q => q.id === parseInt(req.params.id));
if (index === -1) { if (index === -1) {
return res.status(404).json({ error: "Question not found" }); return res.status(404).json({ error: 'Question not found' });
} }
data[index] = { ...data[index], ...req.body }; data[index] = { ...data[index], ...req.body };
@ -161,70 +132,61 @@ app.put("/api/admin/questions/:id", async (req, res) => {
res.json(data[index]); res.json(data[index]);
} catch (error) { } catch (error) {
res.status(500).json({ error: "Failed to update question" }); res.status(500).json({ error: 'Failed to update question' });
} }
}); });
app.delete("/api/admin/questions/:id", async (req, res) => { app.delete('/api/admin/questions/:id', async (req, res) => {
try { try {
const data = await readData(); const data = await readData();
const index = data.findIndex((q) => q.id === parseInt(req.params.id)); const index = data.findIndex(q => q.id === parseInt(req.params.id));
if (index === -1) { if (index === -1) {
return res.status(404).json({ error: "Question not found" }); return res.status(404).json({ error: 'Question not found' });
} }
data.splice(index, 1); data.splice(index, 1);
await writeData(data); await writeData(data);
res.json({ message: "Question deleted" }); res.json({ message: 'Question deleted' });
} catch (error) { } catch (error) {
res.status(500).json({ error: "Failed to delete question" }); res.status(500).json({ error: 'Failed to delete question' });
} }
}); });
app.post("/api/admin/images/upload", upload.single("image"), (req, res) => { app.post('/api/admin/images/upload', upload.single('image'), (req, res) => {
if (!req.file) { if (!req.file) {
return res.status(400).json({ error: "No image provided" }); return res.status(400).json({ error: 'No image provided' });
} }
res.json({ res.json({
filename: req.file.filename, filename: req.file.filename,
path: `/api/pics/${req.file.filename}`, path: `/api/pics/${req.file.filename}`
}); });
}); });
app.delete("/api/admin/images/:filename", async (req, res) => { app.delete('/api/admin/images/:filename', async (req, res) => {
if (isUnsafeFilename(req.params.filename)) {
return res.status(400).json({ error: "Invalid filename" });
}
try { try {
await fs.unlink(path.join(PICS_PATH, req.params.filename)); await fs.unlink(path.join(PICS_PATH, req.params.filename));
res.json({ message: "Image deleted" }); res.json({ message: 'Image deleted' });
} catch (error) { } catch (error) {
if (error.code === "ENOENT") { if (error.code === 'ENOENT') {
return res.status(404).json({ error: "Image not found" }); return res.status(404).json({ error: 'Image not found' });
} }
res.status(500).json({ error: "Failed to delete image" }); res.status(500).json({ error: 'Failed to delete image' });
} }
}); });
// Error handling // Error handling
app.use((error, req, res, next) => { app.use((error, req, res, next) => {
console.error("Error:", error.message); console.error('Error:', error.message);
res.status(500).json({ error: "Server error" }); res.status(500).json({ error: 'Server error' });
}); });
app.use((req, res) => { app.use((req, res) => {
res.status(404).json({ error: "Not found" }); res.status(404).json({ error: 'Not found' });
}); });
// Only start listening when run directly, so tests can import the app.
if (require.main === module) {
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Fizika Admin Backend running on port ${PORT}`); console.log(`Fizika Admin Backend running on port ${PORT}`);
}); });
}
module.exports = { app, isUnsafeFilename };

View file

@ -1,156 +0,0 @@
const { test, before, after, beforeEach } = require("node:test");
const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
// Point the server at a throwaway data dir before importing it, so tests never
// touch real question data.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fizika-test-"));
const dataPath = path.join(tmpDir, "fizika.json");
const picsPath = path.join(tmpDir, "pics");
fs.mkdirSync(picsPath, { recursive: true });
process.env.DATA_PATH = dataPath;
process.env.PICS_PATH = picsPath;
const { app, isUnsafeFilename } = require("./server");
const seedQuestions = [
{
id: 1,
source: "2016/m1/1",
description: "q1",
a: "a",
b: "b",
c: "c",
d: "d",
correct: 1,
type: "mk",
image: null,
},
{
id: 2,
source: "2016/m1/2",
description: "q2",
a: "a",
b: "b",
c: "c",
d: "d",
correct: 2,
type: "md",
image: null,
},
];
let server;
let baseUrl;
const api = (route, options) => fetch(`${baseUrl}${route}`, options);
const sendJson = (route, method, body) =>
api(route, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
before(async () => {
server = app.listen(0);
await new Promise((resolve) => server.once("listening", resolve));
baseUrl = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
beforeEach(() => {
fs.writeFileSync(dataPath, JSON.stringify(seedQuestions, null, 2));
});
test("GET /api/fizika returns every question", async () => {
const res = await api("/api/fizika");
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.length, 2);
});
test("POST creates a question with an incremented id", async () => {
const res = await sendJson("/api/admin/questions", "POST", {
source: "2020/1/1",
description: "new",
a: "a",
b: "b",
c: "c",
d: "d",
correct: 3,
type: "h",
image: null,
});
assert.equal(res.status, 201);
const created = await res.json();
assert.equal(created.id, 3);
const all = await (await api("/api/fizika")).json();
assert.equal(all.length, 3);
});
test("PUT updates an existing question", async () => {
const res = await sendJson("/api/admin/questions/1", "PUT", {
description: "updated",
});
assert.equal(res.status, 200);
const updated = await res.json();
assert.equal(updated.description, "updated");
// unchanged fields are preserved
assert.equal(updated.source, "2016/m1/1");
});
test("PUT returns 404 for a missing question", async () => {
const res = await sendJson("/api/admin/questions/999", "PUT", { a: "x" });
assert.equal(res.status, 404);
});
test("DELETE removes a question", async () => {
const res = await api("/api/admin/questions/1", { method: "DELETE" });
assert.equal(res.status, 200);
const all = await (await api("/api/fizika")).json();
assert.equal(all.length, 1);
assert.equal(all[0].id, 2);
});
test("DELETE returns 404 for a missing question", async () => {
const res = await api("/api/admin/questions/999", { method: "DELETE" });
assert.equal(res.status, 404);
});
test("DELETE image rejects an unsafe filename", async () => {
// Percent-encoded so the path survives client-side URL normalization and
// reaches the route with a separator the guard must reject.
const res = await api(`/api/admin/images/${encodeURIComponent("a\\b")}`, {
method: "DELETE",
});
assert.equal(res.status, 400);
});
test("DELETE image returns 404 for a missing but valid filename", async () => {
const res = await api("/api/admin/images/nope.png", { method: "DELETE" });
assert.equal(res.status, 404);
});
test("unknown routes return 404 JSON", async () => {
const res = await api("/api/does-not-exist");
assert.equal(res.status, 404);
const body = await res.json();
assert.equal(body.error, "Not found");
});
test("isUnsafeFilename blocks traversal but allows plain names", () => {
for (const bad of ["../etc", "a/b", "a\\b", "..", ".", "/abs.png", ""]) {
assert.equal(isUnsafeFilename(bad), true, `${bad} should be unsafe`);
}
for (const ok of ["pic.png", "image_1.jpg", "2016-m1-1.png"]) {
assert.equal(isUnsafeFilename(ok), false, `${ok} should be safe`);
}
});

View file

@ -12,9 +12,7 @@
border-style: solid; border-style: solid;
border-width: 3px; border-width: 3px;
border-color: #003366; border-color: #003366;
box-shadow: box-shadow: 0 8px 16px 0 rgba(0,0,0,0.15), 0 6px 20px 0 rgba(0,0,0,0.15);
0 8px 16px 0 rgba(0, 0, 0, 0.15),
0 6px 20px 0 rgba(0, 0, 0, 0.15);
-webkit-user-select: none; -webkit-user-select: none;
-moz-user-select: none; -moz-user-select: none;
-ms-user-select: none; -ms-user-select: none;
@ -30,3 +28,4 @@
margin: 0.15em; margin: 0.15em;
} }
} }

View file

@ -3,9 +3,7 @@
padding: 2em; padding: 2em;
text-align: left; text-align: left;
margin: 2em 0em 2em 0em; margin: 2em 0em 2em 0em;
box-shadow: box-shadow: 0 8px 16px 0 rgba(0,0,0,0.5), 0 6px 20px 0 rgba(0,0,0,0.09);
0 8px 16px 0 rgba(0, 0, 0, 0.5),
0 6px 20px 0 rgba(0, 0, 0, 0.09);
height: auto; height: auto;
width: auto; width: auto;
overflow: auto; overflow: auto;
@ -16,7 +14,7 @@ h2 {
font-size: 2em; font-size: 2em;
margin: auto; margin: auto;
text-align: center; text-align: center;
color: #0a0f14; color: #0A0F14;
} }
pre { pre {
text-align: justify; text-align: justify;

View file

@ -19,17 +19,16 @@ body {
text-shadow: none; text-shadow: none;
} }
h1:hover { h1:hover {
text-shadow: text-shadow: 0.3px 0.3px #002D5B,
0.3px 0.3px #002d5b,
0.6px 0.6px #002851, 0.6px 0.6px #002851,
1px 1px #002347, 1px 1px #002347,
1.3px 1.3px #001e3d, 1.3px 1.3px #001E3D,
1.6px 1.6px #001933, 1.6px 1.6px #001933,
2px 2px #001326, 2px 2px #001326,
2.3px 2.3px #000e1c, 2.3px 2.3px #000E1C,
2.6px 2.6px #000a14, 2.6px 2.6px #000A14,
3px 3px #00070f, 3px 3px #00070F,
3.3px 3.3px #00050a, 3.3px 3.3px #00050A,
3.6px 3.6px #000205, 3.6px 3.6px #000205,
4px 4px #000000; 4px 4px #000000;
} }
@ -76,7 +75,7 @@ li {
} }
li:hover { li:hover {
cursor: pointer; cursor: pointer;
text-shadow: 1px 1px 1px #0a0f14; text-shadow: 1px 1px 1px #0A0F14;
} }
#wrapper { #wrapper {
width: 75%; width: 75%;
@ -86,8 +85,7 @@ li:hover {
#bfooldal { #bfooldal {
font-weight: 700; font-weight: 700;
} }
.szoveg, .szoveg, #buttonwrapper {
#buttonwrapper {
width: 80%; width: 80%;
margin: auto; margin: auto;
} }
@ -98,7 +96,7 @@ p {
font-family: "Open sans", sans-serif; font-family: "Open sans", sans-serif;
font-weight: 300; font-weight: 300;
font-size: 1.3em; font-size: 1.3em;
color: #0a0f14; color: #0A0F14;
text-align: justify; text-align: justify;
margin: auto; margin: auto;
padding: 0.5em 0; padding: 0.5em 0;
@ -109,10 +107,7 @@ p:last-of-type {
.buttonwrapper { .buttonwrapper {
text-align: center; text-align: center;
} }
#teszt, #teszt, #eredmenyek, #kereses, #temakor {
#eredmenyek,
#kereses,
#temakor {
display: none; display: none;
} }
#temak { #temak {
@ -150,8 +145,10 @@ p:last-of-type {
background-color: white; background-color: white;
width: 63%; width: 63%;
} }
#load, .f2006, .f2016 {
#loadingGif { display: none;
}
#load, #loadingGif {
display: none; display: none;
} }
#loading { #loading {
@ -172,8 +169,7 @@ p:last-of-type {
font-size: 3em; font-size: 3em;
text-align: center; text-align: center;
} }
#state, #state, #state2 {
#state2 {
font-weight: 300; font-weight: 300;
font-size: 2em; font-size: 2em;
text-align: center; text-align: center;
@ -187,7 +183,7 @@ select {
border: 2px solid #ccc; border: 2px solid #ccc;
border-radius: 4px; border-radius: 4px;
font-size: 1.25em; font-size: 1.25em;
color: #0a0f14; color: #0A0F14;
width: 26%; width: 26%;
padding: 12px 20px 12px 40px; padding: 12px 20px 12px 40px;
background-color: #fff; background-color: #fff;
@ -198,8 +194,7 @@ table {
margin : auto; margin : auto;
width: 100%; width: 100%;
} }
th, th, td {
td {
text-align: left; text-align: left;
padding: 0.5em; padding: 0.5em;
} }
@ -270,3 +265,4 @@ h4 {
margin-left: 0; margin-left: 0;
} }
} }

View file

@ -2,43 +2,43 @@
display: none; display: none;
} }
input[type="radio"] { input[type=radio] {
cursor: pointer; cursor: pointer;
} }
input[type="radio"] { input[type=radio] {
width: 28px; width: 28px;
margin: 0; margin: 0;
padding: 0; padding: 0;
opacity: 0; opacity: 0;
} }
input[type="radio"] + label { input[type=radio] + label {
display: inline; display: inline;
font-family: "Open Sans", sans-serif; font-family: "Open Sans", sans-serif;
font-weight: 300; font-weight: 300;
font-size: 1.3em; font-size: 1.3em;
margin-left: -28px; margin-left: -28px;
padding-left: 28px; padding-left: 28px;
background: url("../auxIMG/unchecked.svg") no-repeat 0 50%; background: url('../auxIMG/unchecked.svg') no-repeat 0 50%;
line-height: 35px; line-height: 35px;
background-size: 28px 28px; background-size: 28px 28px;
margin-bottom: 6.25em; margin-bottom: 6.25em;
} }
input[type="radio"]:checked + label { input[type=radio]:checked + label {
background: url("../auxIMG/checked.svg") no-repeat 0 50%; background: url('../auxIMG/checked.svg') no-repeat 0 50%;
background-size: 28px 28px; background-size: 28px 28px;
} }
@media only screen and (max-width: 1000px){ @media only screen and (max-width: 1000px){
input[type="radio"] { input[type=radio] {
width: 60px; width: 60px;
height: 60px; height: 60px;
} }
input[type="radio"] + label { input[type=radio] + label {
margin-left: -60px; margin-left: -60px;
padding-left: 60px; padding-left: 60px;
background-size: 60px 60px; background-size: 60px 60px;
} }
input[type="radio"]:checked + label { input[type=radio]:checked + label {
background-size: 60px 60px; background-size: 60px 60px;
} }
} }

8186
frontend/fizika.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
<!doctype html> <!DOCTYPE html>
<html lang="hu-HU"> <html lang="hu-HU">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
@ -56,7 +56,19 @@
<script src="js/jquery.min.js"></script> <script src="js/jquery.min.js"></script>
<script src="js/load.js"></script> <script src="js/load.js"></script>
<script src="js/fizika.js"></script> <script src="js/fizika.js"></script>
<script type="module" src="js/stats.js"></script> <script
defer
data-domain="fizika.schmelczer.dev"
data-api="https://stats.schmelczer.dev/status"
src="https://stats.schmelczer.dev/js/script.file-downloads.hash.outbound-links.js"
></script>
<script>
window.plausible =
window.plausible ||
function () {
(window.plausible.q = window.plausible.q || []).push(arguments);
};
</script>
</head> </head>
<body onresize="aspect()"> <body onresize="aspect()">
<div id="titlebox"> <div id="titlebox">
@ -80,14 +92,10 @@
<p> <p>
A Tesztek fülre kattintva juthatsz el a gyakorló feladatlapokig, A Tesztek fülre kattintva juthatsz el a gyakorló feladatlapokig,
amiket <b>a te beállításaid alapján</b> állít össze az alkalmazás. Ezt amiket <b>a te beállításaid alapján</b> állít össze az alkalmazás. Ezt
egy egy <b>659 kérdést tartalmazó adatbázisból</b> viszi véghez, melyet az
<b előző <b>több mint 10 év</b> érettségijeinek feladatai alkotnak. Ebben
><span id="questionCount">682</span> kérdést tartalmazó a menüpontban nem csak témakör, hanem év alapján is
adatbázisból</b <b>rákereshetsz a feladatokra</b>.
>
viszi véghez, melyet az előző <b>majdnem 20 év</b> érettségijeinek
feladatai alkotnak. Ebben a menüpontban nemcsak témakör, hanem év
alapján is <b>rákereshetsz a feladatokra</b>.
</p> </p>
<p> <p>
A teszteken elért pontjaidat, és azokkal kapcsolatos egyéb infókat, az A teszteken elért pontjaidat, és azokkal kapcsolatos egyéb infókat, az
@ -96,7 +104,7 @@
<p> <p>
Ha hibát találtál, vagy javaslatod lenne az oldallal kapcsolatban, Ha hibát találtál, vagy javaslatod lenne az oldallal kapcsolatban,
<b>keress nyugodtan</b> <b>keress nyugodtan</b>
<a href="mailto:schmelczerandras@gmail.com">e-mail</a>en. <a href="mailto:schmelczerandras@gmail.com">e-mail</a>.
</p> </p>
<p style="margin-top: 0.5em"><i>Sikeres felkészülést!</i></p> <p style="margin-top: 0.5em"><i>Sikeres felkészülést!</i></p>
</div> </div>
@ -241,8 +249,8 @@
Válaszd ki, melyik feladatsort/feladatot szeretnéd betölteni. Ha Válaszd ki, melyik feladatsort/feladatot szeretnéd betölteni. Ha
valamelyik opciót <b>összes</b> álláson hagyod, akkor valamelyik opciót <b>összes</b> álláson hagyod, akkor
értelemszerűen úgy veszi a rendszer, mintha az adott listából az értelemszerűen úgy veszi a rendszer, mintha az adott listából az
összes elemet kiválasztottad volna. Egyes évekhez több mint összes elemet kiválasztottad volna. Egyes évekhez több, mint 2
kétféle feladatlap is tartozhat, ezeket az aktuális éveket féle feladatlap is tartozhat, ezeket az aktuális éveket
kiválasztva könnyű megtalálni. kiválasztva könnyű megtalálni.
</p> </p>
</div> </div>
@ -252,7 +260,7 @@
<!-- Years will be populated dynamically from question data --> <!-- Years will be populated dynamically from question data -->
</select> </select>
<select id="honap"> <select id="honap">
<option value="all">Összes feladatsor</option> <option value="all">Összes feladatsora</option>
<!-- Months will be populated dynamically based on selected year --> <!-- Months will be populated dynamically based on selected year -->
</select> </select>
<select id="feladat"> <select id="feladat">

View file

@ -1,6 +1,7 @@
var isSearch = false; var isSearch = false;
var totalPoints = 0; var totalPoints = 0;
var loadedQuestions = []; var currentPoints = 0;
var correctAnswersGiven = 0;
var timer = 0; var timer = 0;
var startTimer = 0; var startTimer = 0;
var reviewMode = 0; var reviewMode = 0;
@ -16,7 +17,7 @@ function aspect() {
async function ajaxLoad(type) { async function ajaxLoad(type) {
reviewMode = 0; reviewMode = 0;
totalPoints = 0; totalPoints = 0;
loadedQuestions = []; currentPoints = 0;
$("#state").html(""); $("#state").html("");
$("#state2").html(""); $("#state2").html("");
$("#percentage").html(""); $("#percentage").html("");
@ -28,13 +29,10 @@ async function ajaxLoad(type) {
try { try {
if (type == 1) { if (type == 1) {
var source = var source =
"^" + "^" + $("#evszam").val() + $("#honap").val() + $("#feladat").val() + "$";
$("#evszam").val() +
$("#honap").val() +
$("#feladat").val() +
"$";
for (var i = 0; i <= 3; i++) { for (var i = 0; i <= 3; i++) {
source = source.replace("all", ".*"); source = source.replace("all", ".*");
console.log(source);
} }
result = await loadQuestions(true, undefined, source, 1000000); result = await loadQuestions(true, undefined, source, 1000000);
} else if (type == 2) { } else if (type == 2) {
@ -58,7 +56,7 @@ async function ajaxLoad(type) {
"v", "v",
], ],
undefined, undefined,
15, 15
); );
} else { } else {
var NOQ = $("#numberof").val() ? $("#numberof").val() : 15; var NOQ = $("#numberof").val() ? $("#numberof").val() : 15;
@ -91,8 +89,6 @@ async function ajaxLoad(type) {
) { ) {
$("#megoldas").show(); $("#megoldas").show();
$("#state").html("Feladatok sikeresen letöltve!"); $("#state").html("Feladatok sikeresen letöltve!");
startTimer = 1;
timer = 0;
} }
} catch (error) { } catch (error) {
$("#loadingGif").hide(); $("#loadingGif").hide();
@ -113,84 +109,88 @@ async function ajaxLoad(type) {
</div> </div>
`); `);
$("#state").html("Hiba a feladatok betöltésekor"); $("#state").html("Hiba a feladatok betöltésekor");
console.error("Quiz loading error:", error); console.error('Quiz loading error:', error);
} }
} }
// Colour a single question green/red depending on whether the user selected function showCorrect(id, correctAns) {
// its correct answer, and report whether they got it right. teszt(id, correctAns);
function gradeQuestion(question) { eval(
var div = "#feladat" + question.id; "$('" +
var selected = "#form" + question.id + " #rad" + question.correct; "#label" +
var isCorrect = $(selected).is(":checked"); id +
$(div).animate({ backgroundColor: isCorrect ? "#C6FF8C" : "#FF808C" }, 1100); ".rad" +
return isCorrect; correctAns +
"').css('background-color', '#C6FF8C');"
);
$("#state").html("Helyes válaszok bejelölve!");
$("#state2").html(
"(Ellenőrző mód, az itteni eredményeid nem kerülnek elmentésre, a módból való kilépéshez tölts be egy új tesztsort!)"
);
} }
// Grade every question currently on screen and return the score as a string function teszt(id, correctAns) {
// percentage (e.g. "66.67"). Grading the whole known set in one pass replaces var div = "#feladat" + id;
// the old per-question click handlers, which accumulated across loads and var correct = "#form" + id + " #rad" + correctAns;
// could save bogus results. var isCorrect = $(correct).is(":checked");
function scoreLoadedQuestions() { currentPoints++;
var correctAnswersGiven = 0; if (isCorrect) {
loadedQuestions.forEach(function (question) { $(div).animate({ backgroundColor: "#C6FF8C" }, 1100);
if (gradeQuestion(question)) correctAnswersGiven++; correctAnswersGiven++;
}); } else {
$(div).animate({ backgroundColor: "#FF808C" }, 1100);
}
console.log(currentPoints, totalPoints, correctAnswersGiven);
if (currentPoints >= totalPoints) {
var percentage = (correctAnswersGiven / totalPoints) * 100; var percentage = (correctAnswersGiven / totalPoints) * 100;
percentage = Math.round(percentage * 100) / 100; percentage = Math.round(percentage * 100) / 100;
return percentage.toFixed(2); percentage = percentage.toFixed(2);
}
// "Kiértékelés": grade the answers and, unless we are already reviewing, save
// the result once.
function evaluate() {
if (!loadedQuestions.length) return;
var percentage = scoreLoadedQuestions();
$("#percentage").html("Eredmény: " + percentage + "%"); $("#percentage").html("Eredmény: " + percentage + "%");
$("#state").html("Válaszok leellenőrizve!"); $("#state").html("Válaszok leellenőrizve!");
if (isLocal && !reviewMode) { if (isLocal && !reviewMode) {
saveResult(percentage);
reviewMode = 1;
}
}
// "Helyes megoldások": reveal the correct answers. This is a review action, so
// we enter review mode *before* grading to guarantee it can never overwrite a
// previously saved result.
function showCorrect() {
if (!loadedQuestions.length) return;
reviewMode = 1;
var percentage = scoreLoadedQuestions();
loadedQuestions.forEach(function (question) {
$("#label" + question.id + ".rad" + question.correct).css(
"background-color",
"#C6FF8C",
);
});
$("#percentage").html("Eredmény: " + percentage + "%");
$("#state").html("Helyes válaszok bejelölve!");
$("#state2").show();
$("#state2").html(
"(Ellenőrző mód, az itteni eredményeid nem kerülnek elmentésre, a módból való kilépéshez tölts be egy új tesztsort!)",
);
}
function saveResult(percentage) {
var datum = new Date(); var datum = new Date();
var ido = Math.round(timer / 60); var ido = Math.round(timer / 60);
localStorage["teszt" + numberOfPreviousTests] = percentage; eval(
localStorage["teszt" + numberOfPreviousTests + "date"] = "localStorage.teszt" + numberOfPreviousTests + " = '" + percentage + "'"
datum.toLocaleDateString(); );
localStorage["teszt" + numberOfPreviousTests + "time"] = ido; eval(
localStorage["teszt" + numberOfPreviousTests + "total"] = totalPoints; "localStorage.teszt" +
numberOfPreviousTests +
"date = '" +
datum.toLocaleDateString() +
"'"
);
eval(
"localStorage.teszt" + numberOfPreviousTests + "time = '" + ido + "'"
);
eval(
"localStorage.teszt" +
numberOfPreviousTests +
"total = '" +
totalPoints +
"'"
);
startTimer = 0; startTimer = 0;
timer = 0; timer = 0;
$("#state2").show(); $("#state2").show();
$("#state2").html( $("#state2").html(
"Eredményed mentésre került! Ellenőrző módba belépve az eredményeid nem kerülnek tárolásra. A módból való kilépéshez tölts be egy új tesztsort!", "Eredményed mentésre került! Ellenőrző módba belépve az eredményeid nem kerülnek tárolásra. A módból való kilépéshez tölts be egy új tesztsort!"
); );
eredmeny(); eredmeny();
reviewMode = 1;
}
currentPoints = 0;
correctAnswersGiven = 0;
}
}
function scrollTo(where) {
$("html, body").animate(
{
scrollTop: $(where).offset().top - 100,
},
1000
);
} }
function howMany() { function howMany() {
@ -208,7 +208,7 @@ function eredmeny() {
if (isLocal) { if (isLocal) {
if (typeof localStorage.teszt1 !== "undefined") { if (typeof localStorage.teszt1 !== "undefined") {
$("#tablazat").html( $("#tablazat").html(
'<table id="ered"><tr><th></th><th>Dátum</th><th>Időtartam</th><th>Eredmény</th><th>Pontszám</th></tr></table>', '<table id="ered"><tr><th></th><th>Dátum</th><th>Időtartam</th><th>Eredmény</th><th>Pontszám</th></tr></table>'
); );
for (var i = 1; i < numberOfPreviousTests; i++) { for (var i = 1; i < numberOfPreviousTests; i++) {
var localString = "localStorage.teszt" + i; var localString = "localStorage.teszt" + i;
@ -232,7 +232,7 @@ function eredmeny() {
Math.round((eval(localString) * eval(totalString)) / 100) + Math.round((eval(localString) * eval(totalString)) / 100) +
"/" + "/" +
eval(totalString) + eval(totalString) +
" pont</td></tr>", " pont</td></tr>"
); );
} }
} else { } else {
@ -240,7 +240,7 @@ function eredmeny() {
} }
} else { } else {
$("#tablazat").html( $("#tablazat").html(
"<h2>Sajnos a böngésződ nem támogatja ezt a funkciót, tölts le egy modernebbet vagy jelentkezz be!</h2>", "<h2>Sajnos a böngésződ nem támogatja ezt a funkciót, tölts le egy modernebbet vagy jelentkezz be!</h2>"
); );
} }
} }
@ -260,13 +260,28 @@ setInterval(function () {
$(document).ready(function () { $(document).ready(function () {
eredmeny(); eredmeny();
// Populate the year dropdown, then the month dropdown for the default "all". // Initialize year dropdown with dynamic years from question data
initializeYearDropdown().then(() => initializeMonthDropdown("all/")); if (typeof initializeYearDropdown === 'function') {
initializeYearDropdown().then(() => {
// Initialize month dropdown for default "all" year selection
if (typeof initializeMonthDropdown === 'function') {
initializeMonthDropdown('all/');
}
});
}
$(window).on("mousewheel", function () { $(window).on("mousewheel", function () {
$("body").stop(); $("body").stop();
}); });
$(document).ajaxError(function (event, jqxhr, settings) {
if (!settings.secondExec) {
settings.secondExec = true;
setTimeout(function () {
$.ajax(settings);
}, 500);
}
});
aspect(); aspect();
$("#bfooldal").click(function () { $("#bfooldal").click(function () {
@ -312,7 +327,36 @@ $(document).ready(function () {
isSearch = false; isSearch = false;
}); });
$("#evszam").change(function () { $("#evszam").change(function () {
initializeMonthDropdown($("#evszam").val()); const selectedYear = $("#evszam").val();
// Initialize month dropdown dynamically based on selected year
if (typeof initializeMonthDropdown === 'function') {
initializeMonthDropdown(selectedYear);
} else {
// Fallback to original logic if dynamic function not available
if (selectedYear == "2006/") {
$(".f2006").show();
$(".f2016").hide();
$(".f").hide();
$(".fnem17").hide();
} else if (selectedYear == "2016/") {
$(".f2006").hide();
$(".f2016").show();
$(".f").show();
$(".fnem17").show();
} else if (selectedYear == "2017/") {
$(".f2006").hide();
$(".f2016").hide();
$(".f").show();
$(".fnem17").hide();
} else {
$(".f2006").hide();
$(".f2016").hide();
$(".f").show();
$(".fnem17").show();
}
}
$("#honap").val("all"); $("#honap").val("all");
}); });
@ -341,20 +385,12 @@ $(document).ready(function () {
$("#load").click(function (event) { $("#load").click(function (event) {
ajaxLoad(isSearch ? 1 : 3); ajaxLoad(isSearch ? 1 : 3);
}); });
$("#ans").click(function (event) {
event.preventDefault();
evaluate();
});
$("#cAns").click(function (event) {
event.preventDefault();
showCorrect();
});
$(".scroll").click(function () { $(".scroll").click(function () {
$("body").animate( $("body").animate(
{ {
scrollTop: $("#teszt").offset().top, scrollTop: $("#teszt").offset().top,
}, },
3000, 3000
); );
}); });
}); });
@ -383,28 +419,28 @@ $(document).ready(function () {
Math.max( Math.max(
Math.min( Math.min(
parseInt(g.pos * (g.end[0] - g.start[0]) + g.start[0]), parseInt(g.pos * (g.end[0] - g.start[0]) + g.start[0]),
255, 255
), ),
0, 0
), ),
Math.max( Math.max(
Math.min( Math.min(
parseInt(g.pos * (g.end[1] - g.start[1]) + g.start[1]), parseInt(g.pos * (g.end[1] - g.start[1]) + g.start[1]),
255, 255
), ),
0, 0
), ),
Math.max( Math.max(
Math.min( Math.min(
parseInt(g.pos * (g.end[2] - g.start[2]) + g.start[2]), parseInt(g.pos * (g.end[2] - g.start[2]) + g.start[2]),
255, 255
), ),
0, 0
), ),
].join(",") + ].join(",") +
")"; ")";
}; };
}, }
); );
function b(f) { function b(f) {
var e; var e;
@ -412,17 +448,15 @@ $(document).ready(function () {
return f; return f;
} }
if ( if (
(e = (e = /rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec(
/rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec( f
f,
)) ))
) { ) {
return [parseInt(e[1]), parseInt(e[2]), parseInt(e[3])]; return [parseInt(e[1]), parseInt(e[2]), parseInt(e[3])];
} }
if ( if (
(e = (e = /rgb\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*\)/.exec(
/rgb\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*\)/.exec( f
f,
)) ))
) { ) {
return [ return [

View file

@ -6,124 +6,112 @@ const getApiBase = () => {
const hostname = window.location.hostname; const hostname = window.location.hostname;
// If running on localhost, assume backend is on port 3001 // If running on localhost, assume backend is on port 3001
if (hostname === "localhost" || hostname === "127.0.0.1") { if (hostname === 'localhost' || hostname === '127.0.0.1') {
return `${protocol}//${hostname}:3001`; return `${protocol}//${hostname}:3001`;
} }
// For production, assume backend is on same origin // For production, assume backend is on same origin
return "https://fizika-backend.schmelczer.dev"; return "https://fizika-backend.schmelczer.dev"
}; };
const API_BASE = getApiBase(); const API_BASE = getApiBase();
// Fetch the question bank from the backend, which is the single source of
// truth. Cached after the first successful load.
const ensureQuestionsLoaded = async () => {
if (questions !== null) return questions;
const response = await fetch(`${API_BASE}/api/fizika`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
questions = await response.json();
return questions;
};
const escapeHtml = (value) =>
String(value).replace(
/[&<>"']/g,
(ch) =>
({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[ch],
);
// Question text is trusted but contains light markup (<sub>, <sup>, <br>) and
// pre-encoded entities. Escape everything first, then restore only that
// allowlist, so any other injected markup (e.g. <script>) stays inert.
const sanitizeQuestionHtml = (value) =>
escapeHtml(value)
.replace(/&lt;(\/?)(sub|sup)&gt;/g, "<$1$2>")
.replace(/&lt;br\s*\/?&gt;/g, "<br>")
.replace(/&amp;(lt|gt|amp|quot|#\d+|#x[0-9a-fA-F]+);/g, "&$1;");
const loadQuestions = async ( const loadQuestions = async (
isSearch, isSearch,
categories, categories,
sourceScheme, sourceScheme,
questionCount, questionCount
) => { ) => {
await ensureQuestionsLoaded(); if (questions === null) {
try {
const response = await fetch(`${API_BASE}/api/fizika`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
questions = await response.json();
console.log('Questions loaded from backend API');
} catch (error) {
console.warn('Failed to load questions from API, falling back to local file:', error);
try {
const fallbackResponse = await fetch("fizika.json");
if (!fallbackResponse.ok) {
throw new Error(`Local file not available: ${fallbackResponse.status}`);
}
questions = await fallbackResponse.json();
console.log('Questions loaded from local fallback file');
} catch (fallbackError) {
console.error('Both API and local file failed:', fallbackError);
throw new Error('Unable to load quiz data from either backend API or local file');
}
}
}
let currentQuestions = questions.slice(); let currentQuestions = questions.slice();
if (isSearch) { if (isSearch) {
currentQuestions = currentQuestions.filter((q) => currentQuestions = currentQuestions.filter((q) =>
q.source.match(sourceScheme), q.source.match(sourceScheme)
); );
} else { } else {
shuffleArray(currentQuestions); shuffleArray(currentQuestions);
currentQuestions = currentQuestions.filter((q) => currentQuestions = currentQuestions.filter((q) =>
categories.includes(q.type), categories.includes(q.type)
); );
} }
resultHtml = "";
currentQuestions = currentQuestions.slice(0, questionCount); currentQuestions = currentQuestions.slice(0, questionCount);
// Remember the exact set being shown. The two action buttons ("Kiértékelés"
// and "Helyes megoldások") are bound once in fizika.js and grade this whole
// set in a single pass. Previously each question injected its own #ans/#cAns
// click handler; those were never removed, so they piled up across loads and
// ended up grading stale (already-removed) questions, saving bogus 0%
// results over the user's history.
loadedQuestions = currentQuestions.map(({ id, correct }) => ({
id: Number(id),
correct: Number(correct),
}));
totalPoints = loadedQuestions.length;
let resultHtml = "";
currentQuestions.forEach( currentQuestions.forEach(
({ id, source, description, a, b, c, d, image }, i) => { ({ id, source, description, a, b, c, d, correct, image }, i) => {
const qid = Number(id);
const safeImage = image ? encodeURIComponent(image) : "";
resultHtml += ` resultHtml += `
<div class="feladat card" id="feladat${qid}"> <div class="feladat card" id="feladat${id}">
<h2 style="float: left;">${i + 1}.</h2><h2>${sanitizeQuestionHtml(source)}</h2> <h2 style="float: left;">${i + 1}.</h2><h2>${source}</h2>
<pre>${sanitizeQuestionHtml(description)}</pre> <pre>${description}</pre>
${image ? `<img src="${API_BASE}/api/pics/${safeImage}" crossorigin="anonymous" onerror="this.src='pics/${safeImage}'"><br>` : ""} ${image ? `<img src="${API_BASE}/api/pics/${image}" crossorigin="anonymous" onerror="this.src='pics/${image}'"><br>` : ""}
<form id="form${qid}"> <form id="form${id}"">
<input type="radio" id="rad1" name="group"> <input type="radio" id="rad1" name="group">
<label id="label${qid}" class="rad1">${sanitizeQuestionHtml(a)}</label> <label id="label${id}" class="rad1">${a}</label>
<br> <br>
<input type="radio" id="rad2" name="group"> <input type="radio" id="rad2" name="group">
<label id="label${qid}" class="rad2">${sanitizeQuestionHtml(b)}</label> <label id="label${id}" class="rad2">${b}</label>
<br> <br>
<input type="radio" id="rad3" name="group"> <input type="radio" id="rad3" name="group">
<label id="label${qid}" class="rad3">${sanitizeQuestionHtml(c)}</label> <label id="label${id}" class="rad3">${c}</label>
<br> <br>
${ ${d
d
? ` ? `
<input type="radio" id="rad4" name="group"> <input type="radio" id="rad4" name="group">
<label id="label${qid}" class="rad4">${sanitizeQuestionHtml(d)}</label> <label id="label${id}" class="rad4">${d}</label>
<br>` <br>`
: "" : ""
} }
</form> </form>
<script type="text/javascript">
$(document).ready(function(){
totalPoints++;
$("#ans").click(function(event){
event.preventDefault();
teszt(${id}, ${correct});
});
$("#cAns").click(function(event){
event.preventDefault();
showCorrect(${id}, ${correct});
});
});
</script>
</div> </div>
`; `;
}, }
); );
if (currentQuestions.length === 0) { resultHtml +=
resultHtml = currentQuestions.length === 0
'<div class="buttonwrapper"><b style="font-size: 2rem;">Nem található a keresésnek megfelelő feladat!</b></div>'; ? '<div class="buttonwrapper"><b style="font-size: 2rem;">Nem található a keresésnek megfelelő feladat!</b></div>'
} : `<script type="text/javascript">
startTimer = 1;
timer = 0;
</script>`;
return resultHtml; return resultHtml;
}; };
@ -138,15 +126,34 @@ function shuffleArray(array) {
// Initialize year dropdown with dynamic years from question data // Initialize year dropdown with dynamic years from question data
const initializeYearDropdown = async () => { const initializeYearDropdown = async () => {
try { try {
await ensureQuestionsLoaded(); // Load questions if not already loaded
if (questions === null) {
// Reflect the live question count on the info page. try {
const countEl = document.getElementById("questionCount"); const response = await fetch(`${API_BASE}/api/fizika`);
if (countEl) countEl.textContent = questions.length; if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
questions = await response.json();
console.log('Questions loaded for year dropdown initialization');
} catch (error) {
console.warn('Failed to load questions from API, falling back to local file:', error);
try {
const fallbackResponse = await fetch("fizika.json");
if (!fallbackResponse.ok) {
throw new Error(`Local file not available: ${fallbackResponse.status}`);
}
questions = await fallbackResponse.json();
console.log('Questions loaded from local fallback file for year dropdown');
} catch (fallbackError) {
console.error('Both API and local file failed:', fallbackError);
return; // Don't update dropdown if data unavailable
}
}
}
// Extract unique years from question sources // Extract unique years from question sources
const yearSet = new Set(); const yearSet = new Set();
questions.forEach((q) => { questions.forEach(q => {
const yearMatch = q.source.match(/^(\d{4})\//); const yearMatch = q.source.match(/^(\d{4})\//);
if (yearMatch) { if (yearMatch) {
yearSet.add(parseInt(yearMatch[1])); yearSet.add(parseInt(yearMatch[1]));
@ -157,18 +164,21 @@ const initializeYearDropdown = async () => {
const uniqueYears = Array.from(yearSet).sort((a, b) => b - a); const uniqueYears = Array.from(yearSet).sort((a, b) => b - a);
// Get existing dropdown // Get existing dropdown
const yearDropdown = document.getElementById("evszam"); const yearDropdown = document.getElementById('evszam');
if (!yearDropdown) return; if (!yearDropdown) return;
// Preserve the "Összes év" option and add dynamic years // Preserve the "Összes év" option and add dynamic years
const allYearsOption = '<option value="all/">Összes év</option>'; const allYearsOption = '<option value="all/">Összes év</option>';
const yearOptions = uniqueYears const yearOptions = uniqueYears.map(year =>
.map((year) => `<option value="${year}/">${year}</option>`) `<option value="${year}/">${year}</option>`
.join(""); ).join('');
yearDropdown.innerHTML = allYearsOption + yearOptions; yearDropdown.innerHTML = allYearsOption + yearOptions;
console.log('Year dropdown initialized with years:', uniqueYears);
} catch (error) { } catch (error) {
console.error("Failed to initialize year dropdown:", error); console.error('Failed to initialize year dropdown:', error);
} }
}; };
@ -176,16 +186,16 @@ const initializeYearDropdown = async () => {
const initializeMonthDropdown = (selectedYear) => { const initializeMonthDropdown = (selectedYear) => {
if (!questions) return; if (!questions) return;
const monthDropdown = document.getElementById("honap"); const monthDropdown = document.getElementById('honap');
if (!monthDropdown) return; if (!monthDropdown) return;
// Always include "Összes feladatsor" option // Always include "Összes feladatsora" option
let monthOptions = '<option value="all">Összes feladatsor</option>'; let monthOptions = '<option value="all">Összes feladatsora</option>';
if (selectedYear === "all/") { if (selectedYear === 'all/') {
// Show all possible month patterns // Show all possible month patterns
const monthSet = new Set(); const monthSet = new Set();
questions.forEach((q) => { questions.forEach(q => {
const sourceMatch = q.source.match(/^(\d{4})\/(.+?)\//); const sourceMatch = q.source.match(/^(\d{4})\/(.+?)\//);
if (sourceMatch) { if (sourceMatch) {
monthSet.add(sourceMatch[2]); monthSet.add(sourceMatch[2]);
@ -194,16 +204,17 @@ const initializeMonthDropdown = (selectedYear) => {
// Convert to sorted array and create options // Convert to sorted array and create options
const uniqueMonths = Array.from(monthSet).sort(); const uniqueMonths = Array.from(monthSet).sort();
uniqueMonths.forEach((month) => { uniqueMonths.forEach(month => {
monthOptions += `<option value="${month}">${getMonthLabel(month)}</option>`; monthOptions += `<option value="${month}" class="fdynamic">${getMonthLabel(month)}</option>`;
}); });
} else { } else {
// Extract year from selected value (e.g., "2024/" -> "2024") // Extract year from selected value (e.g., "2024/" -> "2024")
const year = selectedYear.replace("/", ""); const year = selectedYear.replace('/', '');
// Get unique months for this specific year // Get unique months for this specific year
const monthSet = new Set(); const monthSet = new Set();
questions.forEach((q) => { questions.forEach(q => {
const sourceMatch = q.source.match(`^${year}\/(.+?)\/`); const sourceMatch = q.source.match(`^${year}\/(.+?)\/`);
if (sourceMatch) { if (sourceMatch) {
monthSet.add(sourceMatch[1]); monthSet.add(sourceMatch[1]);
@ -213,77 +224,81 @@ const initializeMonthDropdown = (selectedYear) => {
const uniqueMonths = Array.from(monthSet).sort(); const uniqueMonths = Array.from(monthSet).sort();
// Special handling for known year patterns // Special handling for known year patterns
if (year === "2006") { if (year === '2006') {
// Preserve existing 2006 logic but add any new months found // Preserve existing 2006 logic but add any new months found
monthOptions += '<option value="1">Február-Március</option>'; monthOptions += '<option value="1" class="f2006">Február-Március</option>';
monthOptions += '<option value="2">Május-Június</option>'; monthOptions += '<option value="2" class="f2006">Május-Június</option>';
monthOptions += '<option value="3">Október-November</option>'; monthOptions += '<option value="3" class="f2006">Október-November</option>';
// Add any dynamic months not covered by the standard ones // Add any dynamic months not covered by the standard ones
uniqueMonths.forEach((month) => { uniqueMonths.forEach(month => {
if (!["1", "2", "3"].includes(month)) { if (!['1', '2', '3'].includes(month)) {
monthOptions += `<option value="${month}">${getMonthLabel(month)}</option>`; monthOptions += `<option value="${month}" class="f2006">${getMonthLabel(month)}</option>`;
} }
}); });
} else if (year === "2016") {
} else if (year === '2016') {
// Preserve existing 2016 logic but add any new months found // Preserve existing 2016 logic but add any new months found
monthOptions += '<option value="1">Május-Június</option>'; monthOptions += '<option value="1" class="f">Május-Június</option>';
monthOptions += '<option value="2">Október-November</option>'; monthOptions += '<option value="2" class="f">Október-November</option>';
monthOptions += '<option value="m1">1. Mintafeladatsor</option>'; monthOptions += '<option value="m1" class="f2016">1. Mintafeladatsor</option>';
monthOptions += '<option value="m2">2. Mintafeladatsor</option>'; monthOptions += '<option value="m2" class="f2016">2. Mintafeladatsor</option>';
monthOptions += '<option value="m3">3. Mintafeladatsor</option>'; monthOptions += '<option value="m3" class="f2016">3. Mintafeladatsor</option>';
// Add any dynamic months not covered // Add any dynamic months not covered
uniqueMonths.forEach((month) => { uniqueMonths.forEach(month => {
if (!["1", "2", "m1", "m2", "m3"].includes(month)) { if (!['1', '2', 'm1', 'm2', 'm3'].includes(month)) {
monthOptions += `<option value="${month}">${getMonthLabel(month)}</option>`; monthOptions += `<option value="${month}" class="f">${getMonthLabel(month)}</option>`;
} }
}); });
} else if (year === "2017") {
} else if (year === '2017') {
// Preserve existing 2017 logic but add any new months found // Preserve existing 2017 logic but add any new months found
monthOptions += '<option value="1">Május-Június</option>'; monthOptions += '<option value="1" class="f">Május-Június</option>';
monthOptions += '<option value="2">Október-November</option>'; monthOptions += '<option value="2" class="f">Október-November</option>';
// Add any dynamic months // Add any dynamic months
uniqueMonths.forEach((month) => { uniqueMonths.forEach(month => {
if (!["1", "2"].includes(month)) { if (!['1', '2'].includes(month)) {
monthOptions += `<option value="${month}">${getMonthLabel(month)}</option>`; monthOptions += `<option value="${month}" class="f">${getMonthLabel(month)}</option>`;
} }
}); });
} else { } else {
// For other years, use standard logic plus dynamic months // For other years, use standard logic plus dynamic months
const hasStandard1 = uniqueMonths.includes("1"); const hasStandard1 = uniqueMonths.includes('1');
const hasStandard2 = uniqueMonths.includes("2"); const hasStandard2 = uniqueMonths.includes('2');
if (hasStandard1) { if (hasStandard1) {
monthOptions += '<option value="1">Május-Június</option>'; monthOptions += '<option value="1" class="f">Május-Június</option>';
} }
if (hasStandard2) { if (hasStandard2) {
monthOptions += '<option value="2">Október-November</option>'; monthOptions += '<option value="2" class="f">Október-November</option>';
} }
// Add any non-standard months // Add any non-standard months
uniqueMonths.forEach((month) => { uniqueMonths.forEach(month => {
if (!["1", "2"].includes(month)) { if (!['1', '2'].includes(month)) {
monthOptions += `<option value="${month}">${getMonthLabel(month)}</option>`; monthOptions += `<option value="${month}" class="f">${getMonthLabel(month)}</option>`;
} }
}); });
} }
} }
monthDropdown.innerHTML = monthOptions; monthDropdown.innerHTML = monthOptions;
console.log(`Month dropdown initialized for year: ${selectedYear}`);
}; };
// Helper function to get readable month labels // Helper function to get readable month labels
const getMonthLabel = (monthValue) => { const getMonthLabel = (monthValue) => {
// Handle known patterns // Handle known patterns
const knownLabels = { const knownLabels = {
1: "Május-Június", '1': 'Május-Június',
2: "Október-November", '2': 'Október-November',
3: "Harmadik időszak", '3': 'Harmadik időszak',
m1: "1. Mintafeladatsor", 'm1': '1. Mintafeladatsor',
m2: "2. Mintafeladatsor", 'm2': '2. Mintafeladatsor',
m3: "3. Mintafeladatsor", 'm3': '3. Mintafeladatsor'
}; };
return knownLabels[monthValue] || monthValue; return knownLabels[monthValue] || monthValue;

View file

@ -1,10 +0,0 @@
import { init } from "./vendored.js";
init({
domain: "fizika.schmelczer.dev",
endpoint: "https://stats.schmelczer.dev/status",
autoCapturePageviews: true,
outboundLinks: true,
fileDownloads: true,
hashBasedRouting: true,
});

File diff suppressed because one or more lines are too long

View file

@ -1,22 +0,0 @@
{
"name": "fizika-frontend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fizika-frontend",
"version": "1.0.0",
"hasInstallScript": true,
"dependencies": {
"@plausible-analytics/tracker": "^0.4.5"
}
},
"node_modules/@plausible-analytics/tracker": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/@plausible-analytics/tracker/-/tracker-0.4.5.tgz",
"integrity": "sha512-6BfAGejXY+YA3Cw6LYT2Zpn4hTxDtPQAawFsYUsQCOg78wIS5C4deAGXTfJffa5VleMWITv5lpJ/EYuQBl1tPA==",
"license": "MIT"
}
}
}

View file

@ -1,13 +0,0 @@
{
"name": "fizika-frontend",
"version": "1.0.0",
"description": "Static frontend for the Fizika practice app",
"private": true,
"scripts": {
"vendor": "cp node_modules/@plausible-analytics/tracker/plausible.js js/vendored.js",
"postinstall": "npm run vendor"
},
"dependencies": {
"@plausible-analytics/tracker": "^0.4.5"
}
}

31
package-lock.json generated
View file

@ -1,31 +0,0 @@
{
"name": "fizika",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fizika",
"version": "1.0.0",
"devDependencies": {
"prettier": "^3.4.2"
}
},
"node_modules/prettier": {
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
}
}
}

View file

@ -1,13 +0,0 @@
{
"name": "fizika",
"version": "1.0.0",
"description": "Fizika emelt szintű érettségi gyakorló alkalmazás (repo tooling).",
"private": true,
"scripts": {
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"devDependencies": {
"prettier": "^3.4.2"
}
}