Compare commits

..

No commits in common. "main" and "asch/backend" have entirely different histories.

42 changed files with 9420 additions and 1748 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@v4
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
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@v4
- 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
}

117
README.md
View file

@ -1,106 +1,23 @@
# Fizika # Fizika - Physics Quiz Application
Practice web app for the Hungarian **emelt szintű fizika** (advanced-level A comprehensive physics quiz application for Hungarian students preparing for their physics exams (érettségi). The application consists of a frontend quiz interface and an admin backend for content management.
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>. ## 🚀 Features
## Architecture ### Student Interface (Frontend)
``` - Interactive physics quiz questions
frontend/ Static site (HTML + CSS + jQuery, no build step). - Multiple choice questions with immediate feedback
Deployed to Pages. Fetches questions from the backend API. - Category-based filtering (dynamics, mechanics, fluids, etc.)
- Search functionality by year, month, and question number
- Responsive design for desktop and mobile
- Progress tracking and scoring
- Timer functionality
backend/ Express server. Serves the admin panel and a small REST API for ### Admin Interface (Backend)
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 - 📝 Full CRUD operations for quiz questions
always fetches it from `GET /api/fizika` (see `frontend/js/load.js`); the - 🖼️ Image management (upload, view, delete)
question JSON is no longer shipped with the static site. - 📊 RESTful API for frontend integration
- 🛡️ Basic security features (input validation)
### Access control - 🐳 Docker containerization ready
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

134
backend/CLAUDE.md Normal file
View file

@ -0,0 +1,134 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Common Development Commands
### Running the Application
```bash
npm run dev # Development mode with nodemon hot reload
npm start # Production mode
```
### Docker Commands
```bash
# Production deployment
docker-compose up -d
# Development with hot reload
docker-compose --profile dev up fizika-admin-dev
# Build image manually
docker build -t fizika-admin ./backend
```
### Environment Setup
```bash
cp .env.example .env
# Edit .env with your configuration
```
## Architecture Overview
This is a single-file Node.js/Express backend (`server.js`) that serves as both:
1. **API Server**: Provides REST endpoints for question and image management
2. **Static File Server**: Serves the admin interface from `/public/` directory
### Key Components
**Data Storage**:
- Questions stored in JSON file at `../frontend/fizika.json` (configurable via `DATA_PATH`)
- Images stored in `../frontend/pics/` directory (configurable via `PICS_PATH`)
**Admin Interface**:
- Built-in web UI served from `/public/index.html`
- JavaScript client in `/public/admin.js`
- No authentication required (simplified for admin use)
**API Structure**:
- Public endpoints: `/api/fizika`, `/api/images`, `/api/pics/:filename`
- Admin endpoints: `/api/admin/questions`, `/api/admin/images`
- No JWT authentication implemented despite README documentation
### Question Data Format
```json
{
"id": 1,
"source": "2016/m1/1",
"description": "Question text...",
"a": "Option A",
"b": "Option B",
"c": "Option C",
"d": "Option D",
"correct": 2,
"type": "md",
"image": "optional-image.jpg"
}
```
### Complete Question Types (17 Categories)
**IMPORTANT**: The README only mentions 3 types, but the frontend supports all 17:
**Mechanics (Mechanika)**:
- `mec` - Mechanika (general mechanics)
- `mk` - Kinematika (kinematics)
- `md` - Dinamika (dynamics)
- `me` - Munka és energia (work and energy)
- `mf` - Folyadékok és gázok mechanikája (fluid mechanics)
- `mr` - Rezgések és hullámok (oscillations and waves)
**Thermodynamics**:
- `h` - Hőtan (thermodynamics)
**Electricity**:
- `ele` - Elektromosság (general electricity)
- `es` - Elektrosztatika (electrostatics)
- `ee` - Egyenáram (direct current)
- `ev` - Váltakozó áram (alternating current)
**Other Physics**:
- `m` - Mágnesesség (magnetism)
- `o` - Fénytan (optics)
- `atm` - Atomfizika (general atomic physics)
- `ah` - Atomhéj (electron shells)
- `am` - Atommag (atomic nucleus)
- `cs` - Égi mechanika, csillagászat (celestial mechanics, astronomy)
- `v` - Vegyes (mixed/various)
### Security Considerations
- Helmet.js for security headers
- CORS configuration via `FRONTEND_URL` environment variable
- File upload restricted to images only (5MB limit)
- Input validation minimal - add validation when modifying endpoints
## File Structure
```
backend/
├── server.js # Main application file
├── public/
│ ├── index.html # Admin interface HTML
│ └── admin.js # Admin interface JavaScript
├── package.json
├── Dockerfile
├── docker-compose.yml
└── .env.example
```
## Important Notes
- **Single File Architecture**: All server logic is in `server.js` - no separate route files or modules
- **File-based Data**: Uses JSON file for persistence, not a database
- **No Authentication**: Despite README documentation mentioning JWT, no auth is implemented
- **Path Dependencies**: Assumes frontend directory structure (`../frontend/fizika.json`, `../frontend/pics/`)
- **Admin UI Included**: Built-in web interface accessible at root path `/`
- **Question Types**: Support all 17 physics categories listed above, not just the 3 in README
## Making Changes
When modifying the API:
1. All changes go in `server.js`
2. Test both API endpoints and admin UI functionality
3. Ensure question type validation supports all 17 categories if adding validation
4. Consider impact on file paths and data format
5. Update environment variables in `.env.example` if needed

View file

@ -17,6 +17,7 @@ ENV NODE_ENV=production
ENV PORT=3001 ENV PORT=3001
EXPOSE 3001 EXPOSE 3001
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3001/api/fizika', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))" CMD node -e "require('http').get('http://localhost:3001/api/fizika', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))"

View file

@ -0,0 +1,70 @@
version: '3.8'
services:
fizika-admin:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "3001:3001"
environment:
- NODE_ENV=production
- PORT=3001
- FRONTEND_URL=${FRONTEND_URL:-*}
- DATA_PATH=${DATA_PATH:-/usr/src/app/fizika.json}
- PICS_PATH=${PICS_PATH:-/usr/src/app/pics}
volumes:
# Mount data files
- ./fizika.json:/usr/src/app/fizika.json:ro
- ./pics:/usr/src/app/pics
# Optional: mount for development
# - ./backend:/usr/src/app
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/api/fizika', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# Optional: Development service with hot reload
fizika-admin-dev:
build:
context: ./backend
dockerfile: Dockerfile
target: dev-dependencies
ports:
- "3001:3001"
environment:
- NODE_ENV=development
- PORT=3001
- FRONTEND_URL=*
- DATA_PATH=/usr/src/app/fizika.json
- PICS_PATH=/usr/src/app/pics
volumes:
- ./backend:/usr/src/app
- ./fizika.json:/usr/src/app/fizika.json
- ./pics:/usr/src/app/pics
- /usr/src/app/node_modules
command: npm run dev
profiles: ["dev"]
# Optional: Nginx reverse proxy for production
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- .:/usr/share/nginx/html:ro
# SSL certificates (if using HTTPS)
# - ./ssl:/etc/ssl/certs
depends_on:
- fizika-admin
restart: unless-stopped
profiles: ["nginx"]
networks:
default:
name: fizika-network

File diff suppressed because it is too large Load diff

View file

@ -5,20 +5,18 @@
"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,20 +1,5 @@
const API_BASE = window.location.origin; const API_BASE = window.location.origin;
// Escape user-supplied text before interpolating it into HTML, to prevent XSS.
function escapeHtml(value) {
return String(value).replace(
/[&<>"']/g,
(ch) =>
({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[ch],
);
}
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
loadQuestions(); loadQuestions();
loadImages(); loadImages();
@ -22,31 +7,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 +64,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,
)}...</p> 100
<p><strong>Típus:</strong> ${escapeHtml(q.type)} | <strong>Helyes válasz:</strong> ${ )}...</p>
["A", "B", "C", "D"][q.correct - 1] <p><strong>Típus:</strong> ${q.type
}</p> } | <strong>Helyes válasz:</strong> ${["A", "B", "C", "D"][q.correct - 1]
}</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 +120,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 +160,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 +172,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 +184,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 +220,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 +259,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 +275,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) {
@ -324,4 +302,4 @@ function showAlert(elementId, message, type) {
setTimeout(() => { setTimeout(() => {
alertDiv.style.display = "none"; alertDiv.style.display = "none";
}, 5000); }, 5000);
} }

View file

@ -1,10 +1,22 @@
<!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>
<script>
window.plausible =
window.plausible ||
function () {
(window.plausible.q = window.plausible.q || []).push(arguments);
};
</script>
<style> <style>
* { * {
margin: 0; margin: 0;
@ -326,17 +338,20 @@
<div class="form-group"> <div class="form-group">
<label for="questionType">Típus:</label> <label for="questionType">Típus:</label>
<select id="questionType" required> <select id="questionType" required>
<option value="mec">Mechanika</option>
<option value="mk">Kinematika</option> <option value="mk">Kinematika</option>
<option value="md">Dinamika</option> <option value="md">Dinamika</option>
<option value="me">Munka és energia</option> <option value="me">Munka és energia</option>
<option value="mf">Folyadékok és gázok mechanikája</option> <option value="mf">Folyadékok és gázok mechanikája</option>
<option value="mr">Rezgések és hullámok</option> <option value="mr">Rezgések és hullámok</option>
<option value="h">Hőtan</option> <option value="h">Hőtan</option>
<option value="ele">Elektromosság</option>
<option value="es">Elektrosztatika</option> <option value="es">Elektrosztatika</option>
<option value="ee">Egyenáram</option> <option value="ee">Egyenáram</option>
<option value="ev">Váltakozó áram</option> <option value="ev">Váltakozó áram</option>
<option value="m">Mágnesesség</option> <option value="m">Mágnesesség</option>
<option value="o">Fénytan</option> <option value="o">Fénytan</option>
<option value="atm">Atomfizika</option>
<option value="ah">Atomhéj</option> <option value="ah">Atomhéj</option>
<option value="am">Atommag</option> <option value="am">Atommag</option>
<option value="cs">Égi mechanika, csillagászat</option> <option value="cs">Égi mechanika, csillagászat</option>

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,66 +1,26 @@
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({ app.use(cors({
contentSecurityPolicy: { origin: process.env.FRONTEND_URL || '*',
directives: { credentials: true
defaultSrc: [ }));
"'self'",
"https://stats.schmelczer.dev",
"'unsafe-inline'",
],
scriptSrc: [
"'self'",
"https://stats.schmelczer.dev",
"'unsafe-inline'",
],
},
},
}),
);
app.use(
cors({
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 +28,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: 5 * 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 +54,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 +99,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 +117,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. app.listen(PORT, () => {
if (require.main === module) { console.log(`Fizika Admin Backend running on port ${PORT}`);
app.listen(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

@ -1,32 +1,31 @@
.button { .button {
font-family: "Roboto", serif; font-family: "Roboto", serif;
color: #003366; color: #003366;
font-weight: 400; font-weight: 400;
font-size: 1.5em; font-size: 1.5em;
padding: 0.75em; padding: 0.75em;
margin: 1em; margin: 1em;
width: auto; width: auto;
cursor: pointer; cursor: pointer;
background-color: rgba(0, 0, 0, 0); background-color: rgba(0,0,0,0);
display: inline-block; display: inline-block;
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), -webkit-user-select: none;
0 6px 20px 0 rgba(0, 0, 0, 0.15); -moz-user-select: none;
-webkit-user-select: none; -ms-user-select: none;
-moz-user-select: none; user-select: none;
-ms-user-select: none;
user-select: none;
} }
.button:hover { .button:hover {
box-shadow: none; box-shadow:none;
} }
@media only screen and (max-width: 1000px) { @media only screen and (max-width: 1000px){
.button { .button {
font-size: 1.45em; font-size: 1.45em;
padding: 0.3em; padding: 0.3em;
margin: 0.15em; margin: 0.15em;
} }
} }

View file

@ -1,47 +1,44 @@
.card { .card {
background-color: #fff; background-color: #fff;
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), height: auto;
0 6px 20px 0 rgba(0, 0, 0, 0.09); width: auto;
height: auto; overflow: auto;
width: auto;
overflow: auto;
} }
h2 { h2 {
font-family: "Roboto", sans-serif; font-family: "Roboto", sans-serif;
font-weight: 300; font-weight: 300;
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;
font-family: "Open sans", sans-serif; font-family: "Open sans", sans-serif;
font-weight: 300; font-weight: 300;
font-size: 1.5em; font-size: 1.5em;
white-space: normal; white-space: normal;
width: 100%; width: 100%;
} }
img { img {
display: block; display: block;
max-height: 100%; max-height: 100%;
float: right; float: right;
-webkit-user-select: none; -webkit-user-select: none;
-moz-user-select: none; -moz-user-select: none;
-ms-user-select: none; -ms-user-select: none;
user-select: none; user-select: none;
margin: 1px; margin: 1px;
max-width: 500px;
} }
@media only screen and (max-width: 1000px) { @media only screen and (max-width: 1000px){
h2 { h2 {
font-size: 1.6em; font-size: 1.6em;
} }
pre { pre {
font-size: 1em; font-size: 1em;
} }
} }

View file

@ -1,272 +1,268 @@
body { body {
font-size: 1em; font-size: 1em;
text-align: center; text-align: center;
color: #003366; color: #003366;
background-image: url("../auxIMG/bg.jpg"); background-image: url("../auxIMG/bg.jpg");
background-color: #fff; background-color: #fff;
background-repeat: repeat; background-repeat: repeat;
word-wrap: break-word; word-wrap: break-word;
margin-bottom: 1em; margin-bottom: 1em;
} }
::-moz-selection { ::-moz-selection {
background: #003366; background: #003366;
color: #fff; color: #fff;
text-shadow: none; text-shadow: none;
} }
::selection { ::selection {
background: #003366; background: #003366;
color: #fff; color: #fff;
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;
} }
h1 { h1 {
font-family: "Poiret One", "Montserrat", serif; font-family: "Poiret One", "Montserrat", serif;
font-size: 5em; font-size: 5em;
text-align: center; text-align: center;
margin: 0.2em 0 0.3em 0; margin: 0.2em 0 0.3em 0;
-webkit-user-select: none; -webkit-user-select: none;
-moz-user-select: none; -moz-user-select: none;
-ms-user-select: none; -ms-user-select: none;
user-select: none; user-select: none;
display: inline; display: inline;
transition: all 0.6s ease-out; transition: all 0.6s ease-out;
} }
#titlebox { #titlebox {
padding-bottom: 0.5em; padding-bottom: 0.5em;
} }
hr { hr {
border-bottom: 2px solid #003366; border-bottom: 2px solid #003366;
width: 35%; width: 35%;
} }
.menu { .menu {
text-align: center; text-align: center;
width: 100%; width: 100%;
margin: auto; margin: auto;
font-weight: 400; font-weight: 400;
-webkit-user-select: none; -webkit-user-select: none;
-moz-user-select: none; -moz-user-select: none;
-ms-user-select: none; -ms-user-select: none;
user-select: none; user-select: none;
} }
ul { ul {
list-style-type: none; list-style-type: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
li { li {
font-family: "Roboto", serif; font-family: "Roboto", serif;
font-weight: 300; font-weight: 300;
display: inline-block; display: inline-block;
padding: 0.5em 1em; padding: 0.5em 1em;
font-size: 1.5em; font-size: 1.5em;
} }
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%;
margin: auto; margin: auto;
padding-top: 0.5em; padding-top: 0.5em;
} }
#bfooldal { #bfooldal {
font-weight: 700; font-weight: 700;
} }
.szoveg, .szoveg, #buttonwrapper {
#buttonwrapper { width: 80%;
width: 80%; margin: auto;
margin: auto;
} }
p:first-of-type { p:first-of-type {
padding-top: 2em; padding-top: 2em;
} }
p { 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;
} }
p:last-of-type { p:last-of-type {
padding-bottom: 2em; padding-bottom: 2em;
} }
.buttonwrapper { .buttonwrapper {
text-align: center; text-align: center;
} }
#teszt, #teszt, #eredmenyek, #kereses, #temakor {
#eredmenyek, display: none;
#kereses,
#temakor {
display: none;
} }
#temak { #temak {
width: 100%; width: 100%;
display: flex; display: flex;
text-align: left; text-align: left;
} }
.fele { .fele {
width: 40%; width: 40%;
display: block; display: block;
} }
#bal { #bal {
float: left; float: left;
margin-left: 20%; margin-left: 20%;
} }
#jobb { #jobb {
float: right; float: right;
} }
.negyzet { .negyzet {
margin-left: 1.5em; margin-left: 1.5em;
font-family: "Open sans", sans-serif; font-family: "Open sans", sans-serif;
font-weight: 400; font-weight: 400;
} }
.main { .main {
font-size: 1.5em; font-size: 1.5em;
margin-left: 0; margin-left: 0;
font-family: "Roboto", serif; font-family: "Roboto", serif;
} }
#numberof { #numberof {
box-sizing: border-box; box-sizing: border-box;
border: 2px solid #ccc; border: 2px solid #ccc;
border-radius: 4px; border-radius: 4px;
font-size: 1.2em; font-size: 1.2em;
font-weight: 300; font-weight: 300;
background-color: white; background-color: white;
width: 63%; width: 63%;
} }
#load, .f2006, .f2016 {
#loadingGif { display: none;
display: none; }
#load, #loadingGif {
display: none;
} }
#loading { #loading {
width: 100%; width: 100%;
text-align: center; text-align: center;
height: auto; height: auto;
} }
#loadingGif { #loadingGif {
float: none; float: none;
margin: auto; margin: auto;
padding-bottom: 2em; padding-bottom: 2em;
} }
#megoldas { #megoldas {
display: none; display: none;
} }
#percentage { #percentage {
font-family: "Poiret One", "Montserrat", serif; font-family: "Poiret One", "Montserrat", serif;
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; padding: 0.6em;
padding: 0.6em;
} }
#content { #content {
margin: auto; margin: auto;
} }
select { select {
box-sizing: border-box; box-sizing: border-box;
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;
} }
table { table {
border-collapse: collapse; border-collapse: collapse;
margin: auto; margin : auto;
width: 100%; width: 100%;
}
th, td {
text-align: left;
padding: 0.5em;
} }
th, tr:nth-child(even){
td { background-color: #f2f2f2;
text-align: left;
padding: 0.5em;
} }
tr:nth-child(even) { th {
background-color: #f2f2f2; background-color: #003366;
} color: white;
th {
background-color: #003366;
color: white;
} }
h4 { h4 {
margin: auto; margin: auto;
font-size: 0.75em;
}
@media only screen and (max-width: 1000px) {
body {
font-size: 1.75em;
}
h1 {
font-size: 7em;
}
h1:hover {
text-shadow: none;
}
#wrapper {
width: 90%;
}
.szoveg {
width: 90%;
}
#numberof {
font-size: 0.6em;
}
img {
max-width: 400px;
}
select {
font-size: 0.8em;
width: 30%;
}
hr {
border-bottom-width: 4px;
width: 50%;
}
li {
font-size: 1.75em;
padding: 0.25em;
}
p {
font-size: 0.75em; font-size: 0.75em;
}
p:first-of-type {
padding-top: 0.5em;
}
p:last-of-type {
padding-bottom: 0.5em;
}
#tablazat {
font-size: 0.8em;
}
.mbcheck {
font-size: 0.75em;
}
#bal {
margin-left: 0;
}
} }
@media only screen and (max-width: 1000px){
body {
font-size: 1.75em;
}
h1 {
font-size: 7em;
}
h1:hover {
text-shadow: none;
}
#wrapper {
width: 90%;
}
.szoveg {
width: 90%;
}
#numberof {
font-size: 0.6em;
}
img {
max-width: 500px;
}
select {
font-size: 0.8em;
width: 30%;
}
hr {
border-bottom-width: 4px;
width: 50%;
}
li {
font-size: 1.75em;
padding: 0.25em;
}
p {
font-size: 0.75em;
}
p:first-of-type {
padding-top: 0.5em;
}
p:last-of-type {
padding-bottom: 0.5em;
}
#tablazat {
font-size: 0.8em;
}
.mbcheck {
font-size: 0.75em;
}
#bal {
margin-left: 0;
}
}

View file

@ -1,44 +1,44 @@
#preload { #preload {
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;
} }
} }

View file

@ -1,19 +1,19 @@
{ {
"name": "Fizika", "name": "Fizika",
"icons": [ "icons": [
{ {
"src": "favicons/android-chrome-192x192.png", "src": "favicons/android-chrome-192x192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png" "type": "image/png"
}, },
{ {
"src": "favicons/android-chrome-384x384.png", "src": "favicons/android-chrome-384x384.png",
"sizes": "384x384", "sizes": "384x384",
"type": "image/png" "type": "image/png"
} }
], ],
"theme_color": "#ffffff", "theme_color": "#ffffff",
"background_color": "#ffffff", "background_color": "#ffffff",
"display": "standalone", "display": "standalone",
"orientation": "portrait" "orientation": "portrait"
} }

7910
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');"
);
// Grade every question currently on screen and return the score as a string
// percentage (e.g. "66.67"). Grading the whole known set in one pass replaces
// the old per-question click handlers, which accumulated across loads and
// could save bogus results.
function scoreLoadedQuestions() {
var correctAnswersGiven = 0;
loadedQuestions.forEach(function (question) {
if (gradeQuestion(question)) correctAnswersGiven++;
});
var percentage = (correctAnswersGiven / totalPoints) * 100;
percentage = Math.round(percentage * 100) / 100;
return 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 + "%");
$("#state").html("Válaszok leellenőrizve!");
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!"); $("#state").html("Helyes válaszok bejelölve!");
$("#state2").show();
$("#state2").html( $("#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!)", "(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) { function teszt(id, correctAns) {
var datum = new Date(); var div = "#feladat" + id;
var ido = Math.round(timer / 60); var correct = "#form" + id + " #rad" + correctAns;
localStorage["teszt" + numberOfPreviousTests] = percentage; var isCorrect = $(correct).is(":checked");
localStorage["teszt" + numberOfPreviousTests + "date"] = currentPoints++;
datum.toLocaleDateString(); if (isCorrect) {
localStorage["teszt" + numberOfPreviousTests + "time"] = ido; $(div).animate({ backgroundColor: "#C6FF8C" }, 1100);
localStorage["teszt" + numberOfPreviousTests + "total"] = totalPoints; correctAnswersGiven++;
startTimer = 0; } else {
timer = 0; $(div).animate({ backgroundColor: "#FF808C" }, 1100);
$("#state2").show(); }
$("#state2").html( console.log(currentPoints, totalPoints, correctAnswersGiven);
"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!", if (currentPoints >= totalPoints) {
var percentage = (correctAnswersGiven / totalPoints) * 100;
percentage = Math.round(percentage * 100) / 100;
percentage = percentage.toFixed(2);
$("#percentage").html("Eredmény: " + percentage + "%");
$("#state").html("Válaszok leellenőrizve!");
if (isLocal && !reviewMode) {
var datum = new Date();
var ido = Math.round(timer / 60);
eval(
"localStorage.teszt" + numberOfPreviousTests + " = '" + percentage + "'"
);
eval(
"localStorage.teszt" +
numberOfPreviousTests +
"date = '" +
datum.toLocaleDateString() +
"'"
);
eval(
"localStorage.teszt" + numberOfPreviousTests + "time = '" + ido + "'"
);
eval(
"localStorage.teszt" +
numberOfPreviousTests +
"total = '" +
totalPoints +
"'"
);
startTimer = 0;
timer = 0;
$("#state2").show();
$("#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!"
);
eredmeny();
reviewMode = 1;
}
currentPoints = 0;
correctAnswersGiven = 0;
}
}
function scrollTo(where) {
$("html, body").animate(
{
scrollTop: $(where).offset().top - 100,
},
1000
); );
eredmeny();
} }
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;
@ -218,21 +218,21 @@ function eredmeny() {
var isGood = eval(localString); var isGood = eval(localString);
$("#ered tr:last").after( $("#ered tr:last").after(
"<tr><td>" + "<tr><td>" +
i + i +
".</td><td>" + ".</td><td>" +
eval(datumString) + eval(datumString) +
"</td><td>" + "</td><td>" +
eval(timeString) + eval(timeString) +
" perc</td>" + " perc</td>" +
"<td style='color: hsl(" + "<td style='color: hsl(" +
isGood + isGood +
",100%,50%);''> <b>" + ",100%,50%);''> <b>" +
eval(localString) + eval(localString) +
"%</b></td><td>" + "%</b></td><td>" +
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>"
); );
} }
} }
@ -259,14 +259,29 @@ 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,18 +448,16 @@ $(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 [
parseFloat(e[1]) * 2.55, parseFloat(e[1]) * 2.55,

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}" 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]);
@ -211,80 +222,84 @@ 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") {
// Preserve existing 2016 logic but add any new months found } else if (year === '2016') {
monthOptions += '<option value="1">Május-Június</option>'; // Preserve existing 2016 logic but add any new months found
monthOptions += '<option value="2">Október-November</option>'; monthOptions += '<option value="1" class="f">Május-Június</option>';
monthOptions += '<option value="m1">1. Mintafeladatsor</option>'; monthOptions += '<option value="2" class="f">Október-November</option>';
monthOptions += '<option value="m2">2. Mintafeladatsor</option>'; monthOptions += '<option value="m1" class="f2016">1. Mintafeladatsor</option>';
monthOptions += '<option value="m3">3. Mintafeladatsor</option>'; monthOptions += '<option value="m2" class="f2016">2. 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"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

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"
}
}