Compare commits
1 commit
main
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6f4e3105c |
32 changed files with 9476 additions and 1743 deletions
|
|
@ -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
|
|
||||||
|
|
@ -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 }}
|
|
||||||
|
|
@ -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
6
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
36
.github/workflows/deploy.yaml
vendored
Normal file
36
.github/workflows/deploy.yaml
vendored
Normal 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
86
.github/workflows/docker-publish.yml
vendored
Normal 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}
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
{
|
|
||||||
"printWidth": 80,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"semi": true,
|
|
||||||
"singleQuote": false
|
|
||||||
}
|
|
||||||
105
README.md
105
README.md
|
|
@ -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.
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
node_modules
|
node_modules
|
||||||
Dockerfile
|
Dockerfile
|
||||||
.dockerignore
|
.dockerignore
|
||||||
*.test.js
|
|
||||||
856
backend/package-lock.json
generated
856
backend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
};
|
||||||
({
|
|
||||||
"&": "&",
|
|
||||||
"<": "<",
|
|
||||||
">": ">",
|
|
||||||
'"': """,
|
|
||||||
"'": "'",
|
|
||||||
})[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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
});
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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
8186
frontend/fizika.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 [
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
|
||||||
({
|
|
||||||
"&": "&",
|
|
||||||
"<": "<",
|
|
||||||
">": ">",
|
|
||||||
'"': """,
|
|
||||||
"'": "'",
|
|
||||||
})[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(/<(\/?)(sub|sup)>/g, "<$1$2>")
|
|
||||||
.replace(/<br\s*\/?>/g, "<br>")
|
|
||||||
.replace(/&(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;
|
||||||
|
|
|
||||||
|
|
@ -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
22
frontend/package-lock.json
generated
22
frontend/package-lock.json
generated
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
31
package-lock.json
generated
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
13
package.json
13
package.json
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue