Add admin backend #4

Merged
schmelczer merged 9 commits from asch/backend into main 2025-08-31 13:52:10 +01:00
280 changed files with 10743 additions and 8079 deletions

View file

@ -17,7 +17,6 @@ concurrency:
cancel-in-progress: false
jobs:
# Single deploy job since we're just deploying
deploy:
environment:
name: github-pages
@ -31,7 +30,7 @@ jobs:
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: "."
path: "frontend"
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

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

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

69
.gitignore vendored
View file

@ -1,68 +1 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
firebase-debug.log*
firebase-debug.*.log*
# Firebase cache
.firebase/
# Firebase config
# Uncomment this if you'd like others to create their own Firebase project.
# For a team working on the same Firebase project(s), it is recommended to leave
# it commented so all members can deploy to the same project(s) in .firebaserc.
# .firebaserc
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.DS_Store
node_modules

View file

@ -1 +1,23 @@
# Fizika weboldal
# Fizika - Physics Quiz Application
A comprehensive physics quiz application for Hungarian students preparing for their physics exams (érettségi). The application consists of a frontend quiz interface and an admin backend for content management.
## 🚀 Features
### Student Interface (Frontend)
- Interactive physics quiz questions
- Multiple choice questions with immediate feedback
- Category-based filtering (dynamics, mechanics, fluids, etc.)
- Search functionality by year, month, and question number
- Responsive design for desktop and mobile
- Progress tracking and scoring
- Timer functionality
### Admin Interface (Backend)
- 📝 Full CRUD operations for quiz questions
- 🖼️ Image management (upload, view, delete)
- 📊 RESTful API for frontend integration
- 🛡️ Basic security features (input validation)
- 🐳 Docker containerization ready

3
backend/.dockerignore Normal file
View file

@ -0,0 +1,3 @@
node_modules
Dockerfile
.dockerignore

10
backend/.env.example Normal file
View file

@ -0,0 +1,10 @@
# Server Configuration
PORT=3001
NODE_ENV=development
# CORS Configuration
FRONTEND_URL=*
# File Paths
DATA_PATH=../fizika.json
PICS_PATH=../pics

134
backend/CLAUDE.md Normal file
View file

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

61
backend/Dockerfile Normal file
View file

@ -0,0 +1,61 @@
# Multi-stage Dockerfile for Fizika Admin Backend
FROM node:18-alpine AS base
# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init
# Create app directory
WORKDIR /usr/src/app
# Create non-root user
RUN addgroup -g 1001 -S nodejs
RUN adduser -S fizika -u 1001
# Copy package files
COPY package*.json ./
# Development stage
FROM base AS dependencies
# Install dependencies
RUN npm ci --only=production && npm cache clean --force
# Development dependencies for building
FROM base AS dev-dependencies
RUN npm ci
# Production stage
FROM base AS production
# Copy production dependencies
COPY --from=dependencies /usr/src/app/node_modules ./node_modules
# Copy application code
COPY --chown=fizika:nodejs . .
# Create necessary directories and set permissions
RUN mkdir -p /usr/src/app/data /usr/src/app/pics && \
chown -R fizika:nodejs /usr/src/app/data /usr/src/app/pics
# Security: Remove package files and any other sensitive data
RUN rm -f package*.json
# Set environment variables
ENV NODE_ENV=production
ENV PORT=3001
# Expose port
EXPOSE 3001
# Switch to non-root user
USER fizika
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3001/api/fizika', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))"
# Use dumb-init for proper signal handling
ENTRYPOINT ["dumb-init", "--"]
# Start the application
CMD ["node", "server.js"]

View file

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

1406
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

22
backend/package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "fizika-admin-backend",
"version": "1.0.0",
"description": "Backend for editing Fizika quiz data",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"express": "^4.18.2",
"multer": "^1.4.5-lts.1",
"cors": "^2.8.5",
"helmet": "^7.1.0"
},
"devDependencies": {
"nodemon": "^3.0.2"
},
"engines": {
"node": ">=18.0.0"
}
}

305
backend/public/admin.js Normal file
View file

@ -0,0 +1,305 @@
const API_BASE = window.location.origin;
document.addEventListener("DOMContentLoaded", function () {
loadQuestions();
loadImages();
setupEventListeners();
});
function setupEventListeners() {
document.querySelectorAll('.tab[data-tab]').forEach(tab => {
tab.addEventListener('click', (e) => {
switchTab(e.target.dataset.tab);
});
});
document.getElementById('addQuestionBtn').addEventListener('click', showAddQuestionModal);
document.getElementById('imageUpload').addEventListener('change', uploadImage);
document.getElementById('closeModalBtn').addEventListener('click', closeModal);
document.getElementById('cancelBtn').addEventListener('click', closeModal);
document.getElementById('questionForm').addEventListener('submit', saveQuestion);
document.getElementById('questionModal').addEventListener('click', (e) => {
if (e.target.id === 'questionModal') {
closeModal();
}
});
}
function switchTab(tabName) {
document
.querySelectorAll(".tab-content")
.forEach((tab) => tab.classList.remove("active"));
document
.querySelectorAll(".tab")
.forEach((tab) => tab.classList.remove("active"));
document.getElementById(tabName + "Tab").classList.add("active");
document.querySelector(`[data-tab="${tabName}"]`).classList.add("active");
if (tabName === "images") loadImages();
}
async function loadQuestions() {
try {
const response = await fetch(`${API_BASE}/api/admin/questions`);
if (response.ok) {
const questions = await response.json();
displayQuestions(questions);
} else {
throw new Error("Failed to load");
}
} catch (error) {
document.getElementById("questionsList").innerHTML =
`<div class="alert alert-danger">Hiba a kérdések betöltésekor ${error.message}</div>`;
}
}
function displayQuestions(questions) {
const container = document.getElementById("questionsList");
container.innerHTML = questions
.map(
(q) => `
<div class="question-item">
<h4>ID: ${q.id} - ${q.source}</h4>
<p><strong>Kérdés:</strong> ${q.description.substring(
0,
100
)}...</p>
<p><strong>Típus:</strong> ${q.type
} | <strong>Helyes válasz:</strong> ${["A", "B", "C", "D"][q.correct - 1]
}</p>
<div class="question-actions">
<button data-edit-id="${q.id}">Szerkesztés</button>
<button class="danger" data-delete-id="${q.id}">Törlés</button>
</div>
</div>
`
)
.join("");
container.querySelectorAll('[data-edit-id]').forEach(btn => {
btn.addEventListener('click', (e) => {
editQuestion(parseInt(e.target.dataset.editId));
});
});
container.querySelectorAll('[data-delete-id]').forEach(btn => {
btn.addEventListener('click', (e) => {
deleteQuestion(parseInt(e.target.dataset.deleteId));
});
});
}
function showAddQuestionModal() {
document.getElementById("modalTitle").textContent =
"Új kérdés hozzáadása";
document.getElementById("questionForm").reset();
document.getElementById("questionId").value = "";
document.getElementById("questionModal").style.display = "block";
}
async function editQuestion(id) {
try {
const response = await fetch(`${API_BASE}/api/admin/questions`);
if (response.ok) {
const questions = await response.json();
const question = questions.find((q) => q.id === id);
if (question) {
document.getElementById("modalTitle").textContent =
"Kérdés szerkesztése";
document.getElementById("questionId").value = question.id;
document.getElementById("questionSource").value = question.source;
document.getElementById("questionDescription").value =
question.description;
document.getElementById("questionA").value = question.a;
document.getElementById("questionB").value = question.b;
document.getElementById("questionC").value = question.c;
document.getElementById("questionD").value = question.d;
document.getElementById("questionCorrect").value =
question.correct;
document.getElementById("questionType").value = question.type;
document.getElementById("questionImage").value =
question.image || "";
document.getElementById("questionModal").style.display = "block";
}
}
} catch (error) {
showAlert("questionsAlert", "Hiba a kérdés betöltésekor", "danger");
}
}
async function saveQuestion(event) {
event.preventDefault();
const data = {
source: document.getElementById("questionSource").value,
description: document.getElementById("questionDescription").value,
a: document.getElementById("questionA").value,
b: document.getElementById("questionB").value,
c: document.getElementById("questionC").value,
d: document.getElementById("questionD").value,
correct: parseInt(document.getElementById("questionCorrect").value),
type: document.getElementById("questionType").value,
image: document.getElementById("questionImage").value || null,
};
const id = document.getElementById("questionId").value;
const isEdit = id !== "";
try {
const response = await fetch(
isEdit
? `${API_BASE}/api/admin/questions/${id}`
: `${API_BASE}/api/admin/questions`,
{
method: isEdit ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}
);
if (response.ok) {
closeModal();
loadQuestions();
showAlert("questionsAlert", "Kérdés sikeresen mentve!", "success");
} else {
const error = await response.json();
showAlert(
"questionsAlert",
error.error || "Hiba a mentés során",
"danger"
);
}
} catch (error) {
showAlert("questionsAlert", "Kapcsolat hiba", "danger");
}
}
async function deleteQuestion(id) {
if (!confirm("Biztosan törölni szeretnéd ezt a kérdést?")) return;
try {
const response = await fetch(
`${API_BASE}/api/admin/questions/${id}`,
{ method: "DELETE" }
);
if (response.ok) {
loadQuestions();
showAlert("questionsAlert", "Kérdés törölve!", "success");
} else {
throw new Error("Delete failed");
}
} catch (error) {
showAlert("questionsAlert", "Hiba a törlés során", "danger");
}
}
async function loadImages() {
try {
const response = await fetch(`${API_BASE}/api/images`);
if (response.ok) {
const images = await response.json();
displayImages(images);
} else {
throw new Error("Failed to load");
}
} catch (error) {
document.getElementById("imagesList").innerHTML =
'<div class="alert alert-danger">Hiba a képek betöltésekor</div>';
}
}
function displayImages(images) {
const container = document.getElementById("imagesList");
container.innerHTML = images
.map(
(image) => `
<div class="image-item">
<img src="${API_BASE}/api/pics/${image}" alt="${image}">
<p>${image}</p>
<button class="danger" data-delete-image="${image}">Törlés</button>
</div>
`
)
.join("");
container.querySelectorAll('[data-delete-image]').forEach(btn => {
btn.addEventListener('click', (e) => {
deleteImage(e.target.dataset.deleteImage);
});
});
}
async function uploadImage() {
const input = document.getElementById("imageUpload");
const file = input.files[0];
if (!file) return;
const formData = new FormData();
formData.append("image", file);
try {
const response = await fetch(`${API_BASE}/api/admin/images/upload`, {
method: "POST",
body: formData,
});
if (response.ok) {
input.value = "";
loadImages();
showAlert("imagesAlert", "Kép sikeresen feltöltve!", "success");
} else {
const error = await response.json();
showAlert(
"imagesAlert",
error.error || "Hiba a feltöltés során",
"danger"
);
}
} catch (error) {
showAlert("imagesAlert", `Kapcsolat hiba: ${error.message}`, "danger");
}
}
async function deleteImage(filename) {
if (!confirm(`Biztosan törölni szeretnéd a ${filename} képet?`)) return;
try {
const response = await fetch(
`${API_BASE}/api/admin/images/${encodeURIComponent(filename)}`,
{
method: "DELETE",
}
);
if (response.ok) {
loadImages();
showAlert("imagesAlert", "Kép törölve!", "success");
} else {
throw new Error("Delete failed");
}
} catch (error) {
showAlert("imagesAlert", `Hiba a törlés során: ${error.message}`, "danger");
}
}
function closeModal() {
document.getElementById("questionModal").style.display = "none";
}
function showAlert(elementId, message, type) {
const alertDiv = document.getElementById(elementId);
alertDiv.className = `alert alert-${type}`;
alertDiv.textContent = message;
alertDiv.style.display = "block";
setTimeout(() => {
alertDiv.style.display = "none";
}, 5000);
}

379
backend/public/index.html Normal file
View file

@ -0,0 +1,379 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fizika Admin - Kérdések és képek kezelése</title>
<script
defer
data-domain="fizika.schmelczer.dev"
data-api="https://stats.schmelczer.dev/status"
src="https://stats.schmelczer.dev/js/script.file-downloads.hash.outbound-links.js"
></script>
<script>
window.plausible =
window.plausible ||
function () {
(window.plausible.q = window.plausible.q || []).push(arguments);
};
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
background: #003366;
color: white;
padding: 1rem;
margin-bottom: 2rem;
border-radius: 8px;
}
.tabs {
display: flex;
margin-bottom: 2rem;
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.tab {
flex: 1;
padding: 1rem;
background: white;
border: none;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.3s;
}
.tab:hover {
background: #f0f0f0;
}
.tab.active {
background: #003366;
color: white;
}
.tab:not(.active) {
color: #333;
}
.tab-content {
display: none;
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.tab-content.active {
display: block;
}
.form-group {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
}
input,
textarea,
select {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
textarea {
min-height: 100px;
resize: vertical;
}
button {
background: #003366;
color: white;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.3s;
}
button:hover {
background: #004488;
}
button.secondary {
background: #666;
}
button.danger {
background: #dc3545;
}
button.danger:hover {
background: #c82333;
}
.questions-list {
margin-top: 2rem;
}
.question-item {
background: #f8f9fa;
padding: 1rem;
margin-bottom: 1rem;
border-radius: 4px;
border-left: 4px solid #003366;
}
.question-actions button {
margin-right: 0.5rem;
margin-bottom: 0.5rem;
}
.images-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.image-item {
background: white;
padding: 1rem;
border-radius: 4px;
text-align: center;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.image-item img {
max-width: 100%;
height: 150px;
object-fit: cover;
border-radius: 4px;
}
.alert {
padding: 1rem;
margin-bottom: 1rem;
border-radius: 4px;
}
.alert-success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert-danger {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
}
.modal-content {
background-color: white;
margin: 5% auto;
padding: 2rem;
border-radius: 8px;
width: 80%;
max-width: 600px;
max-height: 80%;
overflow-y: auto;
}
.close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
.close:hover {
color: black;
}
@media (max-width: 768px) {
.container {
padding: 10px;
}
.tabs {
flex-direction: column;
}
.images-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Fizika Admin Panel</h1>
<p>Kérdések és képek kezelése</p>
</div>
<!-- Admin Panel -->
<div class="tabs">
<button class="tab active" data-tab="questions">
Kérdések kezelése
</button>
<button class="tab" data-tab="images">Képek kezelése</button>
</div>
<!-- Questions Tab -->
<div id="questionsTab" class="tab-content active">
<h2>Kérdések kezelése</h2>
<div id="questionsAlert"></div>
<button id="addQuestionBtn">Új kérdés hozzáadása</button>
<div id="questionsList" class="questions-list">
<div>Kérdések betöltése...</div>
</div>
</div>
<!-- Images Tab -->
<div id="imagesTab" class="tab-content">
<h2>Képek kezelése</h2>
<div id="imagesAlert"></div>
<div class="form-group">
<label for="imageUpload">Új kép feltöltése:</label>
<input type="file" id="imageUpload" accept="image/*" />
</div>
<div id="imagesList" class="images-grid">
<div>Képek betöltése...</div>
</div>
</div>
</div>
<!-- Question Edit Modal -->
<div id="questionModal" class="modal">
<div class="modal-content">
<span class="close" id="closeModalBtn">&times;</span>
<h2 id="modalTitle">Kérdés szerkesztése</h2>
<form id="questionForm">
<input type="hidden" id="questionId" />
<div class="form-group">
<label for="questionSource">Forrás:</label>
<input type="text" id="questionSource" required />
</div>
<div class="form-group">
<label for="questionDescription">Kérdés szövege:</label>
<textarea id="questionDescription" required></textarea>
</div>
<div class="form-group">
<label for="questionA">A válasz:</label>
<textarea id="questionA"></textarea>
</div>
<div class="form-group">
<label for="questionB">B válasz:</label>
<textarea id="questionB"></textarea>
</div>
<div class="form-group">
<label for="questionC">C válasz:</label>
<textarea id="questionC"></textarea>
</div>
<div class="form-group">
<label for="questionD">D válasz:</label>
<textarea id="questionD"></textarea>
</div>
<div class="form-group">
<label for="questionCorrect">Helyes válasz:</label>
<select id="questionCorrect" required>
<option value="1">A</option>
<option value="2">B</option>
<option value="3">C</option>
<option value="4">D</option>
</select>
</div>
<div class="form-group">
<label for="questionType">Típus:</label>
<select id="questionType" required>
<option value="mec">Mechanika</option>
<option value="mk">Kinematika</option>
<option value="md">Dinamika</option>
<option value="me">Munka és energia</option>
<option value="mf">Folyadékok és gázok mechanikája</option>
<option value="mr">Rezgések és hullámok</option>
<option value="h">Hőtan</option>
<option value="ele">Elektromosság</option>
<option value="es">Elektrosztatika</option>
<option value="ee">Egyenáram</option>
<option value="ev">Váltakozó áram</option>
<option value="m">Mágnesesség</option>
<option value="o">Fénytan</option>
<option value="atm">Atomfizika</option>
<option value="ah">Atomhéj</option>
<option value="am">Atommag</option>
<option value="cs">Égi mechanika, csillagászat</option>
<option value="v">Vegyes</option>
</select>
</div>
<div class="form-group">
<label for="questionImage">Kép:</label>
<input
type="text"
id="questionImage"
placeholder="Kép fájlneve (opcionális)"
/>
</div>
<button type="submit">Mentés</button>
<button type="button" class="secondary" id="cancelBtn">Mégse</button>
</form>
</div>
</div>
<script src="admin.js"></script>
</body>
</html>

177
backend/server.js Normal file
View file

@ -0,0 +1,177 @@
const express = require('express');
const cors = require('cors');
const multer = require('multer');
const path = require('path');
const fs = require('fs').promises;
const helmet = require('helmet');
const app = express();
const PORT = process.env.PORT || 3001;
// Security middleware
app.use(helmet());
app.use(cors({
origin: process.env.FRONTEND_URL || '*',
credentials: true
}));
app.use(express.json({ limit: '100mb' }));
app.use(express.static('public'));
// File paths
const DATA_PATH = process.env.DATA_PATH || path.join(__dirname, '../frontend/fizika.json');
const PICS_PATH = process.env.PICS_PATH || path.join(__dirname, '../frontend/pics');
// Multer configuration for image uploads
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, PICS_PATH);
},
filename: (req, file, cb) => {
cb(null, file.originalname);
}
});
const upload = multer({
storage: storage,
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error('Only images allowed'));
}
}
});
// Utility functions
const readData = async () => {
const data = await fs.readFile(DATA_PATH, 'utf8');
return JSON.parse(data);
};
const writeData = async (data) => {
await fs.writeFile(DATA_PATH, JSON.stringify(data, null, 2));
};
// Public routes
app.get('/api/fizika', async (req, res) => {
try {
const data = await readData();
res.json(data);
} catch (error) {
res.status(500).json({ error: 'Failed to read data' });
}
});
app.get('/api/images', async (req, res) => {
try {
const files = await fs.readdir(PICS_PATH);
const images = files.filter(f => /\.(jpg|jpeg|png|gif|bmp)$/i.test(f));
res.json(images);
} catch (error) {
res.status(500).json({ error: 'Failed to read images' });
}
});
app.use('/api/pics', express.static(PICS_PATH));
// Admin routes (no auth required)
app.get('/api/admin/questions', async (req, res) => {
try {
const data = await readData();
res.json(data);
} catch (error) {
res.status(500).json({ error: 'Failed to read questions' });
}
});
app.post('/api/admin/questions', async (req, res) => {
try {
const data = await readData();
const maxId = Math.max(...data.map(q => q.id), 0);
const newQuestion = { id: maxId + 1, ...req.body };
data.push(newQuestion);
await writeData(data);
res.status(201).json(newQuestion);
} catch (error) {
res.status(500).json({ error: 'Failed to create question' });
}
});
app.put('/api/admin/questions/:id', async (req, res) => {
try {
const data = await readData();
const index = data.findIndex(q => q.id === parseInt(req.params.id));
if (index === -1) {
return res.status(404).json({ error: 'Question not found' });
}
data[index] = { ...data[index], ...req.body };
await writeData(data);
res.json(data[index]);
} catch (error) {
res.status(500).json({ error: 'Failed to update question' });
}
});
app.delete('/api/admin/questions/:id', async (req, res) => {
try {
const data = await readData();
const index = data.findIndex(q => q.id === parseInt(req.params.id));
if (index === -1) {
return res.status(404).json({ error: 'Question not found' });
}
data.splice(index, 1);
await writeData(data);
res.json({ message: 'Question deleted' });
} catch (error) {
res.status(500).json({ error: 'Failed to delete question' });
}
});
app.post('/api/admin/images/upload', upload.single('image'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No image provided' });
}
res.json({
filename: req.file.filename,
path: `/api/pics/${req.file.filename}`
});
});
app.delete('/api/admin/images/:filename', async (req, res) => {
try {
await fs.unlink(path.join(PICS_PATH, req.params.filename));
res.json({ message: 'Image deleted' });
} catch (error) {
if (error.code === 'ENOENT') {
return res.status(404).json({ error: 'Image not found' });
}
res.status(500).json({ error: 'Failed to delete image' });
}
});
// Error handling
app.use((error, req, res, next) => {
console.error('Error:', error.message);
res.status(500).json({ error: 'Server error' });
});
app.use((req, res) => {
res.status(404).json({ error: 'Not found' });
});
app.listen(PORT, () => {
console.log(`Fizika Admin Backend running on port ${PORT}`);
});

File diff suppressed because it is too large Load diff

View file

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 725 B

After

Width:  |  Height:  |  Size: 725 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 632 B

After

Width:  |  Height:  |  Size: 632 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 3 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 997 B

After

Width:  |  Height:  |  Size: 997 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 361 B

After

Width:  |  Height:  |  Size: 361 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 590 B

After

Width:  |  Height:  |  Size: 590 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

7910
frontend/fizika.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -238,7 +238,6 @@
id="numberof"
placeholder="Feladatok mennyisége: "
min="1"
max="435"
/>
</div>
</div>

View file

@ -25,6 +25,8 @@ async function ajaxLoad(type) {
$("#loadingGif").show();
let result = "";
try {
if (type == 1) {
var source =
"^" + $("#evszam").val() + $("#honap").val() + $("#feladat").val() + "$";
@ -88,6 +90,27 @@ async function ajaxLoad(type) {
$("#megoldas").show();
$("#state").html("Feladatok sikeresen letöltve!");
}
} catch (error) {
$("#loadingGif").hide();
$("#content").html(`
<div class="buttonwrapper">
<b style="font-size: 1.5rem; color: #dc3545;">
Nem sikerült betölteni a feladatokat
</b>
<p style="margin-top: 1rem; color: #666;">
${error.message}
</p>
<p style="margin-top: 0.5rem; color: #666;">
Ellenőrizd az internetkapcsolatot vagy próbáld újra.
</p>
<button class="button" onclick="location.reload()" style="margin-top: 1rem;">
Újrapróbálás
</button>
</div>
`);
$("#state").html("Hiba a feladatok betöltésekor");
console.error('Quiz loading error:', error);
}
}
function showCorrect(id, correctAns) {
@ -267,7 +290,6 @@ $(document).ready(function () {
$("#fooldal").hide();
});
$("#beredmenyek").click(function () {
//eredmeny();
$("#bfooldal").css("font-weight", "400");
$("#bteszt").css("font-weight", "400");
$("#beredmenyek").css("font-weight", "700");

View file

@ -1,4 +1,21 @@
let questions = null;
// Auto-detect API base URL
const getApiBase = () => {
const protocol = window.location.protocol;
const hostname = window.location.hostname;
// If running on localhost, assume backend is on port 3001
if (hostname === 'localhost' || hostname === '127.0.0.1') {
return `${protocol}//${hostname}:3001`;
}
// For production, assume backend is on same origin
return "https://fizika-backend.schmelczer.dev"
};
const API_BASE = getApiBase();
const loadQuestions = async (
isSearch,
categories,
@ -6,7 +23,27 @@ const loadQuestions = async (
questionCount
) => {
if (questions === null) {
questions = await (await fetch("fizika.json")).json();
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();
@ -31,7 +68,7 @@ const loadQuestions = async (
<div class="feladat card" id="feladat${id}">
<h2 style="float: left;">${i + 1}.</h2><h2>${source}</h2>
<pre>${description}</pre>
${image ? `<img src="pics/${image}"><br>` : ""}
${image ? `<img src="${API_BASE}/api/pics/${image}" onerror="this.src='pics/${image}'"><br>` : ""}
<form id="form${id}"">
<input type="radio" id="rad1" name="group">
<label id="label${id}" class="rad1">${a}</label>
@ -42,8 +79,7 @@ const loadQuestions = async (
<input type="radio" id="rad3" name="group">
<label id="label${id}" class="rad3">${c}</label>
<br>
${
d
${d
? `
<input type="radio" id="rad4" name="group">
<label id="label${id}" class="rad4">${d}</label>

View file

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

Some files were not shown because too many files have changed in this diff Show more