Compare commits

..
2 Commits
Author SHA1 Message Date
Do SikiandClaude Sonnet 5 58947328d7 sync: mark MITHOME-98 done in TODO.md
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 10:32:37 +02:00
Do SikiandClaude Sonnet 5 8b2546a987 fix(docker): authenticate the dev app's MongoDB URI (MITHOME-98)
docker-compose.dev.yml's mongodb service sets MONGO_INITDB_ROOT_USERNAME/
PASSWORD, which makes the official mongo image enable --auth — but the
app service's MONGODB_URI was unauthenticated
(mongodb://mongodb:27017/mozdit). Same bug class as MITHOME-32
(staging/production), but that ticket covers docker-compose.staging.yml
/docker-compose.prod.yml specifically, not this dev file — hence the
separate MITHOME-98.

Why /api/health never caught it: that route is a pure liveness check
and never touches MongoDB. Only an endpoint that actually performs a
DB operation exercises the bug.

Verified live (docker compose -f docker-compose.dev.yml up --build):
- Reproduced the failure first: inside the running app container, a
  plain `mongodb://mongodb:27017/mozdit` connection's findOne() throws
  "Command find requires authentication" — confirms the hypothesis
  that the app fails only on a real query, not at startup.
- With the fix in place: POST /api/contact returns 200 with a
  submissionId, and the document is actually present in
  contact_submissions (checked via mongosh) — a real, previously-
  broken write path now works end to end.
- npm test: 58/58 passed (unaffected, as expected for a
  docker-compose/doc-only change).

Also fixed the same stale unauthenticated example in DOCKER.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 10:32:04 +02:00
114 changed files with 5133 additions and 9314 deletions
+4 -8
View File
@@ -80,15 +80,10 @@ sync: update Plane issues + TODO [leírás]
websitedev/ websitedev/
├── proto/ # Fő Next.js alkalmazás (itt futtatsd a parancsokat!) ├── proto/ # Fő Next.js alkalmazás (itt futtatsd a parancsokat!)
│ ├── src/ │ ├── src/
│ │ ├── app/ │ │ ├── app/ # Next.js App Router (pages és API routes)
│ │ │ ├── (frontend)/ # Publikus oldalak: [locale]/[slug] catch-all
│ │ │ └── (payload)/ # Payload admin (/admin) + REST/GraphQL API
│ │ ├── components/ # React komponensek (Header, Footer, ThemeProvider) │ │ ├── components/ # React komponensek (Header, Footer, ThemeProvider)
│ │ ├── globals/ # Payload Globals (Home, About, Services, Contact, Common) │ │ ├── content/ # JSON tartalom-kezelő rendszer
│ │ ├── collections/ # Payload Collections (LegalPages, Partners, Media, ContactSubmissions, Users) │ │ ├── lib/ # Utility könyvtárak (MongoDB, Logger, Site Config)
│ │ ├── payload.config.ts # Payload CMS konfiguráció
│ │ ├── content/ # RÉGI JSON rendszer — csak migrációs seed + teszt-fixture (MITHOME-91/93/96)
│ │ ├── lib/ # payload-content.ts (Local API adapter), MongoDB health-check, Logger
│ │ ├── config/ # Statikus site konfiguráció │ │ ├── config/ # Statikus site konfiguráció
│ │ └── types/ # TypeScript típusdefiníciók │ │ └── types/ # TypeScript típusdefiníciók
│ └── public/ # Statikus fájlok │ └── public/ # Statikus fájlok
@@ -284,6 +279,7 @@ Az `.agent/workflows/` mappában találhatók az elérhető slash command workfl
| --- | --- | | --- | --- |
| `/new-feature` | Új funkció fejlesztési folyamata | | `/new-feature` | Új funkció fejlesztési folyamata |
| `/fix-bug` | Hibajavítás folyamata | | `/fix-bug` | Hibajavítás folyamata |
| `/cms-feature` | CMS (Content Editor) fejlesztési folyamata |
| `/review` | Kód review checklist | | `/review` | Kód review checklist |
| `/deploy` | Deployment folyamata | | `/deploy` | Deployment folyamata |
+9 -17
View File
@@ -8,12 +8,11 @@ Ez a fájl minden interakcióban automatikusan betöltődik az AI számára a pr
## Rendszer áttekintés ## Rendszer áttekintés
A projekt egy **Next.js 15 alapú marketing weboldal** a mozdIT Bt. számára, App Router architektúrával, TypeScript-tel és Tailwind CSS 4-gyel, **Payload CMS**-sel (self-hosted, `/admin`) mint tartalomkezelő réteggel (MITHOME-85 epic). A projekt egy **Next.js 15 alapú marketing weboldal** a mozdIT Bt. számára, App Router architektúrával, TypeScript-tel és Tailwind CSS 4-gyel.
- **Frontend**: Next.js 15 App Router (dinamikus renderelés — lásd MITHOME-97 WHY: SSG helyett, hogy egy admin publikálás azonnal látszódjon), React 19, Tailwind CSS 4 - **Frontend**: Next.js 15 App Router (SSR/SSG), React 19, Tailwind CSS 4
- **CMS**: Payload CMS — Globals (Home/About/Services/Contact/Common) + Collections (LegalPages/Partners/Media/ContactSubmissions/Users), hu/en lokalizáció, draft/publish + verziózás - **Backend**: Next.js API Routes (`/api/*`)
- **Backend**: Next.js API Routes (`/api/*`), Payload REST/GraphQL API + Local API - **Adatbázis**: MongoDB (Mongoose ODM) — site config és contact form logok
- **Adatbázis**: MongoDB — Payload-collection-önként (mongooseAdapter), plusz egy vékony natív `mongodb` driveres réteg (`proto/src/lib/mongodb.ts`) csak az `/api/health` connectivity-ellenőrzéséhez
- **Logging**: Winston + Loki (strukturált naplózás) - **Logging**: Winston + Loki (strukturált naplózás)
- **Infrastructure**: Docker + Docker Compose, natív deploy (`deploy.sh`) - **Infrastructure**: Docker + Docker Compose, natív deploy (`deploy.sh`)
@@ -25,13 +24,13 @@ A projekt egy **Next.js 15 alapú marketing weboldal** a mozdIT Bt. számára, A
| **Component** | Újrahasználható UI elemek | `proto/src/components/` | | **Component** | Újrahasználható UI elemek | `proto/src/components/` |
| **API Route** | Route handling, validáció | `proto/src/app/api/` | | **API Route** | Route handling, validáció | `proto/src/app/api/` |
| **Service/Lib** | Üzleti logika, DB kapcsolat | `proto/src/lib/` | | **Service/Lib** | Üzleti logika, DB kapcsolat | `proto/src/lib/` |
| **Content** | Payload CMS Globals/Collections (admin: `/admin`), Local API adapter | `proto/src/globals/`, `proto/src/collections/`, `proto/src/lib/payload-content.ts` | | **Content** | JSON alapú tartalom | `proto/src/content/` |
| **Config** | Statikus site konfiguráció | `proto/src/config/` | | **Config** | Statikus site konfiguráció | `proto/src/config/` |
| **Agent / Tools** | AI ügynökök eszközei | `.agent/` | | **Agent / Tools** | AI ügynökök eszközei | `.agent/` |
## Fontos Architektúra Szabályok ## Fontos Architektúra Szabályok
1. **Tartalom Payload-ban**: SOHA ne égess be szöveget közvetlenül komponensbe — a tartalom Payload Global/Collection mezőkben él, az ügyfél a `/admin` felületen szerkeszti, a frontend a `src/lib/payload-content.ts` adapteren (Payload Local API) keresztül olvassa. (Kivétel: `proto/src/content/*.json` még mindig létezik, de csak a `migrate-content-to-payload.ts` seed-script forrásaként és néhány komponens-teszt fixture-jeként — élő oldal nem olvassa közvetlenül, lásd MITHOME-91/93/96.) 1. **Tartalom JSON-ban**: SOHA ne égess be szöveget közvetlenül komponensbe — használd a `src/content/` rendszert.
2. **Stateless API**: A backend API teljesen stateless — session-t a kliens kezel. 2. **Stateless API**: A backend API teljesen stateless — session-t a kliens kezel.
3. **Observability**: Minden hiba legyen logolva Winstonnal. API válaszok egységes formátumban: `{ success, data, error }`. 3. **Observability**: Minden hiba legyen logolva Winstonnal. API válaszok egységes formátumban: `{ success, data, error }`.
4. **Security-by-design**: Input validáció minden API route-on, secrets csak env változókból. 4. **Security-by-design**: Input validáció minden API route-on, secrets csak env változókból.
@@ -56,21 +55,14 @@ A projekt egy **Next.js 15 alapú marketing weboldal** a mozdIT Bt. számára, A
## Content Management ## Content Management
```typescript ```typescript
// Helyes: Payload Local API adapter használata (Server Component-ben) // Helyes: content rendszer használata
import { getAboutContent } from '@/lib/payload-content' import { content, getPageContent } from '@/content'
const about = await getAboutContent(locale) // locale: 'hu' | 'en' const pageData = content.pages.about
// TILOS: közvetlen szöveg komponensben // TILOS: közvetlen szöveg komponensben
<h1>Rólunk</h1> <h1>Rólunk</h1>
``` ```
Az ügyfél a tartalmat a Payload admin felületen (`/admin`) szerkeszti —
Global (pl. Home, About) vagy Collection (pl. LegalPages, Partners) dokumentum
formájában, "Save Draft"/"Publish changes" munkafolyamattal (MITHOME-92). Új
mező felvételekor a megfelelő `proto/src/globals/*.ts` vagy
`proto/src/collections/*.ts` fájlt bővítsd, majd generáld újra a típusokat
(`payload generate:types`).
## Dependency szabályok ## Dependency szabályok
- Új NPM package bevezetéséhez **jóváhagyás szükséges** - Új NPM package bevezetéséhez **jóváhagyás szükséges**
+5 -5
View File
@@ -76,8 +76,9 @@ git commit -m "fix(<scope>): <mi volt a hiba és hogyan lett javítva>"
## Felhasználói dokumentáció karbantartása ## Felhasználói dokumentáció karbantartása
- A régi egyedi CMS-nek (`content-editor.js`, saját **❓ Súgó** menüponttal, `docs/felhasznaloi-utmutato.md`) volt saját, karbantartott felhasználói útmutatója — ezt a Payload CMS-re állás után leépítettük (MITHOME-93). A Payload admin felület (`/admin`) saját, upstream dokumentációval rendelkezik; ügyfél-facing Payload-specifikus útmutató készítése külön feladat (MITHOME-95), ha szükséges. - A CMS **❓ Súgó** menüpontja a `docs/felhasznaloi-utmutato.md` fájlt rendereli (`/guide`).
- **Kötelező**: minden honlapfunkció-változtatásnál (új útvonal, viselkedésváltozás) ellenőrizd, hogy a `docs/` alatti releváns dokumentáció ne legyen elavult. - **Kötelező**: minden CMS- vagy honlapfunkció változtatásánál (új gomb, viselkedésváltozás, útvonal) ugyanabban a commitban frissítsd az útmutatót.
- Támogatott markdown forma a `scripts/markdown-render.js` részhalmaza: címsorok, **félkövér**, `kód`, listák, linkek, `---` elválasztók (táblázat nem).
## Nyelvhasználat ## Nyelvhasználat
@@ -142,9 +143,8 @@ const rateLimitStore = new Map<string, number[]>();
// DECISION: fire-and-forget MongoDB write — ne várakozzon a válasz a cache írásra // DECISION: fire-and-forget MongoDB write — ne várakozzon a válasz a cache írásra
dbClient.logContact(data).catch(() => null); dbClient.logContact(data).catch(() => null);
// TRADEOFF: Payload Local API vs statikus JSON import — élő, adminból // TRADEOFF: JSON content management vs CMS — könnyebb deploy, de nem non-tech szerkeszthető
// szerkeszthető tartalom, cserébe minden kérésnél DB-olvasás kell (MITHOME-97) const content = await import('@/content/pages/home.json');
const content = await getHomeContent(locale);
``` ```
## Pre-modification kockázatbecslés ## Pre-modification kockázatbecslés
+17 -26
View File
@@ -44,7 +44,6 @@ npm run test:coverage # lefedettség riport
npm run test:browser # browser integration npm run test:browser # browser integration
npm run test:integration # Docker integration (Docker kell!) npm run test:integration # Docker integration (Docker kell!)
npm run test:e2e # E2E tesztek (Docker kell!) npm run test:e2e # E2E tesztek (Docker kell!)
npm run test:payload # Payload Local API integráció (élő MongoDB + migrált tartalom kell!)
npm run test:all # teljes proto suite npm run test:all # teljes proto suite
npm run test:smoke:staging # Playwright smoke a staging ellen npm run test:smoke:staging # Playwright smoke a staging ellen
``` ```
@@ -78,30 +77,22 @@ Minden deploy előtt az **egyetlen belépési pont** futtatandó:
scripts/pre-deploy-tests.sh scripts/pre-deploy-tests.sh
``` ```
Ez lefedi: proto unit + `tsc --noEmit` + `eslint`, content séma-validáció és a Ez lefedi: proto unit + `tsc --noEmit` + `eslint`, content séma-validáció, a CMS
integrációs tesztjei (`scripts/test-content-editor-*.js`), a publish-teszt és a
Plane-sync unit tesztek. Bármelyik hibája megszakítja a kiadást. Plane-sync unit tesztek. Bármelyik hibája megszakítja a kiadást.
> A régi egyedi CMS (content-editor.js) saját teszt-szkriptjeit (auth, CSRF, A CMS (content-editor.js) tesztjei külön is futtathatók (valódi szervert indítanak):
> serializer, optimista zárolás, verziók panel, logó feltöltés, login/logout,
> guide, publish) a Payload CMS-re állás után (MITHOME-93) eltávolítottuk — ```bash
> a Payload admin felület a saját upstream tesztelésével fedett, ezt itt nem node scripts/test-content-editor-security.js # auth, CSRF, XFF
> duplikáljuk. node scripts/test-content-editor-serializer.js # collect/reindex regresszió
> node scripts/test-content-editor-save.js # atomikus mentés + validáció
> **MITHOME-96**: Payload collection/global config tesztek — node scripts/test-content-editor-conflict.js # optimista zárolás (409)
> `src/payload-config.test.ts` (a `npm test`/pre-deploy suite része, nincs node scripts/test-content-editor-versions.js # verziók panel (diff, restore)
> hozzá élő DB, csak a Collection/Global config objektumokat vizsgálja — node scripts/test-content-editor-logo.js # logó feltöltés
> köztük két, MITHOME-121-ben talált éles hibára regresszió-őrt: a Media node scripts/test-content-editor-login.js # login flow (session, Safari)
> publikus olvashatósága, a Partners.logo opcionalitása). A Local API elleni, node scripts/test-content-editor-logout.js # logout + rate-limit
> élő MongoDB-t igénylő tesztek külön scriptben vannak node scripts/test-content-editor-guide.js # /guide + markdown renderer
> (`scripts/test-payload-local-api.ts`, `npm run test:payload`) — WHY nem node scripts/test-content-editor-bottombar.js # alsó sáv layout guard
> Jest: a `payload` csomag ESM-only dist-et ad ki, amit a next/jest node scripts/test-cms-publish.js # publish parancs + integráció
> transzform alapból nem fordít le (a teljes függőségi fa ```
> `transformIgnorePatterns`-be vétele törékeny lenne), ezért ugyanazt a
> bevált `node --import tsx` mintát követi, mint `migrate-content-to-payload.ts`.
> Nincs a `pre-deploy-tests.sh`-ban (élő, migrált tartalmú MongoDB kell hozzá,
> mint a régi, eltávolított CMS-teszteknek is), de a `npm run test:payload`
> paranccsal bármikor lefuttatható egy futó dev MongoDB ellen. A régi,
> Docker-stack-hez kötött `integration.test.ts`/`e2e-docker.test.ts` is
> frissítve lett a locale-prefixes útvonalakra és a Payload collection-nevekre
> (korábban a pre-Payload, unprefixelt útvonalakra és `site_config`/
> `contact_submissions` nyers Mongo collection-ökre hivatkoztak).
+86
View File
@@ -0,0 +1,86 @@
---
description: CMS (Content Editor) fejlesztési munkafolyamata — módosítástól a staging élesítésig
---
# CMS Fejlesztési Workflow (/cms-feature)
A `content-editor.js`, a hozzá tartozó scriptek (`scripts/markdown-render.js`, `scripts/test-content-editor-*.js`) és a CMS-en keresztül szerkeszthető tartalmi sémák (`proto/src/content/schema.js`) módosításakor kövesd ezt a folyamatot.
A szabály (a *miért*): `.agent/steering/development-rules.md` → „Felhasználói dokumentáció karbantartása". Ez a fájl a *hogyan*-t írja le.
---
## 1. Tervezés
- Fogalmazd meg, mi változik a felhasználó szemszögéből (új gomb? más viselkedés? új hibaüzenet?)
- **Új endpoint vagy UI elem esetén**: tervezd meg az útmutató érintett szakaszának frissítését is.
- Content séma (`schema.js`) változásnál ellenőrizd, hogy a meglévő JSON fájlok mind átmegyek az új sémán.
## 2. Implementáció
- A CMS system Nodeon fut, **nincs node_modules** — csak beépítő modulokat használj (`http`, `fs`, `path`, `crypto`, `child_process`).
- Fájlméret: a `content-editor.js` közelít a 300 soros soft limit felé — nagyobb funkciót szervezz ki külön modulba (pl. `scripts/markdown-render.js` minta).
- Biztonsági követelmények:
- Minden új POST endpoint **CSRF token ellenőrzéssel**.
- Új GET endpoint **auth után** serviced (kivétel: `/logout` minta).
- User input escape-elés (lásd `escapeHtml` a markdown rendererben).
- Titok soha nem kerül kimenetre — válaszkóddal (401/200) igazolj.
## 3. Útmutató frissítése (KÖTELEZŐ, ugyanabban a commitban)
- Forrás: `docs/felhasznaloi-utmutato.md` — a felhasználó nyelvén, nem műszaki részletekkel.
- Új UI elem → felület áttekintés szakasz + adott funkció szakasz frissítése.
- Támogatott markdown részhalmaz: címsorok, **félkövér**, `kód`, listák, linkek, `---` (táblázat nem — a `scripts/markdown-render.js` nem jeleníti meg).
## 4. Tesztek
Minden CMS-változtatás után futtasd mindet:
```bash
node --check content-editor.js
node scripts/test-content-editor-security.js # auth, CSRF, XFF
node scripts/test-content-editor-serializer.js # collect/reindex regresszió
node scripts/test-content-editor-save.js # atomic save + validáció
node scripts/test-content-editor-logout.js # /logout viselkedés
node scripts/test-content-editor-guide.js # /guide + Súgó link
```
- Új funkcióhoz **új tesztfájl** a fenti minta szerint (valós szervert indító integration teszt ajánlott).
- Content séma változásnál: `node scripts/test-content-schema.js` is.
## 5. Commit
```bash
git add content-editor.js scripts/ docs/felhasznaloi-utmutato.md
git commit -m "feat(cms): <rövid leírás>" # vagy fix(cms):
```
- Az útmutató frissítése **ugyanabban a commitban** landol, mint a funkció.
- Plane ticket (`Closes MITHOME-XX`) + `node plane-sync.js --yes` a TODO.md szinkronhoz.
## 6. Staging élesítés és ellenőrzés
A CMS a websitetől **független szolgáltatás** — deploy szkript nélkül, közvetlenül élesítjük:
```bash
ssh sadmin@llmdev.mozdit.hu '
cd /home/sadmin/websitedev && git pull --ff-only origin main &&
sudo systemctl restart mozdit-content-editor.service &&
systemctl is-active mozdit-content-editor.service'
```
Ellenőrzés (a hitelesítő adatokat az `/etc/mozdit-content-editor.env`-ből olvasd, **soha ne írd ki**):
```bash
# kulcs nélkül 401-et várunk
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:4001/
# hitelesített kérést sudo sh -c ". /etc/mozdit-content-editor.env; curl -u ..." mintával futtass
```
- Az érintett funkciókra vonatkozó válaszkódok ellenőrzése (401/200/429 stb.).
- Ha a honlap (`proto/`) is változott: `./scripts/deploy_to_stage_on_local.sh` a teljes deployhoz.
## 7. Bezárás
- Plane ticket → Done.
- `node plane-sync.js --yes` + `git commit -m "sync: ..."` + push.
+3 -4
View File
@@ -9,7 +9,7 @@ description: Deployment munkafolyamata — lokális teszttől a stagingen át a
- **Nincs Dokploy / registry push** — a deploy natív Docker Compose-zal, a szerveren buildelve történik. - **Nincs Dokploy / registry push** — a deploy natív Docker Compose-zal, a szerveren buildelve történik.
- **Környezetenként külön compose fájl**: `docker-compose.staging.yml` (app: 127.0.0.1:8081, saját Mongo) és `docker-compose.prod.yml` (app: 8080). A két környezet ugyanazon a hoston, külön hálózaton és volume-mal fut. - **Környezetenként külön compose fájl**: `docker-compose.staging.yml` (app: 127.0.0.1:8081, saját Mongo) és `docker-compose.prod.yml` (app: 8080). A két környezet ugyanazon a hoston, külön hálózaton és volume-mal fut.
- **Környezeti változók**: kötelező `.env.staging` / `.env.production` fájlból jönnek (minta: `.env.staging.example`, `.env.production.example`). Bizalmas értékeknek (MONGO_ROOT_PASSWORD, MONGODB_URI) **nincs fallback** — hiányzó env hangosan hibázik. - **Környezeti változók**: kötelező `.env.staging` / `.env.production` fájlból jönnek (minta: `.env.staging.example`, `.env.production.example`). Bizalmas értékeknek (MONGO_ROOT_PASSWORD, MONGODB_URI) **nincs fallback** — hiányzó env hangosan hibázik.
- **Staging megkülönböztetés**: a `NEXT_PUBLIC_DEPLOY_ENV=staging` build arg amber banner-t jelenít meg a weboldalon. - **Staging megkülönböztetés**: a `NEXT_PUBLIC_DEPLOY_ENV=staging` build arg amber banner-t jelenít meg a weboldalon; a Content Editor is staging feliratot kap.
## Előfeltételek ## Előfeltételek
@@ -44,11 +44,10 @@ Bármely lépés hibája azonnal leállítja a folyamatot. Paraméterek környez
1. Kötelező `.env.<env>` fájl betöltése (`--env-file` a compose-nak). 1. Kötelező `.env.<env>` fájl betöltése (`--env-file` a compose-nak).
2. `docker compose -f docker-compose.<env>.yml up --build --force-recreate -d` — a friss Dockerfile/build-arg változások is érvényesüljenek. 2. `docker compose -f docker-compose.<env>.yml up --build --force-recreate -d` — a friss Dockerfile/build-arg változások is érvényesüljenek.
3. Healthcheck: max 60s-ig kérdezi a `http://localhost:<port>/api/health`-t (staging: 8081, prod: 8080). Sikertelenség esetén app-logok kiírása + exit 1. 3. Healthcheck: max 60s-ig kérdezi a `http://localhost:<port>/api/health`-t (staging: 8081, prod: 8080). Sikertelenség esetén app-logok kiírása + exit 1.
4. **Payload admin-seedelés** (MITHOME-97): healthcheck után meghívja a Payload beépített `POST /api/users/first-register` endpointját az `.env.<env>`-ben megadott `ADMIN_EMAIL`/`ADMIN_PASSWORD`-del. Ez az endpoint csakis akkor enged létrehozni felhasználót, ha a `users` collection teljesen üres — ezért idempotens: egy vadonatúj adatbázisnál (első deploy, vagy volume-törlés utáni újrakezdés) automatikusan létrehozza az ismert admin fiókot, minden további deployon pedig 403-at kap és ártalmatlanul kihagyja (a jelszót utólag sosem írja felül). Ha az `ADMIN_EMAIL`/`ADMIN_PASSWORD` hiányzik az env fájlból, ezt a lépést egyszerűen kihagyja (figyelmeztetéssel, nem hibával) — ilyenkor az első felhasználót manuálisan kell létrehozni `/admin` alatt.
## Tartalom publikálása (Payload admin) ## CMS-ből történő publikálás
A régi Content Editor (`content-editor.js`, git push-alapú publikálás) helyét a Payload admin (`/admin`) draft/publish + verziózás funkciója vette át (MITHOME-92/93) — ez nem jár git commit-tal vagy automatikus deployjal, a tartalom közvetlenül a MongoDB-be íródik. Kódváltozás (Globals/Collections séma, frontend) továbbra is a lenti git-alapú deploy folyamaton megy át. A Content Editor (`content-editor.js`) Publikálás gombja: commit + push (`content: frissítve a CMS-ből`), majd a `CMS_DEPLOY_ENV`-ben beállított környezet deployja a háttérben. **Soha nem deployol productionre implicit** — a `CMS_DEPLOY_ENV` kötelező, érvényes érték nélkül az editor el sem indul.
## Production deploy (élesítés) ## Production deploy (élesítés)
+2 -6
View File
@@ -55,12 +55,8 @@ git checkout -b feature/[leíró-feature-neve]
``` ```
### 3. Content frissítés (ha szöveges tartalom kell) ### 3. Content frissítés (ha szöveges tartalom kell)
- Új mező esetén bővítsd a megfelelő `proto/src/globals/*.ts` vagy - Módosítsd a megfelelő `proto/src/content/pages/*.json` fájlt
`proto/src/collections/*.ts` Payload config fájlt, majd generáld újra a - Frissítsd a `types.ts`-t ha új content struktúra kell
típusokat (`payload generate:types`)
- Meglévő mező szövegét az ügyfél a Payload admin felületen (`/admin`)
szerkeszti — ne írj bele közvetlenül `proto/src/content/*.json`-ba (az már
csak a migrációs seed-script forrása, élő oldal nem olvassa, MITHOME-91/93)
### 4. UI / Komponens implementáció (ha szükséges) ### 4. UI / Komponens implementáció (ha szükséges)
- Server Component alapértelmezetten — csak indokolt esetben `"use client"` - Server Component alapértelmezetten — csak indokolt esetben `"use client"`
+1 -2
View File
@@ -74,8 +74,7 @@ description: Kód review checklist — PR előtt és review során
✅ JÓ: ✅ JÓ:
[BLOCK] Ez hardcoded szöveget tartalmaz a komponensben. [BLOCK] Ez hardcoded szöveget tartalmaz a komponensben.
Át kell tenni Payload mezőbe (proto/src/globals/*.ts vagy collections/*.ts), Át kell tenni: proto/src/content/pages/home.json → content.pages.home.*
és a payload-content.ts adapteren keresztül kell beolvasni.
[SUGGEST] Ezt a logikát érdemes egy helper funkcióba kiszervezni, [SUGGEST] Ezt a logikát érdemes egy helper funkcióba kiszervezni,
ha több helyen is szükség lesz rá. ha több helyen is szükség lesz rá.
@@ -1,70 +0,0 @@
---
name: program-hid-update
description: Frissíti a "Program Híd" publikus Artifactot (MITHOME ↔ PLATFM konvergencia-térkép, PLATFM roadmap-sín, MITHOME Payload-epic progresssáv) a Plane MITHOME és PLATFM projektek friss állapota alapján. Használd, ha a felhasználó a "Program Híd" frissítését/szinkronizálását kéri, "állapotkép"-et, "program térkép"-et említ, vagy miután jelentős haladás történt a MITHOME Payload CMS epicben (MITHOME-85 alá tartozó ticketek) vagy a PLATFM roadmapben/AI-pilotban (PLATFM-1..12).
---
# Program Híd frissítés
Ez a skill szinkronban tartja a publikált **Program Híd** Artifactot (interaktív konvergencia-térkép a mozdIT MITHOME weboldal- és PLATFM platform-projektje között) a Plane aktuális állapotával. Az Artifact statikus HTML+JS — nincs élő Plane-kapcsolata, ezért kézzel (ezzel a skillel) kell frissen tartani.
## Állandók
- **Artifact URL** (mindig ugyanerre frissíts, ne hozz létre új linket): `https://claude.ai/code/artifact/95d7def2-c648-4fb7-a3f3-0d172b1c21ab`
- **MITHOME Plane project_id**: `643f7055-1237-4912-912f-99ec49fd0f0e`
- **PLATFM Plane project_id**: `b3e5b750-5a24-48f9-a3b6-e79925475b64`
- **Kanonikus forrásfájl ebben a repóban**: `.claude/skills/program-hid-update/source.html` — ez van feltöltve az Artifactra. Mindig ezt szerkeszd, ne a régi scratchpad-másolatot (az session-specifikus, nem marad meg).
## Lépések
### 1. Artifact olvasása jóváhagyás előtt
Hívd meg `Artifact` action `"read"`-et a fenti URL-lel, mielőtt bármit publikálsz — ez a tool előírása (nem publikálhatsz olyan artifactra, amit a beszélgetés még nem olvasott). Ha a visszakapott tartalom eltér a helyi `source.html`-től (pl. valaki kézzel szerkesztette a publikált oldalt), a Plane-ből frissen lekért adatot építsd *arra* a verzióra, ne a helyi fájlra.
### 2. Friss Plane-állapot lekérése
**MITHOME oldal** — kérdezd le ezeket (readable identifier vagy `get_issue_using_readable_identifier`):
- MITHOME-85 epic gyerekei: 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 102, 103, 105 — állapot (Done/Backlog/Todo/In Progress/Cancelled) és cím.
- Ellenőrizd `list_project_issues`-szal (project_id fent), hogy nem került-e be **új** gyerek a MITHOME-85 alá az utolsó frissítés óta — ha igen, vedd fel az `epicItems` tömbbe.
- MITHOME-31, MITHOME-40 (a hídon szereplő két satellit-ticket) állapota/prioritása.
**PLATFM oldal** — kérdezd le mind a 12 (vagy annál több, ha új került fel) issue-t `list_project_issues`-szal a PLATFM project_id-n:
- Melyik fázishoz tartoznak (a fázis-hozzárendelés statikus, lásd `source.html` `phases` tömb — csak akkor változtasd, ha a PLATFM-11 roadmap issue szövege explicit átsorolást ír le).
- Állapot/prioritás (kiemelten: PLATFM-5 BCDR és PLATFM-10 AI-pilot, mert ezek adják a híd "hot"/"live" jelölését).
### 3. Adatok újraszámolása
- **`epicItems`** (MITHOME Payload epic): `done: true`, ha a Plane state "Done"; frissítsd a címeket, ha változtak; vedd fel az új ticketeket.
- **`phases`** (PLATFM roadmap-sín): egy fázis `status` legyen `"progress"`, ha van benne legalább egy In Progress vagy Done issue és van még nyitott is; `"urgent"`, ha van benne urgent prioritású *nyitott* issue (ma ez az 1. fázis, PLATFM-5 miatt); egyébként `"open"`. Az `issues` tömb `p` mezője (`urgent`/`high`/`med`) kövesse a Plane priority mezőt.
- **`rows`** (híd, 6 konvergencia-pont): a `cls` mező jelzi az állapotot —
- `"live"` (zöld, animált): a konvergencia-pont mindkét oldala aktívan halad vagy már összekötött (pl. AI-triázs, amíg a MITHOME esemény-emisszió és a PLATFM-10 pilot is legalább In Progress).
- `"hot"` (piros, pulzáló): sürgős, még nincs megoldva — ma ez a BCDR sor (MITHOME-40 + PLATFM-5), amíg mindkettő nyitott.
- `""` (semleges): még nem aktuális, a trigger-feltétel nem teljesült.
- Ha egy konvergencia-pont mindkét oldala Done lesz, változtasd a sor feliratát "lezárva"-ra, és `cls` maradjon `"live"` (zöld), de a `trig` szöveg mondja ki, hogy megtörtént.
- **Fejléc chipek** (`.chiprow`): számold újra a "X aktív · Y kész · Z lezárt duplikátum" (MITHOME) és "N feladat · M urgent · 8 roadmap-fázis" (PLATFM) szövegeket a friss számokból.
- **`.stamp`** dátum: írd át a mai dátumra (`YYYY-MM-DD`, éles Plane-lekérdezés napja).
### 4. A `source.html` szerkesztése
Az adatok a fájl végén, a `<script>` blokkban vannak (`phases`, `unplaced`, `rows`, `epicItems` JS tömbök) — ezeket szerkeszd Edit-tel, ne írd újra az egész fájlt. A CSS/HTML-váz (fejléc, szekciók, SVG-koordináták) stabil marad, hacsak nem változik a konvergencia-pontok *száma* (akkor a bridge SVG `viewBox` magassága és a `rowH`/`top` számítás is igazításra szorul — lásd a JS-ben a `rows.forEach` ciklust).
Ha egy phase `issues` tömbje bővül/csökken, a fejlécben lévő `pcount` automatikusan követi (JS-ből generálódik), nincs kézi szinkron.
### 5. Ellenőrzés publikálás előtt
- Nézd át, hogy minden JS tömb szintaktikailag zárt (vesszők, zárójelek) — egy törött `<script>` az egész oldalt elviszi.
- Számold meg a `{`/`}` és `(`/`)` párokat a módosított szakaszban, ha bizonytalan vagy.
- Ha van rá mód (bejelentkezett böngésző-session), nyisd meg az Artifact URL-t és nézd meg egyszer, mielőtt publikálod a végleges verziót — lásd az `artifact-design` skill "write, look once, publish" szabályát.
### 6. Publikálás
`Artifact` action `"publish"` (alapértelmezett), `file_path` a `source.html`-re, **`url`** a fenti Artifact URL-re (így ugyanaz a link marad, nem jön létre új Artifact). `favicon`-t és `title`-t **ne** add meg újra — a redeploy megtartja a meglévőt.
### 7. Nyomon követés
- Írj egy rövid sort a `TODO.md` "Frissítési Napló" szekciójába: `**YYYY-MM-DD**: Program Híd frissítve (X/Y MITHOME Payload-feladat kész, N nyitott PLATFM-item).`
- Commitold a `source.html` változást (`git add .claude/skills/program-hid-update/source.html TODO.md && git commit` a projekt konvenciói szerint — `docs:` vagy `chore:` prefix).
- Foglald össze a felhasználónak, mi változott a térképen (melyik konvergencia-pont mozdult, melyik fázis indult el).
## Ha rendszeres/automatikus futtatást szeretne a felhasználó
Ez a skill önmagában **kézi indítású** (a felhasználó vagy egy másik munkamenet hívja meg). Ha valódi, felügyelet nélküli, időzített frissítést szeretnének (pl. hetente egyszer), azt a `schedule` skillel/CronCreate-tel lehet erre a skillre ráépíteni — ezt csak akkor állítsd be, ha a felhasználó explicit kéri, és mondd el neki, hogy ez azt jelenti: rendszeres, felügyelet nélküli Plane-olvasás és Artifact-publikálás fog lezajlani a háttérben.
@@ -1,685 +0,0 @@
<title>Program Híd</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap">
<style>
:root{
--bg:#F2F6F6;
--surface:#FFFFFF;
--surface-2:#E7EEEE;
--ink:#152029;
--ink-soft:#4C606D;
--ink-faint:#7E909B;
--line:#C7D5D8;
--line-strong:#A9BCC0;
--accent:#0F7A8F;
--accent-ink:#FFFFFF;
--accent-soft:#DCEEF1;
--success:#2C8A57;
--success-soft:#DEF1E6;
--warning:#AD6F0B;
--warning-soft:#F6E9D2;
--critical:#C13B30;
--critical-soft:#F8DEDB;
--shadow: 0 1px 2px rgba(21,32,41,.06), 0 8px 24px -12px rgba(21,32,41,.18);
--radius: 10px;
font-synthesis: none;
}
@media (prefers-color-scheme: dark){
:root:not([data-theme="light"]){
--bg:#0D151D;
--surface:#131E28;
--surface-2:#1A2733;
--ink:#E7EFF2;
--ink-soft:#9BAEBB;
--ink-faint:#6C8090;
--line:#2A3C49;
--line-strong:#3B5062;
--accent:#3FBBD1;
--accent-ink:#052229;
--accent-soft:#163642;
--success:#4FBE84;
--success-soft:#173428;
--warning:#E3A741;
--warning-soft:#3A2B10;
--critical:#E2685C;
--critical-soft:#3A1D1A;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 12px 28px -14px rgba(0,0,0,.6);
}
}
:root[data-theme="dark"]{
--bg:#0D151D;
--surface:#131E28;
--surface-2:#1A2733;
--ink:#E7EFF2;
--ink-soft:#9BAEBB;
--ink-faint:#6C8090;
--line:#2A3C49;
--line-strong:#3B5062;
--accent:#3FBBD1;
--accent-ink:#052229;
--accent-soft:#163642;
--success:#4FBE84;
--success-soft:#173428;
--warning:#E3A741;
--warning-soft:#3A2B10;
--critical:#E2685C;
--critical-soft:#3A1D1A;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 12px 28px -14px rgba(0,0,0,.6);
}
*{ box-sizing:border-box; }
body{
margin:0;
background:var(--bg);
color:var(--ink);
font-family:"IBM Plex Sans", system-ui, -apple-system, sans-serif;
line-height:1.5;
}
.mono{ font-family:"IBM Plex Mono", ui-monospace, "SF Mono", monospace; }
.wrap{
max-width:1180px;
margin:0 auto;
padding:36px 20px 64px;
}
/* ---------- header ---------- */
header.hero{
display:flex;
flex-direction:column;
gap:14px;
margin-bottom:34px;
}
.eyebrow{
font-family:"IBM Plex Mono", monospace;
font-size:11.5px;
letter-spacing:.14em;
text-transform:uppercase;
color:var(--accent);
font-weight:600;
}
h1{
font-size:clamp(28px, 4.4vw, 42px);
line-height:1.08;
margin:0;
letter-spacing:-0.01em;
text-wrap:balance;
font-weight:700;
}
.lede{
max-width:64ch;
color:var(--ink-soft);
font-size:15.5px;
margin:0;
}
.stamp{
font-family:"IBM Plex Mono", monospace;
font-size:12px;
color:var(--ink-faint);
}
.chiprow{
display:flex;
flex-wrap:wrap;
gap:10px;
margin-top:6px;
}
.chip{
display:flex;
align-items:baseline;
gap:8px;
background:var(--surface);
border:1px solid var(--line);
border-radius:999px;
padding:7px 14px 7px 12px;
box-shadow:var(--shadow);
}
.chip .dot{ width:8px; height:8px; border-radius:50%; flex:none; }
.chip .proj{ font-family:"IBM Plex Mono", monospace; font-weight:600; font-size:12.5px; letter-spacing:.02em; }
.chip .stat{ font-size:12.5px; color:var(--ink-soft); }
.chip.mithome .dot{ background:var(--accent); }
.chip.platfm .dot{ background:#8B6BC9; }
.filterbar{
display:flex;
gap:6px;
margin-top:4px;
}
.filterbar button{
font-family:"IBM Plex Mono", monospace;
font-size:12px;
letter-spacing:.02em;
border:1px solid var(--line);
background:var(--surface);
color:var(--ink-soft);
padding:6px 12px;
border-radius:7px;
cursor:pointer;
transition: background .15s ease, color .15s ease, border-color .15s ease;
}
.filterbar button:hover{ border-color:var(--line-strong); color:var(--ink); }
.filterbar button[aria-pressed="true"]{
background:var(--ink);
color:var(--bg);
border-color:var(--ink);
}
:root[data-theme="dark"] .filterbar button[aria-pressed="true"]{
background:var(--accent); color:var(--accent-ink); border-color:var(--accent);
}
@media (prefers-color-scheme:dark){
:root:not([data-theme="light"]) .filterbar button[aria-pressed="true"]{
background:var(--accent); color:var(--accent-ink); border-color:var(--accent);
}
}
/* ---------- section shell ---------- */
section{ margin-top:46px; }
.sectionhead{
display:flex;
align-items:baseline;
justify-content:space-between;
gap:12px;
margin-bottom:16px;
border-bottom:1px solid var(--line);
padding-bottom:10px;
}
.sectionhead h2{
font-size:14px;
text-transform:uppercase;
letter-spacing:.1em;
margin:0;
font-weight:600;
color:var(--ink-soft);
}
.sectionhead .note{ font-size:12.5px; color:var(--ink-faint); font-family:"IBM Plex Mono", monospace; }
/* ---------- phase rail ---------- */
.railscroll{ overflow-x:auto; padding-bottom:6px; margin: 0 -4px; }
.rail{
position:relative;
display:flex;
gap:0;
min-width:840px;
padding:34px 4px 4px;
}
.rail::before{
content:"";
position:absolute;
top:14px; left:44px; right:44px; height:2px;
background: repeating-linear-gradient(90deg, var(--line-strong) 0 8px, transparent 8px 14px);
background-size: 200% 100%;
animation: flow 6s linear infinite;
}
@media (prefers-reduced-motion: reduce){ .rail::before{ animation:none; } }
@keyframes flow{ from{ background-position:0 0; } to{ background-position:-28px 0; } }
.phase{
flex:1 1 0;
display:flex;
flex-direction:column;
align-items:center;
text-align:center;
padding:0 8px;
position:relative;
cursor:pointer;
transition:opacity .25s ease;
}
.phase .node{
width:26px; height:26px; border-radius:50%;
background:var(--surface);
border:2px solid var(--line-strong);
display:flex; align-items:center; justify-content:center;
font-family:"IBM Plex Mono", monospace;
font-size:11px; font-weight:600; color:var(--ink-soft);
z-index:1;
transition: border-color .15s ease, transform .15s ease, color .15s ease;
}
.phase[data-status="progress"] .node{ border-color:var(--accent); color:var(--accent); background:var(--accent-soft); }
.phase[data-status="urgent"] .node{ border-color:var(--critical); color:var(--critical); background:var(--critical-soft); }
.phase:hover .node{ transform:scale(1.12); }
.phase .pname{
margin-top:10px; font-size:12.5px; font-weight:600; max-width:15ch; text-wrap:balance;
}
.phase .pcount{
margin-top:3px; font-size:11px; color:var(--ink-faint); font-family:"IBM Plex Mono", monospace;
}
.phase.dim{ opacity:.32; }
.phase-extra{
flex:0 0 auto;
width:150px;
padding-left:14px;
margin-left:6px;
border-left:1px dashed var(--line-strong);
display:flex; flex-direction:column; align-items:flex-start; text-align:left;
cursor:pointer;
}
.phase-extra .tag{
font-family:"IBM Plex Mono", monospace; font-size:10.5px; text-transform:uppercase; letter-spacing:.08em;
color:var(--warning); font-weight:600; margin-bottom:6px;
}
.phase-extra .pname{ font-size:12.5px; font-weight:600; }
.phase-extra .pcount{ font-size:11px; color:var(--ink-faint); font-family:"IBM Plex Mono", monospace; margin-top:3px;}
.detail{
margin-top:18px;
background:var(--surface);
border:1px solid var(--line);
border-radius:var(--radius);
box-shadow:var(--shadow);
padding:18px 20px;
display:none;
}
.detail.open{ display:block; }
.detail h3{ margin:0 0 3px; font-size:15px; }
.detail .sub{ margin:0 0 14px; font-size:12.5px; color:var(--ink-faint); font-family:"IBM Plex Mono", monospace; }
.issuelist{ display:flex; flex-direction:column; gap:8px; }
.issue{
display:flex; align-items:center; gap:10px;
padding:8px 10px;
background:var(--surface-2);
border-radius:7px;
}
.issue .id{
font-family:"IBM Plex Mono", monospace; font-size:11.5px; font-weight:600;
background:var(--surface); border:1px solid var(--line); border-radius:5px;
padding:2px 7px; flex:none; color:var(--ink-soft);
}
.issue .t{ font-size:13px; flex:1; }
.issue .p{ font-size:10.5px; font-family:"IBM Plex Mono", monospace; text-transform:uppercase; letter-spacing:.06em; flex:none; padding:2px 7px; border-radius:5px; }
.p.urgent{ background:var(--critical-soft); color:var(--critical); }
.p.high{ background:var(--warning-soft); color:var(--warning); }
.p.med{ background:var(--surface); color:var(--ink-faint); border:1px solid var(--line); }
.empty-note{ font-size:13px; color:var(--ink-faint); font-style:italic; }
/* ---------- bridge ---------- */
.bridgescroll{ overflow-x:auto; }
.bridge-wrap{ min-width:760px; }
svg.bridge{ width:100%; height:auto; display:block; overflow:visible; }
.col-label{
font-family:"IBM Plex Mono", monospace; font-size:11.5px; font-weight:600; letter-spacing:.06em; text-transform:uppercase;
}
.node-card{ cursor:pointer; }
.node-card rect{ fill:var(--surface); stroke:var(--line-strong); stroke-width:1.3; transition:stroke .15s ease, filter .15s ease; }
.node-card:hover rect{ stroke:var(--accent); }
.node-title{ font-size:12.5px; font-weight:600; fill:var(--ink); font-family:"IBM Plex Sans", sans-serif; }
.node-sub{ font-size:10.5px; fill:var(--ink-faint); font-family:"IBM Plex Mono", monospace; }
.conn-label{ font-size:10px; fill:var(--ink-faint); font-family:"IBM Plex Mono", monospace; }
.conn-label.hot{ fill:var(--critical); font-weight:600; }
.conn-label.live{ fill:var(--success); font-weight:600; }
.flowpath{ fill:none; stroke:var(--line-strong); stroke-width:1.6; stroke-dasharray:1 7; stroke-linecap:round; }
.flowpath.live{ stroke:var(--success); stroke-dasharray:5 5; animation:dash 1.8s linear infinite; }
.flowpath.hot{ stroke:var(--critical); stroke-dasharray:5 5; animation:dash 1.2s linear infinite; }
@keyframes dash{ to{ stroke-dashoffset:-20; } }
@media (prefers-reduced-motion: reduce){ .flowpath.live, .flowpath.hot{ animation:none; } }
.ghost rect{ fill:none; stroke:var(--line); stroke-dasharray:3 3; }
.ghost text{ fill:var(--ink-faint); }
.ghostpath{ fill:none; stroke:var(--line); stroke-width:1.2; stroke-dasharray:2 4; }
.side.dim{ opacity:.28; transition:opacity .25s ease; }
.flowpath.dim, .conn-label.dim{ opacity:.15; }
/* ---------- epic progress ---------- */
.epicbar{
display:flex; flex-wrap:wrap; gap:8px;
}
.seg{
display:flex; align-items:center; gap:8px;
background:var(--surface); border:1px solid var(--line); border-radius:7px;
padding:8px 10px;
min-width:200px; flex:1 1 220px;
}
.seg .id{ font-family:"IBM Plex Mono", monospace; font-size:11px; font-weight:600; color:var(--ink-soft); flex:none; }
.seg .t{ font-size:12.5px; flex:1; }
.seg .state{ width:9px; height:9px; border-radius:50%; flex:none; }
.state.done{ background:var(--success); }
.state.open{ background:var(--ink-faint); }
.progresswrap{
display:flex; align-items:center; gap:12px; margin-bottom:16px;
}
.progresstrack{
flex:1; height:8px; border-radius:99px; background:var(--surface-2); overflow:hidden;
}
.progressfill{
height:100%; border-radius:99px;
background:linear-gradient(90deg, var(--accent), var(--success));
transition:width .6s ease;
}
.progresslabel{ font-family:"IBM Plex Mono", monospace; font-size:12.5px; color:var(--ink-soft); flex:none; }
footer{
margin-top:50px; padding-top:18px; border-top:1px solid var(--line);
font-size:12px; color:var(--ink-faint); font-family:"IBM Plex Mono", monospace;
display:flex; flex-wrap:wrap; gap:6px 18px; justify-content:space-between;
}
@media (max-width:640px){
.wrap{ padding:26px 14px 48px; }
.sectionhead{ flex-direction:column; align-items:flex-start; gap:4px; }
}
</style>
<div class="wrap">
<header class="hero">
<span class="eyebrow">mozdIT Bt. · program áttekintés</span>
<h1>Program Híd</h1>
<p class="lede">MITHOME (mozdIT weboldal + Payload CMS-migráció) és PLATFM (a szélesebb vállalati platform) között hat ponton ér össze a munka. Ez az oldal azt mutatja, hol tartunk, és mi indítja el a következő találkozási pontot.</p>
<p class="stamp">Statikus állapotkép — 2026&#8209;09&#8209;10, a Plane MITHOME + PLATFM projektek alapján</p>
<div class="chiprow">
<div class="chip mithome"><span class="dot"></span><span class="proj">MITHOME</span><span class="stat">15 aktív · 2 kész · 7 lezárt duplikátum</span></div>
<div class="chip platfm"><span class="dot"></span><span class="proj">PLATFM</span><span class="stat">12 feladat · 1 urgent · 8 roadmap-fázis</span></div>
</div>
<div class="filterbar" role="group" aria-label="Szűrés projekt szerint">
<button type="button" data-filter="all" aria-pressed="true">Mindkettő</button>
<button type="button" data-filter="mithome" aria-pressed="false">MITHOME</button>
<button type="button" data-filter="platfm" aria-pressed="false">PLATFM</button>
</div>
</header>
<!-- ===================== PHASE RAIL ===================== -->
<section>
<div class="sectionhead">
<h2>PLATFM roadmap · 8 fázis</h2>
<span class="note">sorrend van, dátum nincs — kattints egy fázisra</span>
</div>
<div class="railscroll">
<div class="rail" id="rail">
<!-- phases injected by JS -->
</div>
</div>
<div class="detail" id="phaseDetail"></div>
</section>
<!-- ===================== BRIDGE ===================== -->
<section>
<div class="sectionhead">
<h2>Konvergencia-híd</h2>
<span class="note">6 pont, ahol a két projekt ugyanazt a dolgot érinti</span>
</div>
<div class="bridgescroll">
<div class="bridge-wrap">
<svg class="bridge" viewBox="0 0 940 566" role="img" aria-label="MITHOME és PLATFM közötti hat konvergencia-pont, összekötő nyilakkal és aktiválási feltétellel">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="var(--line-strong)"></path>
</marker>
<marker id="arrowLive" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="var(--success)"></path>
</marker>
<marker id="arrowHot" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="var(--critical)"></path>
</marker>
</defs>
<text x="40" y="26" class="col-label" fill="var(--accent)">MITHOME · weboldal</text>
<text x="900" y="26" class="col-label" fill="#8B6BC9" text-anchor="end">PLATFM · vállalati platform</text>
<!-- connector paths (drawn first, under nodes) -->
<g id="connectors"></g>
<!-- nodes -->
<g id="leftNodes"></g>
<g id="rightNodes"></g>
<!-- ghost story node -->
<g class="ghost" id="ghostNode" transform="translate(392,470)">
<rect x="0" y="0" width="156" height="46" rx="8"></rect>
<text x="12" y="18" class="node-sub">MITHOME-99 EPIC</text>
<text x="12" y="33" class="node-sub">duplikátum · lezárva 09-10</text>
</g>
</svg>
</div>
</div>
</section>
<!-- ===================== EPIC PROGRESS ===================== -->
<section id="mithomeSection">
<div class="sectionhead">
<h2>MITHOME · Payload CMS epic</h2>
<span class="note">MITHOME-85 alá tartozó feladatok</span>
</div>
<div class="progresswrap">
<div class="progresstrack"><div class="progressfill" id="epicFill" style="width:0%"></div></div>
<span class="progresslabel" id="epicLabel">2 / 15 kész</span>
</div>
<div class="epicbar" id="epicBar"></div>
</section>
<footer>
<span>Forrás: Plane · MITHOME + PLATFM projektek</span>
<span>Frissítve: 2026-09-10 · nem szinkronizál élőben</span>
</footer>
</div>
<script>
(function(){
var phases = [
{n:1, name:"Platform-alapok és governance", status:"progress", issues:[
{id:"PLATFM-1", t:"Platform architektúra és komponensfüggőségi nyilvántartás", p:"high"},
{id:"PLATFM-5", t:"Üzletmenet-folytonossági és katasztrófa-helyreállítási terv (BCDR)", p:"urgent"},
{id:"PLATFM-6", t:"Build vs. Open Source döntési keret és komponensenkénti ADR-ek", p:"high"},
{id:"PLATFM-8", t:"Infrastruktúra-kapacitás, értékesíthető készlet és bővítési ROI modell", p:"high"},
{id:"PLATFM-9", t:"Magyar mikro- és KKV webszolgáltatási piac kutatása és termékpriorizálás", p:"high"}
]},
{n:2, name:"IDM v1 (identitás, RBAC, audit)", status:"open", issues:[
{id:"PLATFM-4", t:"Közös jogosultsági modell (RBAC + adatbesorolás) alapjai", p:"high"}
]},
{n:3, name:"Content Release réteg", status:"open", issues:[]},
{n:4, name:"Központi termékkatalógus", status:"open", issues:[
{id:"PLATFM-3", t:"Központi termék- és szolgáltatáskatalógus tervezése (mIT Start/Mid/Pro/Pro Plus)", p:"high"}
]},
{n:5, name:"Belső mIT Portal v1", status:"open", issues:[]},
{n:6, name:"Messaging Gateway", status:"open", issues:[
{id:"PLATFM-2", t:"Központi Messaging Gateway tervezése és első e-mail csatorna", p:"high"}
]},
{n:7, name:"Rendelés / számlázás / provisioning", status:"open", issues:[
{id:"PLATFM-7", t:"Felügyelt szolgáltatás-teljesítési lánc (rendelés → számlázás → provisioning → üzemeltetés)", p:"high"}
]},
{n:8, name:"RAG és tudásréteg", status:"open", issues:[]}
];
var unplaced = {name:"Nincs fázisba sorolva", issues:[
{id:"PLATFM-10", t:"Kis kapacitásigényű, felügyelt AI-funkciók pilot és ROI-validáció", p:"med"},
{id:"PLATFM-12", t:"ADR: AI Gateway / triázs-motor — Hermes Agent vs. alternatívák", p:"high"}
]};
var rail = document.getElementById("rail");
var detail = document.getElementById("phaseDetail");
function statusLabel(s){
if(s==="progress") return "folyamatban";
if(s==="urgent") return "sürgős";
return "nem indult";
}
function renderDetail(name, issues, sub){
var html = "<h3>"+name+"</h3><p class=\"sub\">"+sub+"</p>";
if(issues.length===0){
html += "<p class=\"empty-note\">Ehhez a fázishoz még nincs Plane-feladat — az előtte lévő fázisok lezárása után nyílik meg.</p>";
} else {
html += "<div class=\"issuelist\">" + issues.map(function(i){
return "<div class=\"issue\"><span class=\"id\">"+i.id+"</span><span class=\"t\">"+i.t+"</span><span class=\"p "+i.p+"\">"+i.p+"</span></div>";
}).join("") + "</div>";
}
detail.innerHTML = html;
detail.classList.add("open");
}
var openPhase = null;
function togglePhase(el, name, issues, sub){
if(openPhase === el){
detail.classList.remove("open");
openPhase = null;
return;
}
openPhase = el;
renderDetail(name, issues, sub);
}
phases.forEach(function(ph){
var el = document.createElement("div");
el.className = "phase";
el.dataset.status = ph.status;
el.dataset.side = "platfm";
el.innerHTML = "<div class=\"node\">"+ph.n+"</div><div class=\"pname\">"+ph.name+"</div><div class=\"pcount\">"+ph.issues.length+" feladat</div>";
el.addEventListener("click", function(){
togglePhase(el, ph.n+". fázis · "+ph.name, ph.issues, "Roadmap-sorrend szerinti "+ph.n+". lépés · státusz: "+statusLabel(ph.status));
});
rail.appendChild(el);
});
var extra = document.createElement("div");
extra.className = "phase-extra";
extra.dataset.side = "platfm";
extra.innerHTML = "<div class=\"tag\">függőben</div><div class=\"pname\">"+unplaced.name+"</div><div class=\"pcount\">"+unplaced.issues.length+" feladat</div>";
extra.addEventListener("click", function(){
togglePhase(extra, unplaced.name, unplaced.issues, "PLATFM-10 pilot elindult izoláltan, mielőtt a roadmap besorolta volna — 2026-09-10-i döntés");
});
rail.appendChild(extra);
// ---------------- bridge ----------------
var leftG = document.getElementById("leftNodes");
var rightG = document.getElementById("rightNodes");
var connG = document.getElementById("connectors");
var rows = [
{
left:{t:"Esemény-forrás", sub:"MITHOME-102 · 103 · 105"},
right:{t:"AI-triázs pilot + ADR", sub:"PLATFM-10 · 12"},
trig:"már aktív", cls:"live"
},
{
left:{t:"Payload Users", sub:"MITHOME-90 · helyi auth"},
right:{t:"IDM v1 / RBAC", sub:"PLATFM-4 · 2. fázis"},
trig:"IDM v1 indulásakor", cls:""
},
{
left:{t:"Draft & régi CMS", sub:"MITHOME-92 · 93"},
right:{t:"Content Release réteg", sub:"3. fázis"},
trig:"réteg indulásakor", cls:""
},
{
left:{t:"Szolgáltatások oldal", sub:"Payload Services global"},
right:{t:"Termékkatalógus", sub:"PLATFM-3 · 4. fázis"},
trig:"katalógus MVP-nél", cls:""
},
{
left:{t:"Kapcsolati form", sub:"e-mail értesítés"},
right:{t:"Messaging Gateway", sub:"PLATFM-2 · 6. fázis"},
trig:"gateway kész", cls:""
},
{
left:{t:"Mongo mentés", sub:"MITHOME-40 · urgent"},
right:{t:"BCDR terv", sub:"PLATFM-5 · RPO 15p / RTO 4ó"},
trig:"production induláskor", cls:"hot"
}
];
var top = 60, rowH = 68, boxW = 210, boxH = 46;
var leftX = 40, rightX = 940 - 40 - boxW;
rows.forEach(function(r, idx){
var y = top + idx*rowH;
var lg = document.createElementNS("http://www.w3.org/2000/svg","g");
lg.setAttribute("class","node-card"); lg.setAttribute("data-side","mithome");
lg.setAttribute("transform","translate("+leftX+","+y+")");
lg.innerHTML = '<rect width="'+boxW+'" height="'+boxH+'" rx="8"></rect>'+
'<text x="14" y="19" class="node-title">'+r.left.t+'</text>'+
'<text x="14" y="34" class="node-sub">'+r.left.sub+'</text>';
leftG.appendChild(lg);
var rg = document.createElementNS("http://www.w3.org/2000/svg","g");
rg.setAttribute("class","node-card"); rg.setAttribute("data-side","platfm");
rg.setAttribute("transform","translate("+rightX+","+y+")");
rg.innerHTML = '<rect width="'+boxW+'" height="'+boxH+'" rx="8"></rect>'+
'<text x="14" y="19" class="node-title">'+r.right.t+'</text>'+
'<text x="14" y="34" class="node-sub">'+r.right.sub+'</text>';
rightG.appendChild(rg);
var x1 = leftX+boxW, y1 = y+boxH/2, x2 = rightX, y2 = y+boxH/2;
var mx = (x1+x2)/2;
var d = "M"+x1+","+y1+" C "+mx+","+y1+" "+mx+","+y2+" "+(x2-8)+","+y2;
var marker = r.cls==="live" ? "url(#arrowLive)" : (r.cls==="hot" ? "url(#arrowHot)" : "url(#arrow)");
var path = document.createElementNS("http://www.w3.org/2000/svg","path");
path.setAttribute("d", d);
path.setAttribute("class","flowpath "+r.cls);
path.setAttribute("marker-end", marker);
path.setAttribute("data-side","bridge");
connG.appendChild(path);
var label = document.createElementNS("http://www.w3.org/2000/svg","text");
label.setAttribute("x", mx);
label.setAttribute("y", y1 - 8);
label.setAttribute("text-anchor","middle");
label.setAttribute("class","conn-label"+(r.cls==="hot"?" hot":r.cls==="live"?" live":""));
label.setAttribute("data-side","bridge");
label.textContent = r.trig;
connG.appendChild(label);
});
// ghost connector: MITHOME-99 (cancelled) -> PLATFM-10
var ghostPath = document.createElementNS("http://www.w3.org/2000/svg","path");
var gx1 = 392, gy1 = 470+10, gx2 = rightX+8, gy2 = top + 0*rowH + boxH/2;
ghostPath.setAttribute("d","M"+gx1+","+gy1+" C "+(gx1-120)+","+gy1+" "+(gx2-40)+","+gy2+" "+gx2+","+gy2);
ghostPath.setAttribute("class","ghostpath");
connG.insertBefore(ghostPath, connG.firstChild);
// ---------------- epic progress ----------------
var epicItems = [
{id:"MITHOME-86", t:"Alapinstallálás: config, MongoDB adapter, admin route", done:true},
{id:"MITHOME-87", t:"Home/About/Services/Contact/Common Globals + JSON migráció", done:true},
{id:"MITHOME-88", t:"LegalPages collection", done:false},
{id:"MITHOME-89", t:"Partners + Media collection", done:false},
{id:"MITHOME-90", t:"Users collection: admin bejelentkezés", done:false},
{id:"MITHOME-91", t:"Frontend Local API átállás", done:false},
{id:"MITHOME-92", t:"Draft/preview és verziózás", done:false},
{id:"MITHOME-93", t:"Régi egyedi CMS leépítése", done:false},
{id:"MITHOME-94", t:"ContactSubmissions collection", done:false},
{id:"MITHOME-95", t:"Dokumentáció frissítése", done:false},
{id:"MITHOME-96", t:"Tesztek migrálása", done:false},
{id:"MITHOME-97", t:"Deploy/Docker frissítés", done:false},
{id:"MITHOME-102", t:"Esemény-emisszió — PLATFM-2 séma", done:false},
{id:"MITHOME-103", t:"Esemény-emisszió biztonsága", done:false},
{id:"MITHOME-105", t:"MITHOME-31 lezárása a pilottal", done:false}
];
var epicBar = document.getElementById("epicBar");
epicItems.forEach(function(it){
var el = document.createElement("div");
el.className = "seg";
el.dataset.side = "mithome";
el.innerHTML = '<span class="state '+(it.done?"done":"open")+'"></span>'+
'<span class="id">'+it.id+'</span><span class="t">'+it.t+'</span>';
epicBar.appendChild(el);
});
var doneCount = epicItems.filter(function(i){return i.done;}).length;
document.getElementById("epicFill").style.width = Math.round(doneCount/epicItems.length*100)+"%";
document.getElementById("epicLabel").textContent = doneCount+" / "+epicItems.length+" kész";
// ---------------- filter ----------------
var buttons = document.querySelectorAll(".filterbar button");
buttons.forEach(function(btn){
btn.addEventListener("click", function(){
buttons.forEach(function(b){ b.setAttribute("aria-pressed", b===btn ? "true":"false"); });
var f = btn.dataset.filter;
document.querySelectorAll('[data-side="mithome"]').forEach(function(n){
n.classList.toggle("dim", f==="platfm");
});
document.querySelectorAll('[data-side="platfm"]').forEach(function(n){
n.classList.toggle("dim", f==="mithome");
});
document.querySelectorAll('[data-side="bridge"]').forEach(function(n){
n.classList.toggle("dim", f!=="all");
});
});
});
})();
</script>
-15
View File
@@ -7,21 +7,6 @@ MONGODB_DB=mozdit
MONGO_ROOT_USER=admin MONGO_ROOT_USER=admin
MONGO_ROOT_PASSWORD=changeme MONGO_ROOT_PASSWORD=changeme
# Payload CMS (MITHOME-86) — generálj egyedi, erős titkot ehhez a
# környezethez (pl. `openssl rand -hex 32`); ne ossz meg értéket másik
# environmenttel.
PAYLOAD_SECRET=changeme
# Payload admin — ismert, biztosan létező admin fiók (MITHOME-97). A
# deploy.sh minden deploy után meghívja a Payload "first-register" REST
# endpointját ezekkel az adatokkal: teljesen friss (üres users collection-ű)
# adatbázisnál létrehozza ezt a fiókot, egyébként (ha már van user)
# ártalmatlanul kihagyja — nem írja felül a jelszót utólag. Generálj egyedi,
# erős jelszót (pl. jelszókezelővel), NE ossz meg értéket a staginggel; a
# hiányzó két érték esetén a deploy.sh csak kihagyja ezt a lépést.
ADMIN_EMAIL=admin@mozdit.hu
ADMIN_PASSWORD=changeme
# Publikus site URL (beég build időben a Dockerfile-ba) # Publikus site URL (beég build időben a Dockerfile-ba)
NEXT_PUBLIC_SITE_URL=https://mozdit.hu NEXT_PUBLIC_SITE_URL=https://mozdit.hu
-15
View File
@@ -7,21 +7,6 @@ MONGODB_DB=mozdit
MONGO_ROOT_USER=admin MONGO_ROOT_USER=admin
MONGO_ROOT_PASSWORD=changeme MONGO_ROOT_PASSWORD=changeme
# Payload CMS (MITHOME-86) — generálj egyedi, erős titkot ehhez a
# környezethez (pl. `openssl rand -hex 32`); ne ossz meg értéket másik
# environmenttel.
PAYLOAD_SECRET=changeme
# Payload admin — ismert, biztosan létező admin fiók (MITHOME-97). A
# deploy.sh minden deploy után meghívja a Payload "first-register" REST
# endpointját ezekkel az adatokkal: teljesen friss (üres users collection-ű)
# adatbázisnál létrehozza ezt a fiókot, egyébként (ha már van user)
# ártalmatlanul kihagyja — nem írja felül a jelszót utólag. Generálj egyedi,
# erős jelszót (pl. jelszókezelővel); a hiányzó két érték esetén a
# deploy.sh csak kihagyja ezt a lépést, nem hibázik el.
ADMIN_EMAIL=admin@mozdit.hu
ADMIN_PASSWORD=changeme
# Publikus site URL (beég build időben a Dockerfile-ba) # Publikus site URL (beég build időben a Dockerfile-ba)
NEXT_PUBLIC_SITE_URL=https://stage.mozdit.hu NEXT_PUBLIC_SITE_URL=https://stage.mozdit.hu
+9 -15
View File
@@ -10,14 +10,14 @@
- **Projekt**: mozdIT Bt. weboldal (websitedev) - **Projekt**: mozdIT Bt. weboldal (websitedev)
- **Plane workspace**: `developments``pm.llmdev.mozdit.hu` - **Plane workspace**: `developments``pm.llmdev.mozdit.hu`
- **Stack**: Next.js 15, React 19, TypeScript, Tailwind CSS 4, Payload CMS (self-hosted, `/admin`), MongoDB, Winston - **Stack**: Next.js 15, React 19, TypeScript, Tailwind CSS 4, MongoDB, Winston
- **Fő könyvtár**: `proto/`**minden parancsot innen futtatunk** - **Fő könyvtár**: `proto/`**minden parancsot innen futtatunk**
--- ---
## Gyors referencia — Alapszabályok ## Gyors referencia — Alapszabályok
1. **Szöveg sosem kerülhet közvetlenül komponensbe**Payload CMS Global/Collection mező (admin: `/admin`), a frontend `proto/src/lib/payload-content.ts`-en át olvassa. (`proto/src/content/pages/*.json` már csak a migrációs seed-script forrása + teszt-fixture, élő oldal nem használja — MITHOME-91/93/96.) 1. **Szöveg sosem kerülhet közvetlenül komponensbe**`proto/src/content/pages/*.json`
2. **Feladatok**: `TODO.md` (Plane tükörképe), szinkron: `node plane-sync.js` 2. **Feladatok**: `TODO.md` (Plane tükörképe), szinkron: `node plane-sync.js`
3. **Tesztek**: minden feature-höz kötelező; commit előtt `npm test` zöld 3. **Tesztek**: minden feature-höz kötelező; commit előtt `npm test` zöld
4. **Commit**: Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`) 4. **Commit**: Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`)
@@ -47,24 +47,19 @@ node plane-sync.js # Plane szinkronizáció
websitedev/ websitedev/
├── proto/ # Fő Next.js alkalmazás ├── proto/ # Fő Next.js alkalmazás
│ └── src/ │ └── src/
│ ├── app/ │ ├── app/ # App Router: oldalak és API route-ok
│ │ ├── (frontend)/ # Publikus oldalak: [locale]/[slug] catch-all
│ │ └── (payload)/ # Payload admin (/admin) + REST/GraphQL API
│ ├── components/ # React komponensek (PascalCase.tsx) │ ├── components/ # React komponensek (PascalCase.tsx)
│ ├── globals/ # Payload Globals (Home, About, Services, Contact, Common) │ ├── content/ # JSON tartalom-kezelő rendszer
│ ├── collections/ # Payload Collections (LegalPages, Partners, Media, ContactSubmissions, Users) ├── common.json # Közös szövegek
├── payload.config.ts # Payload CMS konfiguráció │ └── pages/ # Oldal-specifikus JSONok
│ ├── content/ # RÉGI JSON rendszer — csak migrációs seed + teszt-fixture (MITHOME-91/93/96) │ ├── lib/ # MongoDB, Logger, Site Config
│ │ ├── common.json
│ │ └── pages/
│ ├── lib/ # payload-content.ts (Local API adapter), MongoDB health-check, Logger
│ ├── config/ # Statikus site konfiguráció │ ├── config/ # Statikus site konfiguráció
│ └── types/ # TypeScript típusok │ └── types/ # TypeScript típusok
├── docs/ # Projekt dokumentáció (Magyar) ├── docs/ # Projekt dokumentáció (Magyar)
├── .agent/ # AI szabályrendszer ← OLVASD EL ├── .agent/ # AI szabályrendszer ← OLVASD EL
│ ├── AGENTS.md # Elsődleges szabályok │ ├── AGENTS.md # Elsődleges szabályok
│ ├── steering/ # Auto-betöltődő irányelvek │ ├── steering/ # Auto-betöltődő irányelvek
│ ├── workflows/ # /new-feature, /fix-bug, /review, /deploy │ ├── workflows/ # /new-feature, /fix-bug, /cms-feature, /review, /deploy
│ └── references/ # Accessibility checklist │ └── references/ # Accessibility checklist
├── TODO.md # Feladatlista (Plane szinkron) ├── TODO.md # Feladatlista (Plane szinkron)
└── plane-sync.js # Plane szinkronizáló script └── plane-sync.js # Plane szinkronizáló script
@@ -81,8 +76,6 @@ Szükséges változók (`.env` és `proto/.env.local`):
- `NEXT_PUBLIC_WEBMAIL_URL` — Webmail service URL - `NEXT_PUBLIC_WEBMAIL_URL` — Webmail service URL
- `NEXT_PUBLIC_CONTACT_EMAIL` — Kapcsolati email cím - `NEXT_PUBLIC_CONTACT_EMAIL` — Kapcsolati email cím
- `PLANE_API_KEY` — Plane szinkronizációhoz (a `.mcp.json`-ban is lehet) - `PLANE_API_KEY` — Plane szinkronizációhoz (a `.mcp.json`-ban is lehet)
- `PAYLOAD_SECRET` — Payload CMS JWT/session aláíráshoz (MITHOME-86); környezetenként egyedi, erős érték
- `ADMIN_EMAIL` / `ADMIN_PASSWORD` — staging/production: `deploy.sh` ezekkel hoz létre egy ismert admin usert egy vadonatúj adatbázison (MITHOME-97 follow-up), csak akkor, ha a `users` collection még üres
--- ---
@@ -104,6 +97,7 @@ Szükséges változók (`.env` és `proto/.env.local`):
| `.agent/steering/testing.md` | Tesztelési stratégia, coverage elvárások | | `.agent/steering/testing.md` | Tesztelési stratégia, coverage elvárások |
| `.agent/workflows/new-feature.md` | Új funkció fejlesztési lépései | | `.agent/workflows/new-feature.md` | Új funkció fejlesztési lépései |
| `.agent/workflows/fix-bug.md` | Hibajavítás lépései (TDD) | | `.agent/workflows/fix-bug.md` | Hibajavítás lépései (TDD) |
| `.agent/workflows/cms-feature.md` | CMS fejlesztés + útmutató karbantartás |
| `.agent/workflows/review.md` | Code review checklist | | `.agent/workflows/review.md` | Code review checklist |
| `.agent/workflows/deploy.md` | Deployment folyamat | | `.agent/workflows/deploy.md` | Deployment folyamat |
| `.agent/references/accessibility-checklist.md` | WCAG 2.1 AA ellenőrzőlista | | `.agent/references/accessibility-checklist.md` | WCAG 2.1 AA ellenőrzőlista |
-2
View File
@@ -37,8 +37,6 @@ A Docker stack a következő szolgáltatásokat indítja:
- Username: `admin` - Username: `admin`
- Password: `admin123` - Password: `admin123`
**Payload CMS admin** (http://localhost:8080/admin): nincs előre létrehozott felhasználó — az első betöltéskor a Payload felkínálja az admin fiók létrehozását. A `docker-compose.dev.yml` egy fix, nem titkos `PAYLOAD_SECRET`-et ad át (MITHOME-97) — staging/production környezetben ez kötelezően a saját `.env.<env>` fájlból jön, nincs alapértelmezett érték (a konténer el sem indul nélküle).
## 📊 MongoDB Hozzáférés ## 📊 MongoDB Hozzáférés
### 1. Mongo Express Web UI ### 1. Mongo Express Web UI
+15 -16
View File
@@ -5,10 +5,10 @@ Modern Next.js weboldal a mozdIT Bt. számára — webtárhely, email- és DNS-s
## Stack ## Stack
- **Frontend**: Next.js 15 (Turbopack), React 19, TypeScript, Tailwind CSS 4 - **Frontend**: Next.js 15 (Turbopack), React 19, TypeScript, Tailwind CSS 4
- **CMS**: Payload CMS (self-hosted, `/admin`), MongoDB adapter, draft/publish + verziózás, hu/en lokalizáció - **Tartalom**: JSON-alapú, séma-validált content rendszer (`proto/src/content/`)
- **Tartalom**: Payload Globals/Collections (a korábbi JSON-alapú content rendszer, `proto/src/content/`, csak a migrációs script forrásaként és teszt-fixture-ként él tovább — MITHOME-91/93) - **CMS**: saját, dependency-mentes `content-editor.js` (böngészős szerkesztő)
- **Backend**: Next.js API routes, MongoDB - **Backend**: Next.js API routes, MongoDB
- **Tesztelés**: Jest, React Testing Library, Playwright (smoke) - **Tesztelés**: Jest, React Testing Library, Playwright (smoke), valódi szervert indító CMS-tesztek
- **Deploy**: natív Docker Compose (`deploy.sh`) + Gitea Actions nélkül, lokálisan vezérelt - **Deploy**: natív Docker Compose (`deploy.sh`) + Gitea Actions nélkül, lokálisan vezérelt
- **Monitoring**: Winston + Loki, plusz `scripts/security-scan.sh` (ntfy riasztással) - **Monitoring**: Winston + Loki, plusz `scripts/security-scan.sh` (ntfy riasztással)
@@ -39,33 +39,32 @@ scripts/pre-deploy-tests.sh
./deploy.sh production # éles (szerveren, staging ellenőrzése után) ./deploy.sh production # éles (szerveren, staging ellenőrzése után)
``` ```
A Payload admin (`/admin`) draft/publish + verziózás munkafolyamata (MITHOME-92) nem jár git commit-tal vagy automatikus deployjal — a tartalom közvetlenül a MongoDB-be íródik. Kódváltozás (Globals/Collections séma, frontend) továbbra is a fenti git-alapú deploy folyamaton megy át. Részletek: `.agent/workflows/deploy.md`. A CMS „Publikálás" gombja szintén commitol + pushol + deployol (csak a beállított környezetre). Részletek: `.agent/workflows/deploy.md`, `docs/helyi-staging-deploy.md`.
## Tartalomkezelés ## Tartalomkezelés
A weboldal szövegei Payload CMS Globals/Collections mezőkben élnek, az ügyfél a `/admin` felületen szerkeszti őket: A weboldal szövegei és a CMS a `proto/src/content/` JSON-fájljaiból jönnek:
``` ```
proto/src/globals/ # Home, About, Services, Contact, Common proto/src/content/
proto/src/collections/ # LegalPages, Partners, Media, ContactSubmissions, Users ├── schema.js # közös séma-validátor (Next + CMS)
proto/src/payload.config.ts ├── types.ts # TypeScript típusok
├── index.ts # tartalom-betöltő
├── common.json # közös szövegek (gombok, lábléc, a11y)
└── pages/ # oldalankénti tartalom (home, about, services, …)
``` ```
Használat Server Component-ből (a Payload Local API-n keresztül): Használat:
```typescript ```typescript
import { getAboutContent } from '@/lib/payload-content' import { content, getPageContent } from '@/content'
const about = await getAboutContent(locale) // locale: 'hu' | 'en' const about = content.pages.about
``` ```
A régi, JSON-alapú content rendszer (`proto/src/content/`) már csak a
`migrate-content-to-payload.ts` seed-script forrásaként és néhány
komponens-teszt fixture-jeként él tovább — élő oldal nem olvassa (MITHOME-91/93).
## Dokumentáció ## Dokumentáció
- **Agent-szabályok**: `.agent/AGENTS.md`, `.agent/steering/`, `.agent/workflows/` - **Agent-szabályok**: `.agent/AGENTS.md`, `.agent/steering/`, `.agent/workflows/`
- **nginx vhost-ok**: `docs/nginx-vhosts.md` - **CMS felhasználói útmutató**: `docs/felhasznaloi-utmutato.md` (a CMS-ben a ❓ Súgó is ezt rendereli)
- **Plane szinkron**: `PLANE-SYNC-GUIDE.md` - **Plane szinkron**: `PLANE-SYNC-GUIDE.md`
- **Staging deploy**: `docs/helyi-staging-deploy.md` - **Staging deploy**: `docs/helyi-staging-deploy.md`
- **Gitea runner**: `docs/gitea-runner-telepites.md` - **Gitea runner**: `docs/gitea-runner-telepites.md`
-74
View File
@@ -97,54 +97,6 @@ Next.js 15 alapú weboldal a mozdIT Bt. számára, Docker Compose-szal deployolv
## 📋 Backlog (v1.0.2+) ## 📋 Backlog (v1.0.2+)
### EPIC: Payload CMS migráció (MITHOME-85)
Cél: az ügyfél önállóan, admin felületen szerkeszthesse a tartalmat — a JSON content rendszer (`proto/src/content/pages/*.json`) és a hozzá épült egyedi CMS (`content-editor.js`) lecserélése Payload CMS-re (Next.js App Router-be épülő, MongoDB adapterrel).
| Plane | Feladat | Státusz |
|-------|---------|---------|
| MITHOME-85 | **EPIC**: Áttérés Payload CMS-re (admin felület az ügyfélnek) | 📋 |
| MITHOME-86 | Payload CMS alapinstallálás: config, MongoDB adapter, admin route | ✅ |
| MITHOME-87 | Home/About/Services/Contact/Common Globals kialakítása és JSON tartalom migrálása | ✅ |
| MITHOME-88 | LegalPages collection (adatvedelem, hasznalati-feltetelek) migrálása | ✅ |
| MITHOME-89 | Partners + Media collection: logófeltöltés (MVP) | ✅ |
| MITHOME-118 | Logószerkesztő Payload admin komponensként — **lezárva, nem valósítjuk meg** (3rd party előfeldolgozás marad) | ❌ |
| MITHOME-90 | Users collection: ügyfél admin bejelentkezés és access control | ✅ |
| MITHOME-119 | MFA előkészítés a Users collection-höz (kutatás/terv, nem implementáció) | 📋 |
| MITHOME-91 | Frontend átállítása Payload Local API-ra, JSON content rendszer kivezetése | ✅ |
| MITHOME-92 | Draft/preview és verziózás bekapcsolása (Verziók panel utódja) | ✅ |
| MITHOME-93 | Régi egyedi CMS (content-editor.js) leépítése | ✅ |
| MITHOME-94 | Opcionális: ContactSubmissions collection a Mongoose modell helyett | ✅ |
| MITHOME-95 | .agent/ és CLAUDE.md dokumentáció frissítése Payload architektúrára | ✅ |
| MITHOME-96 | Tesztek migrálása: content schema tesztek helyett Payload collection/global tesztek | ✅ |
| MITHOME-97 | Deploy/Docker frissítés: PAYLOAD_SECRET, admin bundle, Dockerfile/deploy.sh | ✅ |
| MITHOME-117 | Vizuális design-védelem: színek/design tokenek változatlansága (MITHOME-91/93 elfogadási kritériuma) | 📋 |
| MITHOME-102 | ContactSubmissions afterChange hook: PLATFM-2-kompatibilis esemény-emisszió (függ: MITHOME-94) | 📋 |
| MITHOME-103 | Esemény-emisszió biztonsága: hitelesítés, rate limit, payload-validáció, elérhetetlenség-riasztás | 📋 |
| MITHOME-105 | MITHOME-31 lezárása: elveszett üzenet bug összekötése a PLATFM-10 triázs pilottal | 📋 |
| MITHOME-120 | Admin gyorskeresés (teljes szöveges kereső a Payload admin tetején) | ✅ |
| MITHOME-121 | Partner logók soha nem töltődtek be a publikus oldalon (Media read access) + logo mező opcionálissá tétele | ✅ |
| MITHOME-122 | Payload admin UI fordítása (menü/csoport/mező címkék nyelvfüggőek legyenek) | 📋 |
### EPIC: Többnyelvűség bevezetése — hu alapértelmezett + en (MITHOME-109)
Cél: a weboldal magyar (alapértelmezett, prefix nélkül, pl. `/rolunk`) és angol (`/en/` alatt, pl. `/en/about`) nyelven is elérhető legyen, Payload beépített mező-szintű lokalizációjára építve. Az angol tartalom első körben AI-draft fordítás, admin felületen jelezve, emberi jóváhagyásig. Retroaktívan érinti a már kész MITHOME-87/88-at (localized mezők utólagos bejelölése).
| Plane | Feladat | Státusz |
|-------|---------|---------|
| MITHOME-109 | **EPIC**: Többnyelvűség bevezetése (hu alapértelmezett + en) | 📋 |
| MITHOME-110 | Payload localization config bekapcsolása | ✅ |
| MITHOME-111 | Globals retrofit: localized mezők (Home/About/Services/Contact/Common) | 📋 |
| MITHOME-112 | LegalPages collection retrofit: localized mezők | 📋 |
| MITHOME-113 | AI-draft angol tartalom + migrációs script bővítése (en locale seed) | 📋 |
| MITHOME-114 | Next.js /hu/ + /en/ szimmetrikus prefix routing (egy menetben MITHOME-91-gyel) | ✅ |
| MITHOME-115 | Nyelvválasztó UI komponens (Header) | 📋 |
| MITHOME-116 | SEO: hreflang tagek és kétnyelvű sitemap | 📋 |
### EPIC: Hermes Agent — kapcsolatfelvételi triázs pilot (MITHOME-99) — **LEZÁRVA, duplikátum**
2026-09-10: kiderült, hogy ugyanez a pilot már meg volt tervezve (jobban kidolgozva) a **PLATFM-10** ("Kis kapacitásigényű, felügyelt AI-funkciók pilot") alatt a `PLATFM` projektben. A motor-választás (Hermes vs. alternatívák) külön ADR-ben dől el: **PLATFM-12**. A MITHOME-99 EPIC és a hozzá tartozó MITHOME-100/101/104/106/107/108 issue-k Cancelled státuszban maradnak, Plane-kommenttel a PLATFM-re mutatva. Ami valódi MITHOME-felelősség (a weboldal saját eseményforrása), az a Payload-epic alá került fentebb (MITHOME-102/103/105).
| Plane | Feladat | Státusz | | Plane | Feladat | Státusz |
|-------|---------|---------| |-------|---------|---------|
| MITHOME-15 | Production domain, Nginx reverse proxy és HTTPS | 📋 | | MITHOME-15 | Production domain, Nginx reverse proxy és HTTPS | 📋 |
@@ -208,33 +160,7 @@ docker-compose -f docker-compose.dev.yml down
--- ---
## Frissítési Napló ## Frissítési Napló
- **2026-09-13**: MITHOME-95 (dokumentáció frissítése) kész — `.agent/steering/architecture.md` és `CLAUDE.md` már a Payload Global/Collection + `/admin` + `payload-content.ts` Local API adaptert írja le a régi JSON rendszer helyett (rendszer-áttekintés, Content sor/szabály, kódpélda, projekt-struktúra fa). Emellett javítva több, ugyanebbe a dokumentációs adósságba tartozó, de a ticketben nem nevesített hely is: `.agent/AGENTS.md` projekt-fája, `new-feature.md`/`review.md` workflow-ok, `development-rules.md` TRADEOFF példakódja, és a `README.md` teljes "Tartalomkezelés" szekciója + egy halott link a MITHOME-93-ban törölt CMS-útmutatóra. Tisztán dokumentáció-változtatás.
- **2026-09-12**: Felhasználói kérésre a főoldal "Partnereink" szekciója flex-wrap helyett CSS grid-re váltott (max 3 oszlop, `grid-cols-1 sm:grid-cols-2 md:grid-cols-3`, `w-fit mx-auto` a középre igazításhoz kevesebb partnernél is) — élőben ellenőrizve 5 teszt-partnerrel (3+2-es elrendezés, középre igazítva).
- **2026-09-12**: MITHOME-96 (tesztek migrálása) kész — `src/payload-config.test.ts` (gyors, DB nélküli config-tesztek, két regresszió-őrrel a MITHOME-121-es hibákra) + `scripts/test-payload-local-api.ts`/`npm run test:payload` (élő MongoDB elleni Local API teszt, sima node script Jest helyett — a Payload csomag ESM-only, Jest alapból nem fordítja). Emellett javítva a régi, Docker-stack-hez kötött `integration.test.ts`/`e2e-docker.test.ts` eddig észrevétlen elavulása (unprefixelt útvonalak, `site_config`/`contact_submissions` nyers Mongo collection-ök a valódi Payload collection-ök helyett). Gate zöld (63 unit teszt), `test:payload` külön lefuttatva élőben (8/8).
- **2026-09-12**: MITHOME-94 (ContactSubmissions collection) kész — a `/api/contact` a nyers, Payload-on kívüli Mongo collection helyett most a Payload Local API-n keresztül egy `ContactSubmissions` collection-be ír (name/email/subject/message/gdprConsent/status), amit az ügyfél az adminban lát. Rate limiting/validáció/spam-szűrés változatlanul a route-on maradt. Nincs egyedi access-blokk — a Payload alapértelmezése (csak bejelentkezett usernek REST-en) pont a kívánt, a route saját írása a Local API-n (`overrideAccess: true`) nem ütközik ezzel. Talált és dokumentált teszt-gotcha: a next/jest SWC transform lecseréli a `@payload-config` alias-t valódi relatív útvonalra, ezért a mockot is a feloldott útvonalra kell tenni (`jest.mock('../../../payload.config', ...)`), nem az alias-ra. Élesben ellenőrizve mindkét helyen (helyi dev valódi űrlap-küldéssel + staging curl-lal).
- **2026-09-12**: MITHOME-97 follow-up #3 — a felhasználó kérdésére ("el lehet jutni a honlapra az admin oldalról?") kiderült, hogy nem: az admin navigációban csak belső linkek voltak. Hozzáadva egy "🌐 Honlap megnyitása" link a keresősáv mellé (`QuickSearch.tsx`), ami `NEXT_PUBLIC_SITE_URL`-re mutat — környezetenként automatikusan helyes cím. Élesben ellenőrizve, staging-re deployolva.
- **2026-09-12**: MITHOME-121 kész — a felhasználó kérésére a Partners `logo` mezője opcionálissá vált (`required: true` törölve; a frontend már eleve kiszűrte a logó nélküli partnereket). Eközben egy valódi, MITHOME-89 óta jelen lévő hiba is előkerült: a `Media` collection sosem kapott explicit `access.read`-et, így a Payload alapértelmezett "csak bejelentkezett user" szabálya miatt a `/api/media/file/*` route mindig 403-at adott — a Next.js image-optimizer emiatt sosem tudta betölteni a partner logókat a publikus oldalon (törött kép ikon, senki nem vette észre). Javítva: `access: { read: () => true }`. Staging-en emellett egy második, kapcsolódó hibát is találtam: a médiafájl fizikai byte-jai elvesztek egy korábbi (a `media_data_staging` volume bevezetése előtti) konténer-újraépítéskor — az árva Media rekord törlésével és a migráció volume-mountolt konténerből való újrafuttatásával helyreállítva. Élesben ellenőrizve mindkét helyen (helyi dev + staging).
- **2026-09-12**: MITHOME-120 (admin gyorskeresés) kész — a felhasználó kérésére egy keresőmező került a Payload admin minden oldalának tetejére (`admin.components.header`), ami az összes Global + Collection összes szöveges mezőjét átkeresi mindkét locale-ban (kliens-oldali, index nélküli megoldás — `@payloadcms/plugin-search` aránytalan lenne a projekt méretéhez, ugyanaz az érvelés, mint MITHOME-118-nál). Útközbeni gotcha: a Payload komponens-útvonalak `process.cwd()`-hez (nem a config fájl mappájához) relatívak — dokumentálva. Élesben ellenőrizve böngészőben (Global + Collection találat is, helyes navigáció, 0 console hiba), a lint egy valódi hibát is elkapott (ref helyett state kellett). Staging-re is kideployolva.
- **2026-09-11**: MITHOME-97 follow-up #2 — a felhasználó véletlen elgépelt egy URL-t (`stage.llmdev.mozdit.hu`), amire a Firefox valódinak tűnő "site could be impersonating" figyelmeztetést adott. Kiderült: egy elárvult `cms.stage.llmdev.mozdit.hu` nginx vhost a régi, leépített CMS-re (content-editor.js, port 4001, MITHOME-93) mutatott — a backend leállítva, de a vhost/tanúsítványa élt, nginx fallback-ként adta ezt bármilyen nem egyező `*.mozdit.hu` aldomainre. Megoldás: a vhost most a Payload admin felé proxyz (`/admin`, `/api/`, `/_next/` → staging app 127.0.0.1:8081), minden más redirect a kanonikus `stage.mozdit.hu`-ra — kényelmi admin-URL, meglévő tanúsítvány újrahasznosítva. Dokumentálva: `docs/nginx-vhosts.md` (eddig egyetlen nginx-konfig sem volt nyomon követve a repóban). Élesben ellenőrizve böngészőben (0 console hiba, bejelentkezés is működik ezen a domain-en).
- **2026-09-11**: MITHOME-97 follow-up — automatikus, ismert Payload admin-felhasználó minden deploy után. `deploy.sh` a healthcheck után meghívja a Payload beépített `POST /api/users/first-register` végpontját `ADMIN_EMAIL`/`ADMIN_PASSWORD` alapján; ez a végpont csak üres `users` collection-nél enged létrehozást (403 minden további hívásra) — idempotens, sosem ír felül meglévő jelszót. Élesben ellenőrizve (friss DB → 200, ismételt hívás → 403, meglévő más-user-es DB → 403), majd staging-en ténylegesen bevezetve: `.env.staging`-hez szerveren generált admin jelszó, két egymást követő `deploy.sh staging` (első létrehozta, második helyesen kihagyta), bejelentkezés-teszt 200.
- **2026-09-11**: MITHOME-92 (draft/preview + verziózás) kész — Payload versions.drafts minden tartalmi Global/Collection-ön, admin UI Save Draft/Publish + Versions tab, ez a régi CMS "Verziók panel" (MITHOME-64) utódja. Valódi hiba javítva: a `_status` mező defaultValue-ja 'draft', a migrációs script eddig sosem adott át explicit `_status`-t → minden migrált dokumentum draft állapotban landolt; javítva `_status: 'published'` explicit átadásával. Élesben ellenőrizve (draft mentés nem látszik a publikus oldalon, publish után igen).
- **2026-09-11**: MITHOME-93 (régi egyedi CMS leépítése) kész — `content-editor.js` + `scripts/cms-*.js` + a hozzá tartozó tesztkészlet eltávolítva. Felhasználói kérésre biztonsági mentés készült a törlés előtt: `proto/scripts/export-content-snapshot.ts` (Payload Local API export) → `docs/backups/payload-content-snapshot-*.json`, plusz ellenőrizve, hogy a régi CMS `.content-backups/` mappájának legutóbbi bejegyzése a mai migráció előtti, és minden JSON-szerkesztés saját git commitként is megvan.
- **2026-09-11**: MITHOME-97 (Deploy/Docker) kész, ÉS staging deploy ténylegesen megtörtént (https://stage.mozdit.hu). Két valódi hiba a Docker build első élő tesztjén: (1) a `(frontend)/[locale]` oldalak SSG-ként (`generateStaticParams`) épültek, build közben hívva a Payload Local API-t — DB nélkül a build elhalt; (2) ennél súlyosabb, hogy SSG mellett egy admin publikálás csak redeploy után látszott volna, ami az önkiszolgáló szerkesztési cél ellen dolgozott. Felhasználói jóváhagyással `force-dynamic`-ra váltva — minden kérés élő Payload-olvasás, build DB-független. `PAYLOAD_SECRET` bekötve staging/prod/dev compose-ba (eddig csak `.env.*.example`-ben létezett, sosem jutott el a konténerig). Named media volume a Payload uploadoknak. Külön javítva: a staging deploy script egy már nem létező `mozdit-content-editor.service`-t indított újra — eltávolítva, a szerveren a valós unit is leállítva/letiltva. Staging: hiányzó `PAYLOAD_SECRET` generálva a szerveren, `deploy.sh staging` lefuttatva, tartalom-migráció lefuttatva a staging DB ellen, élesben ellenőrizve böngészőben (`/hu`, `/en`, `/admin` mind működik).
- **2026-09-10**: MITHOME-91 + MITHOME-114 (Frontend Payload Local API-ra állítás + hu/en locale routing) kész, egy menetben. URL-stratégia véglegesítve: mindkét nyelv szimmetrikus prefixet kap (`/hu/rolunk`, `/en/about`, lefordított szlögekkel), felülírva a korábbi "hu prefix nélkül" döntést. Minden oldal Payload Local API-ról megy, `@/content` kivezetve az app-kódból. Két valódi, régről létező bug javítva (webmail-linkek, GDPR-checkbox linkje). Talált és javított: Next.js beépített 404-fallback ütközött a (payload) route group-pal — saját `not-found.tsx` oldotta meg, éles production-standalone szerverrel is ellenőrizve (0 console hiba). Build/lint/tsc/teszt zöld. **Folyamat-eltérés**: ez a commit közvetlenül a `main`-re ment feature branch nélkül (egy interrupt miatt kimaradt a branch-nyitás) — a végeredmény megegyezik azzal, mintha branch+merge lett volna, mert eddig is minden branch azonnali fast-forwarddal ment be.
- **2026-09-10**: MITHOME-90 (Users access control) kész — explicit maxLoginAttempts/lockTime, cookie security, access control minden op-ra. GHSA-jg8r-5jh2-v2xj advisory megvizsgálva és tudatosan elfogadva az egyetlen "admin" szerepkör modellben. Élesben ellenőrizve valódi lockout-teszttel. Follow-up: MITHOME-119 (MFA előkészítés, felhasználói kérésre, külön taszk) — mert a Payloadnak nincs natív MFA-ja, és ez ugyanabba a konvergencia-pontba esik, mint az Auth/IDM (PLATFM-4/PLATFM-11 IDM v1), ezért csak ADR-szintű döntést csinálunk, nem implementációt. PLATFM-1 kapcsolati térkép frissítve ("Auth/IDM/MFA" sor).
- **2026-09-10**: MITHOME-118 (logószerkesztő Payload-ba építése) lezárva, tudatosan nem valósítjuk meg — determinisztikus képfeldolgozás, nem illik sem a Payload admin-ba (aránytalan karbantartási teher egy ritkán használt funkcióért), sem Hermes/AI-agent munkafolyamatba (az nyelvi/döntési feladatokra való, nem pixel-transzformációra). Döntés: 3rd party előfeldolgozás (pl. remove.bg) marad — ez rögzítve a Partners collection `logo` mezőjének admin leírásában is.
- **2026-09-10**: MITHOME-110 (Payload localization bekapcsolása) kész — hu alapértelmezett, en, fallback:true. `Common.buttons.*` localized:true (valódi, előremutató teszt-mező, része a MITHOME-111 retrofitnak). Fontos tapasztalat dokumentálva: utólagos localized:true után a migrációs scriptet újra kell futtatni, mert a defaultLocale alatti régi érték "eltűnik". Élesben ellenőrizve admin UI nyelvváltóval.
- **2026-09-10**: MITHOME-89 (Partners + Media collection MVP) kész. Payload upload (Media) + Partners collection, migrálva a meglévő partner logóval — élesben ellenőrizve (1+1 dokumentum, idempotens, logó thumbnail rendben). A régi crop/rotate/transparent-logo szerkesztő (kliens-oldali canvas, 345 sor) külön ticketre bontva: MITHOME-118. Fontos: a Payload uploadok `proto/media/`-ba kerülnek (gitignore-olva) — staging/production docker-compose-ban perzisztens volume kell (jelezve MITHOME-97-nél).
- **2026-09-10**: MITHOME-117 létrehozva — explicit elfogadási kritérium, hogy a Payload-átállás (MITHOME-91/93) ne változtasson a publikus oldal jelenlegi színein/design tokenjein (`proto/src/app/(frontend)/globals.css`, márka-színek: Logo Blue #1f4e9d, Logo Orange #e43e26). Before/after screenshot-összehasonlítás lesz az elfogadási feltétel.
- **2026-09-10**: Többnyelvűség EPIC (MITHOME-109) és 7 alfeladat (MITHOME-110116) létrehozva Plane-ben — hu (alapértelmezett, prefix nélkül) + en (/en/ alatt), Payload natív lokalizációra építve, AI-draft angol tartalommal. MITHOME-91-hez (Frontend Local API átállás) hozzáadva a MITHOME-114 (locale routing) függőségi megjegyzés — egy menetben érdemes elvégezni.
- **2026-09-10**: MITHOME-88 (LegalPages collection) kész. Slug-alapú collection (adatvedelem, hasznalati-feltetelek), migrálva `migrate-content-to-payload.ts`-sel (idempotens upsert, kétszeri futtatás után is pontosan 2 dokumentum). Élesben ellenőrizve admin UI-ban.
- **2026-09-10**: PLATFM-1 ("Platform architektúra és komponensfüggőségi nyilvántartás") kiegészítve egy MITHOME↔PLATFM kapcsolati térképpel (6 konvergencia-pont: AI-triázs, Auth/IDM, Content Release, Termékkatalógus, Messaging, BCDR) — ez a Program-szintű koordináció, nem kell hozzá külön Plane-projekt. A MITHOME-40 (production MongoDB mentés) prioritása medium→urgentre emelve, mert a PLATFM-5 (BCDR) explicit RPO≤15p/RTO≤4ó célt ad a kapcsolatfelvételi üzenetekre.
- **2026-09-10**: MITHOME/PLATFM rendrakás. Kiderült, hogy a Hermes Agent EPIC (MITHOME-99) duplikálta a PLATFM-10 AI-pilotot (kapcsolatfelvételi triázs) — MITHOME-99/100/101/104/106/107/108 lezárva (Cancelled), PLATFM-10-re hivatkozva. Új ADR nyitva a motor-választáshoz: PLATFM-12. Ami valódi MITHOME-felelősség maradt (ContactSubmissions eseményforrás, PLATFM-2-kompatibilis sémával), az a Payload-epic alá került: MITHOME-102/103/105.
- **2026-09-10**: Hermes Agent EPIC (MITHOME-99) és 9 alfeladat (MITHOME-100108) létrehozva Plane-ben — pilot: self-hosted AI agent (Nous Research, MCP-támogatás) a kapcsolatfelvételi triázshoz, kapcsolódva a nyitott MITHOME-31 bughoz és a MITHOME-47 (belső operatív munkaasztal) irányhoz.
- **2026-09-10**: MITHOME-98 (dev docker-compose Mongo URI auth) kész. A `docker-compose.dev.yml` `app` service-e hitelesítés nélkül próbált kapcsolódni egy authot igénylő MongoDB-hez — élesben reprodukálva (findOne → "Command find requires authentication"), majd javítva és ellenőrizve (POST /api/contact valódi Mongo-írása). Ugyanaz a hibaosztály, mint MITHOME-32, de külön, dev-specifikus ticket, mert MITHOME-32 csak staging/production compose-t fed le. Branch: `fix/dev-mongo-uri-auth`. - **2026-09-10**: MITHOME-98 (dev docker-compose Mongo URI auth) kész. A `docker-compose.dev.yml` `app` service-e hitelesítés nélkül próbált kapcsolódni egy authot igénylő MongoDB-hez — élesben reprodukálva (findOne → "Command find requires authentication"), majd javítva és ellenőrizve (POST /api/contact valódi Mongo-írása). Ugyanaz a hibaosztály, mint MITHOME-32, de külön, dev-specifikus ticket, mert MITHOME-32 csak staging/production compose-t fed le. Branch: `fix/dev-mongo-uri-auth`.
- **2026-09-10**: MITHOME-87 (Home/About/Services/Contact/Common Globals + JSON migráció) kész. Munka közben két, MITHOME-86-hoz visszanyúló hibát javítottunk: (1) a gyökér src/app/layout.tsx ütközött a Payload (payload)/layout.tsx-ével — a webodal saját route-jai átkerültek egy (frontend) route groupba; (2) a kézzel írt importMap.js stub nem volt elég — tsx 4.22.4→4.23.13 (Node 25 kompatibilitás) után legenerálva a valódit. Mindkettő valós böngészőben ellenőrizve (curl nem buktatta fel korábban). `npm run migrate:content` script + `npm test`/lint/build zöld.
- **2026-09-10**: MITHOME-86 (Payload alapinstalláció) kész: Next.js 15.5→16.3.4 upgrade (Payload peer dep miatt), payload.config.ts + mongooseAdapter + minimális Users collection + admin/API/GraphQL route-ok. Élesben ellenőrizve valódi MongoDB-vel. Ismert nyitott advisory a payload@3.88.0-ban (GHSA-jg8r-5jh2-v2xj), nyomon követve MITHOME-90 alatt. Branch: `feature/payload-cms-setup`.
- **2026-09-10**: Payload CMS migrációs EPIC (MITHOME-85) és 12 alfeladat (MITHOME-8697) létrehozva Plane-ben — cél: JSON content + egyedi CMS lecserélése Payload-ra, ügyfél önálló admin szerkesztéshez.
- **2026-08-17**: Plane sync (`plane-sync.js`). Új MITHOME-27 (Production környezet) Backlog-ba; Custom CMS és Webmail bevéve a Plane-be (28/29, Done). `regenerate()` hiba javítva (szekció-elválasztó). - **2026-08-17**: Plane sync (`plane-sync.js`). Új MITHOME-27 (Production környezet) Backlog-ba; Custom CMS és Webmail bevéve a Plane-be (28/29, Done). `regenerate()` hiba javítva (szekció-elválasztó).
- **2026-08-17**: Plane sync futtatása (`plane-sync.js`). MITHOME-15, MITHOME-18 visszaminősítve Backlog-ba (Plane szerint), MITHOME-27/28 megtartva. - **2026-08-17**: Plane sync futtatása (`plane-sync.js`). MITHOME-15, MITHOME-18 visszaminősítve Backlog-ba (Plane szerint), MITHOME-27/28 megtartva.
- **2026-04-26**: Átállás Linear → Plane (MITHOME projekt). TODO.md teljes újraírva, 26 issue szinkronizálva. - **2026-04-26**: Átállás Linear → Plane (MITHOME projekt). TODO.md teljes újraírva, 26 issue szinkronizálva.
+362
View File
@@ -0,0 +1,362 @@
#!/usr/bin/env node
/**
* mozdIT Content Editor Server v2
* Szerkesztő felület a JSON tartalom fájlokhoz
* Támogatja: szöveg szerkesztés, tömbelem hozzáadás/törlés
* Futtatás: node content-editor.js
* Megnyitás: http://localhost:4001
*/
const http = require('http');
const fs = require('fs');
const path = require('path');
const { exec, execSync } = require('child_process');
const crypto = require('crypto');
const { validateContent } = require('./proto/src/content/schema');
const { renderMarkdown } = require('./scripts/markdown-render');
const { buildPublishCommand, interpretPublishResult } = require('./scripts/cms-publish');
const { handleVersionRoutes, listVersions } = require('./scripts/cms-versions');
const { handleSaveRoute } = require('./scripts/cms-save');
const { LOGO_TARGETS, handleLogoRoutes } = require('./scripts/cms-logo');
const PORT = Number(process.env.CONTENT_EDITOR_PORT) || 4001;
// WHY: overridable so the publish integration test can run against a throwaway
// git clone instead of the real repository.
const CONTENT_DIR = process.env.CONTENT_EDITOR_CONTENT_DIR || path.join(__dirname, 'proto', 'src', 'content');
const BACKUP_DIR = path.join(__dirname, '.content-backups');
const MAX_REQUEST_BODY_BYTES = 256 * 1024;
const AUDIT_LOG_FILE = process.env.CONTENT_EDITOR_AUDIT_FILE || path.join(__dirname, '.content-editor-audit.jsonl');
const GUIDE_FILE = process.env.CONTENT_EDITOR_GUIDE_FILE || path.join(__dirname, 'docs', 'felhasznaloi-utmutato.md');
const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
const AUTH_MAX_ATTEMPTS = 5;
const PUBLISH_MAX_ATTEMPTS = 3;
let isPublishing = false;
const FILES = {
common: path.join(CONTENT_DIR, 'common.json'),
home: path.join(CONTENT_DIR, 'pages', 'home.json'),
about: path.join(CONTENT_DIR, 'pages', 'about.json'),
services: path.join(CONTENT_DIR, 'pages', 'services.json'),
contact: path.join(CONTENT_DIR, 'pages', 'contact.json'),
adatvedelem: path.join(CONTENT_DIR, 'pages', 'adatvedelem.json'),
hasznalatiFeltetelek: path.join(CONTENT_DIR, 'pages', 'hasznalati-feltetelek.json'),
};
const FILE_LABELS = {
common: '⚙️ Közös szövegek',
home: '🏠 Kezdőlap',
about: '👥 Rólunk',
services: '🛠️ Szolgáltatások',
contact: '📬 Kapcsolat',
adatvedelem: '🔒 Adatvédelem',
hasznalatiFeltetelek: '⚖️ ÁSZF',
};
const { HTML, GUIDE_PAGE, LOGIN_PAGE, VERSIONS_PAGE } = require('./scripts/cms-pages');
const { LOGO_PAGE } = require('./scripts/cms-logo-page');
const { validateLogin, createSessionCookie, clearSessionCookie, hasValidSession, deleteSession } = require('./scripts/cms-session');
// Browser script is kept in its own file and inlined into the HTML template at render time.
const clientJs = fs.readFileSync(path.join(__dirname, 'scripts', 'cms-editor-client.js'), 'utf8')
+ '\n' + fs.readFileSync(path.join(__dirname, 'scripts', 'cms-editor-shortcuts.js'), 'utf8');
// ── Server ───────────────────────────────────────────────────────────────────
// Security/infra helpers live in scripts/cms-core.js (file-size limits).
const core = require('./scripts/cms-core');
const { CMS_USER, CMS_PASS, CMS_DEPLOY_ENV, CSRF_TOKEN, securityConfigIsValid, getClientAddress, hasValidCsrfToken, backupAndWriteAtomically } = core;
const exceedsRateLimit = (key, limit) => core.exceedsRateLimit(key, limit, RATE_LIMIT_WINDOW_MS);
const isRateLimited = (key, limit) => core.isRateLimited(key, limit, RATE_LIMIT_WINDOW_MS);
const recordRateLimitAttempt = key => core.recordRateLimitAttempt(key, RATE_LIMIT_WINDOW_MS);
const hasValidCredentials = req => core.hasValidCredentials(req, validateLogin);
const isAuthenticated = core.makeIsAuthenticated(hasValidSession, validateLogin);
const isBrowserNavigation = core.isBrowserNavigation;
const writeAudit = core.makeWriteAudit(AUDIT_LOG_FILE);
// Deploy version = git short SHA of the checked-out commit. Read once at startup:
// a CMS "deploy" is git pull + service restart, so this identifies the running code.
function readDeployVersion() {
try {
return execSync('git rev-parse --short HEAD', { cwd: __dirname, encoding: 'utf8' }).trim();
} catch {
return 'unknown';
}
}
const DEPLOY_VERSION = readDeployVersion();
const server = http.createServer(async (req, res) => {
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-Content-Type-Options', 'nosniff');
const clientAddress = getClientAddress(req);
if (!securityConfigIsValid()) {
res.writeHead(503, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Content Editor is disabled: CMS_USER and CMS_PASS must be configured.');
return;
}
const u = new URL(req.url, `http://localhost:${PORT}`);
// WHY: Basic Auth credentials are cached by the browser until it closes, so there is
// no native logout. The client calls /logout with deliberately invalid credentials,
// which overwrites the cached pair; the next navigation prompts for login again.
// Deliberately exempt from the auth rate limiter so logging out never locks the user out.
if (u.pathname === '/logout' && req.method === 'GET') {
// Legacy cache-buster endpoint; no WWW-Authenticate — Safari would show its
// native auth dialog on any fetch hitting this challenge.
res.writeHead(401, { 'Cache-Control': 'no-store' });
res.end('Logged out');
return;
}
// Public: logo asset for the login page.
if (req.method === 'GET' && u.pathname === '/logo.png') {
try {
// ?variant=header serves the website header logo (branding page preview).
const file = u.searchParams.get('variant') === 'header' ? LOGO_TARGETS.header : LOGO_TARGETS.icon;
const logo = fs.readFileSync(path.join(__dirname, 'proto', 'public', file));
res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600' });
res.end(logo);
} catch {
res.writeHead(404); res.end('Not found');
}
return;
}
// Public: deploy version (git SHA only — no secrets) for quick "is the fix live?" checks.
if (req.method === 'GET' && u.pathname === '/version') {
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify({ version: DEPLOY_VERSION, env: CMS_DEPLOY_ENV }));
return;
}
// Public: styled login page (shown after logout and for unauthenticated browser visits).
if (req.method === 'GET' && u.pathname === '/login') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(LOGIN_PAGE());
return;
}
// Public: login form endpoint. Shares the auth rate-limit budget with failed
// Basic attempts so the form cannot be brute-forced either.
if (req.method === 'POST' && u.pathname === '/login') {
let body = '';
let bodyTooLarge = false;
req.on('data', c => {
if (body.length + c.length > 1024) { bodyTooLarge = true; return; }
body += c;
});
req.on('end', () => {
if (isRateLimited(`auth:${clientAddress}`, AUTH_MAX_ATTEMPTS)) {
writeAudit('login_failed', { clientAddress, result: 'rate_limited' });
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': String(RATE_LIMIT_WINDOW_MS / 1000) });
res.end(JSON.stringify({ ok: false, error: 'Túl sok belépési kísérlet — próbáld újra később.' }));
return;
}
let user = '';
let pass = '';
try {
const parsed = JSON.parse(body);
user = String(parsed.user || '');
pass = String(parsed.pass || '');
} catch { /* empty credentials fail validation below */ }
if (!bodyTooLarge && validateLogin(user, pass, CMS_USER, CMS_PASS)) {
// WHY: successful logins must not consume the failure budget — tests and
// multi-tab users log in repeatedly and would lock themselves out.
const isSecure = req.headers['x-forwarded-proto'] === 'https';
writeAudit('login_success', { clientAddress });
res.writeHead(200, { 'Content-Type': 'application/json', 'Set-Cookie': createSessionCookie(isSecure) });
res.end(JSON.stringify({ ok: true }));
return;
}
recordRateLimitAttempt(`auth:${clientAddress}`);
writeAudit('login_failed', { clientAddress, result: bodyTooLarge ? 'request_too_large' : 'invalid_credentials' });
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'Hibás felhasználónév vagy jelszó.' }));
});
return;
}
if (isRateLimited(`auth:${clientAddress}`, AUTH_MAX_ATTEMPTS)) {
writeAudit('authentication_failed', { clientAddress, limited: true });
res.writeHead(429, { 'Retry-After': String(RATE_LIMIT_WINDOW_MS / 1000) });
res.end('Too many authentication attempts');
return;
}
if (!isAuthenticated(req)) {
recordRateLimitAttempt(`auth:${clientAddress}`);
writeAudit('authentication_failed', { clientAddress, limited: false });
// Browser navigations land on the styled login page; API/curl gets a plain 401.
// WHY no WWW-Authenticate: Safari pops its native auth dialog on fetch() calls
// that receive a Basic challenge — the styled /login page handles browsers.
if (isBrowserNavigation(req)) {
res.writeHead(302, { Location: '/login', 'Cache-Control': 'no-store' });
res.end();
return;
}
res.writeHead(401, { 'Cache-Control': 'no-store' });
res.end('Access denied');
return;
}
const fileKey = u.searchParams.get('file') || 'home';
const activeFile = FILES[fileKey] ? fileKey : 'home';
if (req.method === 'POST' && !hasValidCsrfToken(req)) {
writeAudit('csrf_rejected', { clientAddress, path: u.pathname, file: activeFile });
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'Érvénytelen vagy hiányzó CSRF token' }));
return;
}
// POST /logout — invalidate the browser session (Basic Auth stays valid by design).
if (req.method === 'POST' && u.pathname === '/logout') {
deleteSession(req);
writeAudit('logout', { clientAddress, user: CMS_USER });
res.writeHead(200, { 'Content-Type': 'application/json', 'Set-Cookie': clearSessionCookie() });
res.end(JSON.stringify({ ok: true }));
return;
}
// GET /guide — user guide rendered from the maintained markdown in the repo.
if (req.method === 'GET' && u.pathname === '/guide') {
let contentHtml;
try {
contentHtml = renderMarkdown(fs.readFileSync(GUIDE_FILE, 'utf8'));
} catch (error) {
contentHtml = '<p>Az útmutató jelenleg nem elérhető. Kérlek, szólj a fejlesztőnek.</p>';
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(GUIDE_PAGE(contentHtml));
return;
}
// POST /save — handled in scripts/cms-save.js (optimistic lock + validation + backup).
if (handleSaveRoute({
req, res, u, activeFile, files: FILES, maxBodyBytes: MAX_REQUEST_BODY_BYTES,
validate: validateContent, writeAudit, backupAndWrite: backupAndWriteAtomically,
backupDir: BACKUP_DIR, user: CMS_USER, clientAddress, cmsDirname: __dirname,
})) return;
// POST /publish — Git Commit, Pull Rebase & Push
if (req.method === 'POST' && u.pathname === '/publish') {
if (isPublishing) {
res.writeHead(423, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'Már folyamatban van egy publikálás. Kérlek, várj.' }));
return;
}
isPublishing = true;
if (exceedsRateLimit(`publish:${clientAddress}`, PUBLISH_MAX_ATTEMPTS)) {
isPublishing = false;
writeAudit('publish_rate_limited', { clientAddress, user: CMS_USER });
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': String(RATE_LIMIT_WINDOW_MS / 1000) });
res.end(JSON.stringify({ ok: false, error: 'Túl sok publikálási kísérlet' }));
return;
}
// Command shape and result classification live in scripts/cms-publish.js
// (WHY comments there): commit only when staged changes exist, rebase with
// abort-on-failure, deterministic no-changes marker instead of output matching.
exec(buildPublishCommand('content: frissítve a CMS-ből'), { cwd: CONTENT_DIR }, (error, stdout, stderr) => {
isPublishing = false;
res.writeHead(200, { 'Content-Type': 'application/json' });
const outcome = interpretPublishResult(error, stdout, stderr);
writeAudit('publish_finished', { clientAddress, user: CMS_USER, result: outcome.result });
if (!outcome.ok) {
res.end(JSON.stringify({ ok: false, error: outcome.error }));
return;
}
// Deploy only when content actually changed — a no-op publish must not
// trigger a rebuild. Deploy only the explicitly configured environment;
// never default to production. Overridable for tests.
if (outcome.hadChanges) {
// WHY direct child instead of a detached `cmd &`: under the systemd unit's
// hardening (NoNewPrivileges/PrivateTmp) the backgrounded grandchild died
// silently (observed twice: stale site after a publish). A direct child is
// not detached, runs to completion, and the callback turns the audit entry
// into a real "deploy finished/failed" signal. The HTTP response is already
// sent; deploy output goes to deploy.log so the pipes stay quiet.
const deployCmd = process.env.CONTENT_EDITOR_DEPLOY_CMD
|| `cd ../../../ && ./deploy.sh ${CMS_DEPLOY_ENV} > deploy.log 2>&1`;
writeAudit('deploy_spawned', { clientAddress, user: CMS_USER, env: CMS_DEPLOY_ENV });
// WHY cwd: without it the child starts in the process working directory
// (repo root), where `cd ../../../` lands on "/" — no write access, so
// deploy.log creation failed with Permission denied and the deploy never
// ran. CONTENT_DIR is the same base the git publish command uses.
exec(deployCmd, { cwd: CONTENT_DIR, maxBuffer: 8 * 1024 * 1024 }, deployError => {
writeAudit('deploy_exec_exit', {
clientAddress,
user: CMS_USER,
result: deployError ? 'error' : 'ok',
error: deployError ? String(deployError.message).slice(0, 300) : undefined,
});
});
}
res.end(JSON.stringify({ ok: true, output: outcome.output }));
});
return;
}
// GET /versions + POST /restore — handled in scripts/cms-versions.js.
if (handleVersionRoutes({
req, res, u, activeFile,
backupDir: BACKUP_DIR,
currentFile: FILES[activeFile],
validate: validateContent,
writeAudit, clientAddress, user: CMS_USER,
versionsPage: (fileKey, diff) => VERSIONS_PAGE(fileKey, FILE_LABELS[fileKey] || fileKey, listVersions(BACKUP_DIR, fileKey), diff, CSRF_TOKEN),
})) return;
// GET /branding + POST /logo — handled in scripts/cms-logo.js.
if (handleLogoRoutes({
req, res, u,
publicDir: path.join(__dirname, 'proto', 'public'),
backupDir: BACKUP_DIR,
writeAudit, clientAddress, user: CMS_USER,
logoPage: () => LOGO_PAGE(CSRF_TOKEN),
})) return;
// GET / — editor UI
let message = null;
let jsonData = '{}';
try {
jsonData = fs.readFileSync(FILES[activeFile], 'utf8').trim();
} catch (e) {
message = { type: 'err', text: 'Fájl olvasási hiba: ' + e.message };
}
// WHY: fingerprint of the file content at page load. The editor sends it back
// on save (X-Content-Hash); a mismatch means the file changed since this tab
// was opened (deploy, another tab, git) and a blind save would silently
// overwrite those changes.
const contentHash = crypto.createHash('sha256').update(jsonData).digest('hex');
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(HTML(activeFile, jsonData, message, CSRF_TOKEN, FILE_LABELS, clientJs, contentHash, DEPLOY_VERSION));
});
if (require.main === module) {
if (!securityConfigIsValid()) {
throw new Error('CMS_USER, CMS_PASS és érvényes CMS_DEPLOY_ENV nélkül a Content Editor nem indítható el.');
}
server.listen(PORT, '127.0.0.1', () => {
writeAudit('startup', { version: DEPLOY_VERSION, env: CMS_DEPLOY_ENV });
console.log(`\n✅ mozdIT Content Editor fut: http://localhost:${PORT} (v${DEPLOY_VERSION})\n`);
console.log(' Szerkeszthető fájlok:');
Object.entries(FILE_LABELS).forEach(([k, l]) => {
const rel = k === 'common' ? 'common.json' : `pages/${k}.json`;
console.log(`${l}: proto/src/content/${rel}`);
});
console.log('\n Ctrl+C a leállításhoz\n');
});
}
module.exports = {
backupAndWriteAtomically,
validateContent,
hasValidCredentials,
hasValidCsrfToken,
getClientAddress,
securityConfigIsValid,
csrfToken: CSRF_TOKEN,
};
+7 -57
View File
@@ -42,12 +42,8 @@ fi
echo "🔑 Környezeti változók betöltése ($ENV_FILE)..." echo "🔑 Környezeti változók betöltése ($ENV_FILE)..."
# WHY: a --env-file kapcsoló csak a compose változó-helyettesítését táplálja; # WHY: a --env-file kapcsoló csak a compose változó-helyettesítését táplálja;
# a shell nem látja belőle ezeket az értékeket, ezért a healthcheckhez és az # a shell nem látja belőle az APP_PORT-ot, ezért a healthcheckhez expliciten kiolvassuk.
# admin-seedeléshez expliciten kiolvassuk. APP_PORT_VALUE="$(grep -E '^APP_PORT=' "$ENV_FILE" | tail -n 1 | cut -d= -f2- | tr -d '[:space:]' | tr -d '"' | tr -d "'")"
env_value() {
grep -E "^$1=" "$ENV_FILE" | tail -n 1 | cut -d= -f2- | tr -d '[:space:]' | tr -d '"' | tr -d "'"
}
APP_PORT_VALUE="$(env_value APP_PORT)"
# 3. Docker konténerek újraépítése és indítása # 3. Docker konténerek újraépítése és indítása
echo "🐳 Build és indítás..." echo "🐳 Build és indítás..."
@@ -57,61 +53,15 @@ docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" up --build --force-recr
# 4. Healthcheck # 4. Healthcheck
echo "⏳ Healthcheck (max 60s)..." echo "⏳ Healthcheck (max 60s)..."
HEALTH_URL="http://localhost:${APP_PORT_VALUE:-$DEFAULT_PORT}/api/health" HEALTH_URL="http://localhost:${APP_PORT_VALUE:-$DEFAULT_PORT}/api/health"
HEALTHY=0
for i in $(seq 1 30); do for i in $(seq 1 30); do
if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then
echo "✅ Healthcheck OK: $HEALTH_URL" echo "✅ Healthcheck OK: $HEALTH_URL"
HEALTHY=1 echo "✅ Deploy sikeres: [$ENV]"
break exit 0
fi fi
sleep 2 sleep 2
done done
if [ "$HEALTHY" -ne 1 ]; then echo "❌ Healthcheck sikertelen: $HEALTH_URL"
echo "❌ Healthcheck sikertelen: $HEALTH_URL" docker compose -f "$COMPOSE_FILE" logs app --tail 50
docker compose -f "$COMPOSE_FILE" logs app --tail 50 exit 1
exit 1
fi
# 5. Payload admin felhasználó biztosítása (MITHOME-97 follow-up)
#
# WHY: minden friss adatbázisú deploy (első staging/prod indítás, vagy egy
# volume-törlés utáni újrakezdés) a Payload "Create first user" képernyőjét
# mutatná — valaki ott manuálisan hozná létre a saját fiókját, ami könnyen
# elmarad vagy elfelejtődik, és ismeretlen/inkonzisztens admin-hozzáférést
# eredményez környezetenként. Payload REST API-ja egy beépített
# `POST /<usersCollection>/first-register` endpointot ad erre — DE csakis
# akkor enged bármit létrehozni, ha a `users` collection még teljesen üres
# (0 dokumentum); ha már van akár egy felhasználó is, 403 Forbidden-t ad.
# Ez pont idempotenssé teszi: minden deploy után lefuttatjuk, első alkalommal
# létrehozza az ismert admin fiókot ADMIN_EMAIL/ADMIN_PASSWORD alapján,
# utána minden további deploy-on ártalmatlanul 403-at kap és kihagyja.
ADMIN_EMAIL_VALUE="$(env_value ADMIN_EMAIL)"
ADMIN_PASSWORD_VALUE="$(env_value ADMIN_PASSWORD)"
if [ -z "$ADMIN_EMAIL_VALUE" ] || [ -z "$ADMIN_PASSWORD_VALUE" ]; then
echo "⚠️ ADMIN_EMAIL/ADMIN_PASSWORD nincs beállítva $ENV_FILE-ban — admin-seedelés kihagyva."
echo " (Egy teljesen friss adatbázisnál manuálisan kell létrehozni az első usert /admin alatt.)"
else
echo "👤 Admin felhasználó biztosítása..."
ADMIN_HTTP_CODE="$(curl -sS -o /tmp/mozdit-admin-seed-response.json -w '%{http_code}' \
-X POST "http://localhost:${APP_PORT_VALUE:-$DEFAULT_PORT}/api/users/first-register" \
-H 'Content-Type: application/json' \
--data-binary @- <<EOF
{"email":"${ADMIN_EMAIL_VALUE}","password":"${ADMIN_PASSWORD_VALUE}"}
EOF
)"
rm -f /tmp/mozdit-admin-seed-response.json
case "$ADMIN_HTTP_CODE" in
200)
echo "✅ Admin felhasználó létrehozva ($ADMIN_EMAIL_VALUE)."
;;
403)
echo "✅ Admin felhasználó már létezik — nincs teendő."
;;
*)
echo "⚠️ Admin-seedelés váratlan válasza: HTTP $ADMIN_HTTP_CODE (a deploy egyébként sikeres, ez nem állítja meg)."
;;
esac
fi
echo "✅ Deploy sikeres: [$ENV]"
+2 -3
View File
@@ -1,3 +1,5 @@
version: '3.8'
services: services:
# Next.js Application # Next.js Application
app: app:
@@ -16,9 +18,6 @@ services:
# with "Command find requires authentication" (MITHOME-98). # with "Command find requires authentication" (MITHOME-98).
- MONGODB_URI=mongodb://admin:password123@mongodb:27017/mozdit?authSource=admin - MONGODB_URI=mongodb://admin:password123@mongodb:27017/mozdit?authSource=admin
- MONGODB_DB=mozdit - MONGODB_DB=mozdit
# Dev-only, nem titkos (MITHOME-97) — Payload ez nélkül el sem indul
# ("missing secret key"), és ez a compose fájl eddig nem adta át.
- PAYLOAD_SECRET=dev-only-insecure-secret-do-not-use-in-staging-or-prod
- NEXT_PUBLIC_SITE_URL=http://localhost:8080 - NEXT_PUBLIC_SITE_URL=http://localhost:8080
- NEXT_PUBLIC_COMPANY_NAME=mozdIT Bt. - NEXT_PUBLIC_COMPANY_NAME=mozdIT Bt.
- NEXT_PUBLIC_CONTACT_EMAIL=info@mozdit.hu - NEXT_PUBLIC_CONTACT_EMAIL=info@mozdit.hu
-9
View File
@@ -17,17 +17,10 @@ services:
# unauthenticated default URI would silently break the app — fail loudly instead. # unauthenticated default URI would silently break the app — fail loudly instead.
- MONGODB_URI=${MONGODB_URI} - MONGODB_URI=${MONGODB_URI}
- MONGODB_DB=${MONGODB_DB:-mozdit} - MONGODB_DB=${MONGODB_DB:-mozdit}
# No fallback either (MITHOME-97): Payload refuses to start without a real
# secret ("missing secret key"), which is exactly what we want here.
- PAYLOAD_SECRET=${PAYLOAD_SECRET}
- NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-https://mozdit.hu} - NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-https://mozdit.hu}
- NEXT_PUBLIC_COMPANY_NAME=${NEXT_PUBLIC_COMPANY_NAME:-mozdIT Bt.} - NEXT_PUBLIC_COMPANY_NAME=${NEXT_PUBLIC_COMPANY_NAME:-mozdIT Bt.}
- NEXT_PUBLIC_CONTACT_EMAIL=${NEXT_PUBLIC_CONTACT_EMAIL:-info@mozdit.hu} - NEXT_PUBLIC_CONTACT_EMAIL=${NEXT_PUBLIC_CONTACT_EMAIL:-info@mozdit.hu}
- LOKI_HOST=${LOKI_HOST:-http://loki:3100} - LOKI_HOST=${LOKI_HOST:-http://loki:3100}
volumes:
# Payload helyi upload storage — lásd docker-compose.staging.yml azonos
# kommentjét (MITHOME-97).
- media_data_prod:/app/media
depends_on: depends_on:
- mongodb - mongodb
networks: networks:
@@ -59,8 +52,6 @@ services:
volumes: volumes:
mongodb_data_prod: mongodb_data_prod:
driver: local driver: local
media_data_prod:
driver: local
networks: networks:
mozdit-network: mozdit-network:
-11
View File
@@ -18,20 +18,11 @@ services:
# unauthenticated default URI would silently break the app — fail loudly instead. # unauthenticated default URI would silently break the app — fail loudly instead.
- MONGODB_URI=${MONGODB_URI} - MONGODB_URI=${MONGODB_URI}
- MONGODB_DB=${MONGODB_DB:-mozdit} - MONGODB_DB=${MONGODB_DB:-mozdit}
# No fallback either (MITHOME-97): Payload refuses to start without a real
# secret ("missing secret key"), which is exactly what we want here.
- PAYLOAD_SECRET=${PAYLOAD_SECRET}
- NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-https://stage.mozdit.hu} - NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-https://stage.mozdit.hu}
- NEXT_PUBLIC_DEPLOY_ENV=staging - NEXT_PUBLIC_DEPLOY_ENV=staging
- NEXT_PUBLIC_COMPANY_NAME=${NEXT_PUBLIC_COMPANY_NAME:-mozdIT Bt.} - NEXT_PUBLIC_COMPANY_NAME=${NEXT_PUBLIC_COMPANY_NAME:-mozdIT Bt.}
- NEXT_PUBLIC_CONTACT_EMAIL=${NEXT_PUBLIC_CONTACT_EMAIL:-info@mozdit.hu} - NEXT_PUBLIC_CONTACT_EMAIL=${NEXT_PUBLIC_CONTACT_EMAIL:-info@mozdit.hu}
- LOKI_HOST=${LOKI_HOST:-http://loki:3100} - LOKI_HOST=${LOKI_HOST:-http://loki:3100}
volumes:
# Payload helyi upload storage (proto/src/collections/Media.ts) — futásidőben
# a konténer WORKDIR-je (/app) alatti media/ mappába ír. Named volume nélkül
# a `deploy.sh` minden újratelepítéskor (--force-recreate) elveszítené a
# korábban feltöltött logókat/képeket (MITHOME-97).
- media_data_staging:/app/media
depends_on: depends_on:
- mongodb - mongodb
networks: networks:
@@ -63,8 +54,6 @@ services:
volumes: volumes:
mongodb_data_staging: mongodb_data_staging:
driver: local driver: local
media_data_staging:
driver: local
networks: networks:
mozdit-network-staging: mozdit-network-staging:
File diff suppressed because it is too large Load Diff
+122
View File
@@ -0,0 +1,122 @@
# mozdIT — Felhasználói útmutató
Ez az útmutató a mozdIT weboldalt és a hozzá tartozó **Content Editor** (CMS) felületet írja le nem műszaki felhasználóknak.
A dokumentum a repó része, és **folyamatosan karbantartott**: minden funkcióváltozásnál a fejlesztő frissíti. A CMS ❓ Súgó menüpontja ezt a fájlt jeleníti meg.
---
## 1. A weboldal
### Hol érhető el?
- **Staging (teszt) oldal**: [https://stage.mozdit.hu](https://stage.mozdit.hu) — itt ellenőrizhetők a friss változtatások éles környezetben, még a véglegesítés előtt.
- A staging oldal tetején **sárga figyelmeztető sáv** jelzi, hogy tesztkörnyezetet látsz.
### Oldalak
- **Kezdőlap** — `https://stage.mozdit.hu/`
- **Rólunk** — `/rolunk`
- **Szolgáltatások** — `/szolgaltatasok`
- **Kapcsolat** — `/kapcsolat` (űrlap, ami beérkező üzenetként tárolódik)
- **Adatvédelmi tájékoztató** — `/adatvedelem`
- **Felhasználási feltételek** — `/felhasznalasi-feltetelek`
### Hogyan változik a weboldal tartalma?
1. A szerkesztő a **Content Editorban** módosítja a szövegeket (2. fejezet).
2. **💾 Mentés** — a módosítás elmentődik, azonnali biztonsági mentéssel.
3. **🚀 Publikálás** — a változtatás bekerül a Git repóba, és automatikusan deployol a staging oldalra.
4. Az éles (production) weboldalra a tartalom csak ellenőrzött, szándékos deploy lépéssel kerül fel — a CMS-ből soha nem publisholódik automatikusan productionre.
---
## 2. Content Editor (CMS)
### Belépés és kilépés
- A CMS a kiadott címen érhető el (staging: `https://cms.stage.llmdev.mozdit.hu`).
- **Bejelentkezés**: a logós bejelentkező oldalon add meg a **felhasználónevet és jelszót** (ezt az adminisztrátor adja), majd kattints a Belépés gombra.
- Több **sikertelen próbálkozás** (5) után a belépés kb. 15 percre zárolásra kerül.
- A belépés **8 óráig érvényes** — ezután a CMS visszairányít a bejelentkező oldalra, ahol újra meg kell adni a jelszót.
- **🚪 Kilépés**: az alsó sáv gombja — egy megerősítő kérdés („Biztosan ki szeretnél lépni?") után kijelentkezel, és megjelenik a bejelentkező oldal.
### Felület áttekintés
- **Fájl fülek** (felül): oldalankénti tartalom — Kezdőlap, Rólunk, Szolgáltatások, Kapcsolat, jogi oldalak, közös szövegek.
- **Szerkesztőfelület**: a kiválasztott oldal összes szerkeszthető mezője.
- **Alsó sáv**: 💾 Mentés, 🚀 Publikálás, 🔗 Előnézet, ❓ Súgó, 🚪 Kilépés, valamint a **futó verzió** (pl. `va7b1a2c`) — ha a fejlesztő megkér, hogy ellenőrizd a verziót, ezt a jelölést mondd neki.
### Szöveg szerkesztése
- A mezők fölötti **útvonal** (pl. `hero.title`) jelzi, hol jelenik meg a szöveg az oldalon.
- Mezőtípusok:
- **Egysoros / több soros szövegmező** — általános szöveg; a hosszabb szöveg automatikusan nagyobb mezőben szerkeszthető.
- **Jelölőnégyzet** — be/ki (igen/nem) érték.
- **Számmező** — numerikus érték.
- A módosítás **nem kerül azonnal az oldalra** — ahhoz Mentés, majd Publikálás kell.
### Listák szerkesztése
- Lista elem (pl. egy jelszó, egy szolgáltatás tulajdonság): **❌ gombbal törölhető**.
- ** Új elem hozzáadása** gomb: új elem beszúrása a lista végére (üres, a meglévőkhöz hasonló űrlappal).
- Kártyás listáknál (pl. szolgáltatások) minden kártya külön törölhető a kártya alján lévő gombbal.
### 🎨 Logó kezelése
- Az alsó sáv **🎨 Logó** gombja megnyitja a logókezelő oldalt.
- Két logó cserélhető: a **weboldal fejléclogója** (szöveges) és a **CMS bejelentkező oldal ikonja**.
- **Partner logó feltöltése**: ugyanitt — a fájlnév megadása után a feltöltött PNG a `/partners/…` elérési útra kerül; ezt az utat másold a partner **logo** mezőjébe.
- Csak **PNG**, max. **1 MB**; ajánlott átlátszó háttér a sötét fejléchez.
- A régi logó mentésre kerül — a csere biztonságos és visszavonható (a mentések a `.content-backups` mappában).
- A **CMS azonnal** az új logót mutatja; a **weboldalon a Publikálás (deploy) után** jelenik meg.
### 🤝 Partnerek
- A kezdőlap **„Partnereink"** szekciója a Szolgáltatások alatt jelenik meg (logó + név + hivatkozás).
- A **Kezdőlap** fülön a `partners.items` listában szerkeszthetők: `name` (név), `url` (hivatkozás), `logo` (elérési út, pl. `/partners/acme.png`).
- Új logó: a **🎨 Logó** oldalon töltsd fel, a visszaadott utat illeszd a `logo` mezőbe.
### ⌨️ Gyorsbillentyűk
- **Ctrl/Cmd + S** — Mentés
- **Ctrl/Cmd + P** — Publikálás
- **Ctrl/Cmd + Shift + V** — Verziók panel megnyitása új fülön
- **?** — gyorsbillentyű-súgó megjelenítése (Esc vagy kattintás zárja)
A gyorsbillentyűk csak a szerkesztő főoldalán működnek; beviteli mezőben gépelve a normál karakterként viselkednek.
### 🕘 Verziók — korábbi állapotok
- Az alsó sáv **🕘 Verziók** gombja megnyitja az éppen szerkesztett fájl mentéseit (minden Mentés automatikus másolatot készít).
- **⚖ Összehasonlítás**: megmutatja, mi változott az adott mentéshez képest (piros = a mentésben lévő régi szöveg, zöld = a jelenlegi).
- **↩ Visszaállítás**: egy kattintással visszaállítja a mentést. A visszaállítás **előtt a jelenlegi tartalom is mentésre kerül**, tehát a visszaállítás is visszavonható.
- A visszaállítás sémaillesztésen megy át — hibás mentést nem lehet visszaállítani.
- Visszaállítás után a nyitott szerkesztő fülek frissítést kérnek (a tartalom megváltozott).
### 💾 Mentés
- A Mentés **ellenőrzi a tartalmat**: hiányzó vagy rossz típusú mező esetén hibaüzenetet kapsz, és a mentés nem történik meg — az oldal így nem tud elromlani.
- **Ha a tartalom megváltozott, mióta a lapot megnyitottad** (pl. közben deploy történt vagy egy másik fülben mentett valaki), a Mentés figyelmeztet: ilyenkor döntsd el, hogy frissíted a lapot az új tartalomra (a szerkesztésed elvész), vagy megszakítod. Ezzel a védelemmel nem írható véletlenül felül senki módosítása.
- Minden sikeres mentés **biztonsági mentést** készít a szerveren (`.content-backups/`), és naplózza a műveletet.
- Ha a Mentés sikeres, a mentett állapotot **Előnézet** gombbal nézheted meg a staging oldalon.
### 🚀 Publikálás
- A Publikálás **commitolja és feltolja** a változtatásokat, majd elindítja a staging deployt.
- „Nincs új változtatás." üzenet: nincs új mentett változtatás — ez **nem hiba**, ilyenkor deploy sem indul.
- A publikálás korlátozva van (3 próbálkozás / 15 perc) a véletlen tömeges deploy elkerülésére.
- A deploy eltarthat 1-2 percig; az eredményt az Előnézet gombbal ellenőrizheted.
### Biztonság
- Több **sikertelen belépési kísérlet** után a rendszer átmenetileg letiltja a belépést a gépedről (kb. 15 percre).
- Minden mentés és publikálás **naplózva** van (audit log) a nyomonkövethetőség érdekében.
---
## Karbantartás (fejlesztőknek)
- Forrás: `docs/felhasznaloi-utmutato.md` — a CMS a `/guide` útvonalon rendereli ki.
- **Szabály**: minden CMS- vagy honlapfunkció változásnál frissítsd ezt a fájlt ugyanabban a commitban.
- Az útmutató támogatott formátuma: címsorok, **félkövér**, `kód`, listák, linkek, elválasztó vonalak.
-50
View File
@@ -1,50 +0,0 @@
# nginx vhost-ok (szerveroldali, nincs git-ben verziózva)
> Ez a fájl **dokumentáció, nem forrás** — a tényleges konfiguráció a
> szerveren (`sadmin@llmdev.mozdit.hu`) él, `/etc/nginx/sites-available/`
> alatt, ott kell szerkeszteni és `nginx -t` + `systemctl reload nginx`-szel
> érvényesíteni. Ez a projekt nem tart fenn saját IaC-t/Ansible-t az
> nginx-hez; ez a dokumentum azért létezik, hogy a vhost-ok célja és
> létezése ne csak a szerver `/etc/nginx/`-jében legyen fellelhető (lásd
> MITHOME-97 follow-up: egy elárvult, régi CMS-re mutató vhost tanúsítványa
> okozott zavaró Firefox biztonsági figyelmeztetést, mert semmilyen
> dokumentáció nem jelezte a létezését).
A szerver (`llmdev.mozdit.hu`) **több, egymástól független projektet is
kiszolgál** (pl. `n8n.llmdev.mozdit.hu`, `gradia.hu`, `tippom-stage` stb.) —
ez a dokumentum csak a **websitedev / mozdIT** projekthez tartozó
vhost-okat írja le.
## `stage.mozdit.hu`
A publikus staging weboldal — `docker-compose.staging.yml` `app` service,
`127.0.0.1:8081` felé proxyz. Ez a kanonikus staging URL.
## `cms.stage.llmdev.mozdit.hu`
Kényelmi URL a Payload admin felülethez, **ugyanarra a staging
app-konténerre** proxyzva (`127.0.0.1:8081`) — nem külön szolgáltatás, nem
külön adatbázis.
- `/` → 302 redirect `/admin`-ra
- `/admin`, `/api/`, `/_next/` → proxyzva a staging app-ra
- minden más (publikus oldalak) → 302 redirect `https://stage.mozdit.hu`-ra
(nincs duplikált tartalom a két domain alatt)
TLS: Certbot-kezelt Let's Encrypt tanúsítvány, saját magára a
`cms.stage.llmdev.mozdit.hu` névre kiállítva.
**Történet**: ez a vhost eredetileg a régi, egyedi CMS-nek
(`content-editor.js`, `127.0.0.1:4001`) szólt. Miután a CMS-t leépítettük
(MITHOME-93) és a `mozdit-content-editor.service`-t leállítottuk/letiltottuk,
a vhost egy ideig egy halott backendre mutatott, és tanúsítványa lett az
nginx véletlenszerű fallback-je más, nem konfigurált `*.mozdit.hu`
albdomainekre (pl. elgépelt URL-ekre) — ez okozott egy valódinak tűnő, de
ártalmatlan Firefox "site could be impersonating" figyelmeztetést. A vhost-ot
2026-09-11-én átállítottuk a Payload admin felé (lásd fent).
## Production megfelelő (ha/amikor lesz)
Ha production is élesedik, érdemes ugyanezt a mintát követni: egy
`cms.mozdit.hu` (vagy hasonló) vhost, ugyanazzal a proxy-scope-pal
(`/admin`, `/api/`, `/_next/`), a production app portjára (8080) mutatva.
-6
View File
@@ -39,9 +39,3 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# Payload CMS — local upload storage (MITHOME-89). Runtime data, not source;
# needs a persistent Docker volume in staging/production (see MITHOME-97).
/media
# Payload CMS — generated TS types (payload.config.ts typescript.outputFile)
src/payload-types.ts
+11 -9
View File
@@ -1,14 +1,16 @@
// NOTE (Next.js 16 / eslint-config-next 16): the FlatCompat("next/core-web-vitals") import { dirname } from "path";
// bridge to the legacy .eslintrc-style config caused a circular-JSON crash in import { fileURLToPath } from "url";
// ESLint 9 (the "react" plugin object closes a cycle when re-validated through import { FlatCompat } from "@eslint/eslintrc";
// FlatCompat). eslint-config-next now ships native flat-config arrays, so
// import those directly instead of going through the compat layer. const __filename = fileURLToPath(import.meta.url);
import nextCoreWebVitals from "eslint-config-next/core-web-vitals"; const __dirname = dirname(__filename);
import nextTypescript from "eslint-config-next/typescript";
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [ const eslintConfig = [
...nextCoreWebVitals, ...compat.extends("next/core-web-vitals", "next/typescript"),
...nextTypescript,
{ {
ignores: [ ignores: [
"node_modules/**", "node_modules/**",
+5 -15
View File
@@ -1,19 +1,13 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
import { withPayload } from "@payloadcms/next/withPayload";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
// Enable standalone output for Docker deployment // Enable standalone output for Docker deployment
output: 'standalone', output: 'standalone',
// NOTE (Next.js 16): `next dev` auto-generates AGENTS.md/CLAUDE.md stub // Skip linting during build for faster Docker builds
// files describing the framework to AI agents. This project already has eslint: {
// its own agent instruction system (root CLAUDE.md -> .agent/) — a second, ignoreDuringBuilds: true,
// unrelated proto/CLAUDE.md would conflict with it, so this is disabled. },
agentRules: false,
// NOTE (Next.js 16): `eslint.ignoreDuringBuilds` was removed — `next build`
// no longer runs ESLint itself (lint is now only `next lint` / `npm run lint`),
// so there is nothing left to ignore here.
// Skip TypeScript checking during build (for faster Docker builds) // Skip TypeScript checking during build (for faster Docker builds)
typescript: { typescript: {
@@ -77,8 +71,4 @@ const nextConfig: NextConfig = {
}, },
}; };
// MITHOME-86: bundles the admin UI's route handlers correctly under export default nextConfig;
// Turbopack/webpack. `@payload-config` itself resolves via the `paths`
// alias in tsconfig.json (./src/payload.config.ts) — this wrapper's
// installed version has no separate configPath option.
export default withPayload(nextConfig);
+463 -4213
View File
File diff suppressed because it is too large Load Diff
+4 -13
View File
@@ -27,22 +27,14 @@
"docker:dev:down": "cd .. && docker-compose -f docker-compose.dev.yml down", "docker:dev:down": "cd .. && docker-compose -f docker-compose.dev.yml down",
"docker:dev:logs": "cd .. && docker-compose -f docker-compose.dev.yml logs -f app", "docker:dev:logs": "cd .. && docker-compose -f docker-compose.dev.yml logs -f app",
"docker:build": "docker build -t mozdit-app .", "docker:build": "docker build -t mozdit-app .",
"docker:run": "docker run -p 3000:3000 --env-file .env.local mozdit-app", "docker:run": "docker run -p 3000:3000 --env-file .env.local mozdit-app"
"migrate:content": "node --env-file=.env.local --import tsx scripts/migrate-content-to-payload.ts",
"test:payload": "node --env-file=.env.local --import tsx scripts/test-payload-local-api.ts"
}, },
"dependencies": { "dependencies": {
"@payloadcms/db-mongodb": "^3.88.0",
"@payloadcms/next": "^3.88.0",
"@payloadcms/richtext-lexical": "^3.88.0",
"graphql": "^16.14.2",
"mongodb": "^6.5", "mongodb": "^6.5",
"mongoose": "^8.2", "mongoose": "^8.2",
"next": "^16.3.4", "next": "^15.5.23",
"payload": "^3.88.0",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0"
"sharp": "^0.35.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
@@ -57,11 +49,10 @@
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/winston": "^2.4", "@types/winston": "^2.4",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "^16.3.4", "eslint-config-next": "15.5.2",
"jest": "^29.7", "jest": "^29.7",
"jest-environment-jsdom": "^29.7", "jest-environment-jsdom": "^29.7",
"tailwindcss": "^4", "tailwindcss": "^4",
"tsx": "^4.23.13",
"typescript": "^5", "typescript": "^5",
"undici": "^7.15.0", "undici": "^7.15.0",
"winston": "^3.11", "winston": "^3.11",
-70
View File
@@ -1,70 +0,0 @@
/**
* MITHOME-93 biztonsági lépés: a régi egyedi CMS (content-editor.js +
* scripts/cms-*.js) eltávolítása előtt exportálja a Payload jelenlegi
* (publikált) szöveges tartalmát egy olvasható JSON fájlba — mindkét
* locale-lal (hu, en) —, hogy git-committolt, ember által is átnézhető
* biztonsági mentés maradjon a leépítés pillanatáról.
*
* NEM helyettesíti a git history-t (a src/content/*.json fájlok minden
* korábbi szerkesztése megvan commit-onként), és nem helyettesíti a
* MongoDB-t (az az élő forrás) — ez egy plusz, könnyen olvasható
* pillanatkép a "mielőtt törlünk, mentsünk" elv jegyében.
*
* Futtatás (proto/ mappából, futó MongoDB-vel és beállított env-ekkel):
* node --env-file=.env.local --import tsx scripts/export-content-snapshot.ts
*/
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import { getPayload, type Payload } from 'payload'
import config from '../src/payload.config'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const outDir = path.resolve(scriptDir, '../../docs/backups')
const GLOBAL_SLUGS = ['home', 'about', 'services', 'contact', 'common'] as const
const LOCALES = ['hu', 'en'] as const
async function exportGlobals(payload: Payload) {
const result: Record<string, Record<string, unknown>> = {}
for (const slug of GLOBAL_SLUGS) {
result[slug] = {}
for (const locale of LOCALES) {
result[slug][locale] = await payload.findGlobal({ slug, locale })
}
}
return result
}
async function exportCollection(payload: Payload, collection: 'legal-pages' | 'partners') {
const result: Record<string, unknown> = {}
for (const locale of LOCALES) {
const { docs } = await payload.find({ collection, locale, limit: 1000 })
result[locale] = docs
}
return result
}
async function run() {
const payload = await getPayload({ config })
const snapshot = {
exportedAt: new Date().toISOString(),
reason: 'MITHOME-93 — régi CMS (content-editor.js) leépítése előtti biztonsági mentés',
globals: await exportGlobals(payload),
collections: {
legalPages: await exportCollection(payload, 'legal-pages'),
partners: await exportCollection(payload, 'partners'),
},
}
fs.mkdirSync(outDir, { recursive: true })
const filename = `payload-content-snapshot-${snapshot.exportedAt.replace(/[:.]/g, '-')}.json`
const outPath = path.join(outDir, filename)
fs.writeFileSync(outPath, JSON.stringify(snapshot, null, 2), 'utf8')
payload.logger.info(`Snapshot kiírva: ${outPath}`)
process.exit(0)
}
run()
-217
View File
@@ -1,217 +0,0 @@
/**
* MITHOME-87/88: egyszeri migrációs script — a proto/src/content/pages/*.json
* + common.json tartalmát átemeli a Payload Globals-ekbe (Home, About,
* Services, Contact, Common) és a LegalPages collection-be a Local API-n
* keresztül.
*
* Futtatás (proto/ mappából, futó MongoDB-vel és beállított env-ekkel):
* node --env-file=.env.local --import tsx scripts/migrate-content-to-payload.ts
*
* Idempotens: updateGlobal-t / slug-alapú upsert-et hív, tetszőlegesen
* többször futtatható — mindig a JSON az aktuális "forrás igazság", felül-
* írja a Payload-ban lévő korábbi állapotot. NEM törli/nem érinti a JSON
* fájlokat.
*
* MITHOME-92 tapasztalat: a versions.drafts bekapcsolása után a `_status`
* mező defaultValue-ja 'draft' — ha egy create/update hívás data-jában
* nincs explicit `_status`, ÚJ dokumentum létrehozásakor 'draft' lesz, és
* frissítéskor a meglévő (draft) érték marad meg (nem íródik felül
* automatikusan 'published'-re). Ezért itt minden Globals/LegalPages/
* Partners írás explicit `_status: 'published'`-t ad át — a migrációs
* script eredménye mindig publikált tartalom legyen, sosem draft.
*
* MITHOME-110 tapasztalat: ha egy mezőt utólag `localized: true`-ra
* állítasz (MITHOME-111/112 retrofit), a korábban beírt érték a régi,
* nem-lokalizált tárolási alakban marad, és `locale: defaultLocale`-lal
* nem olvasható vissza (üresnek látszik) — ilyenkor ezt a scriptet újra
* kell futtatni, hogy a defaultLocale (hu) alá újra beírja az értéket a
* lokalizált alakban. Explicit `locale` paramétert egyik updateGlobal/
* update hívás sem ad meg itt, ezért mindig a `defaultLocale` (hu) alá ír.
*/
import path from 'path'
import { fileURLToPath } from 'url'
import { getPayload, type Payload } from 'payload'
import config from '../src/payload.config'
import homeJson from '../src/content/pages/home.json'
import aboutJson from '../src/content/pages/about.json'
import servicesJson from '../src/content/pages/services.json'
import contactJson from '../src/content/pages/contact.json'
import commonJson from '../src/content/common.json'
import adatvedelemJson from '../src/content/pages/adatvedelem.json'
import hasznalatiFeltetelekJson from '../src/content/pages/hasznalati-feltetelek.json'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const publicDir = path.resolve(scriptDir, '../public')
/** string[] -> [{ value: string }] — lásd src/globals/fields/stringArray.ts */
function toStringArray(items: readonly string[]): { value: string }[] {
return items.map((value) => ({ value }))
}
function buildHomeData(json: typeof homeJson) {
const { hero, about, services, cta, serviceFeatures } = json
return {
hero: {
...hero,
trustBullets: toStringArray(hero.trustBullets),
},
about,
services: {
...services,
items: services.items.map((item) => ({
...item,
features: toStringArray(item.features),
})),
},
cta,
serviceFeatures,
}
}
function buildAboutData(json: typeof aboutJson) {
const { meta, hero, story, mission, team, cta } = json
return {
meta,
hero,
story: { ...story, paragraphs: toStringArray(story.paragraphs) },
mission,
team: { ...team, paragraphs: toStringArray(team.paragraphs) },
cta,
}
}
function buildServicesData(json: typeof servicesJson) {
const { meta, hero, details, support, cta } = json
return {
meta,
hero,
details: {
...details,
services: details.services.map((service) => ({
...service,
specs: {
...service.specs,
items: toStringArray(service.specs.items),
},
})),
},
support,
cta,
}
}
type LegalPageJson = {
title: string
lastUpdated: string
sections: { id: string; title: string; content: string }[]
}
/** Collections have no per-document "known slug" API like Globals do —
* find-by-slug, then update or create. Idempotent across re-runs. */
async function upsertLegalPage(payload: Payload, slug: string, json: LegalPageJson) {
const existing = await payload.find({
collection: 'legal-pages',
where: { slug: { equals: slug } },
limit: 1,
})
if (existing.docs.length > 0) {
await payload.update({
collection: 'legal-pages',
id: existing.docs[0].id,
data: { slug, ...json, _status: 'published' },
})
} else {
await payload.create({
collection: 'legal-pages',
data: { slug, ...json, _status: 'published' },
})
}
}
type PartnerJson = { name: string; url: string; logo: string }
/**
* MITHOME-89 (MVP): a home.json partners.items publikus /partners/*.png
* útvonalait tölti fel Media collection dokumentumként, majd Partner
* rekordot hoz létre/frissít rá hivatkozva. Idempotens: a Media dokumentumot
* `alt` (== partner név) alapján, a Partnert `name` alapján keresi.
*/
async function upsertPartner(payload: Payload, partner: PartnerJson) {
const existingMedia = await payload.find({
collection: 'media',
where: { alt: { equals: partner.name } },
limit: 1,
})
let mediaId: string | number
if (existingMedia.docs.length > 0) {
mediaId = existingMedia.docs[0].id
} else {
const filePath = path.resolve(publicDir, partner.logo.replace(/^\//, ''))
const created = await payload.create({
collection: 'media',
data: { alt: partner.name },
filePath,
})
mediaId = created.id
}
const existingPartner = await payload.find({
collection: 'partners',
where: { name: { equals: partner.name } },
limit: 1,
})
if (existingPartner.docs.length > 0) {
await payload.update({
collection: 'partners',
id: existingPartner.docs[0].id,
data: { name: partner.name, url: partner.url, logo: mediaId, _status: 'published' },
})
} else {
await payload.create({
collection: 'partners',
data: { name: partner.name, url: partner.url, logo: mediaId, _status: 'published' },
})
}
}
async function run() {
const payload = await getPayload({ config })
await payload.updateGlobal({ slug: 'home', data: { ...buildHomeData(homeJson), _status: 'published' } })
payload.logger.info('Home global migrálva')
await payload.updateGlobal({ slug: 'about', data: { ...buildAboutData(aboutJson), _status: 'published' } })
payload.logger.info('About global migrálva')
await payload.updateGlobal({ slug: 'services', data: { ...buildServicesData(servicesJson), _status: 'published' } })
payload.logger.info('Services global migrálva')
await payload.updateGlobal({ slug: 'contact', data: { ...contactJson, _status: 'published' } })
payload.logger.info('Contact global migrálva')
await payload.updateGlobal({ slug: 'common', data: { ...commonJson, _status: 'published' } })
payload.logger.info('Common global migrálva')
await upsertLegalPage(payload, 'adatvedelem', adatvedelemJson)
payload.logger.info('LegalPages/adatvedelem migrálva')
await upsertLegalPage(payload, 'hasznalati-feltetelek', hasznalatiFeltetelekJson)
payload.logger.info('LegalPages/hasznalati-feltetelek migrálva')
for (const partner of homeJson.partners.items) {
await upsertPartner(payload, partner)
payload.logger.info(`Partners/${partner.name} migrálva`)
}
payload.logger.info('MITHOME-87/88/89 migráció kész.')
process.exit(0)
}
run().catch((error) => {
console.error('Migráció sikertelen:', error)
process.exit(1)
})
-134
View File
@@ -1,134 +0,0 @@
/**
* MITHOME-96 — Payload Local API integrációs teszt.
*
* Ez a "Local API integrációs tesztek" rész a ticketből: valódi, futó
* MongoDB-vel ellenőrzi, hogy a src/lib/payload-content.ts adapter-réteg
* ténylegesen jó alakú adatot ad vissza a Globals/Collections-ökből — nem
* mock-olt Payload-dal, hanem éles Local API hívásokkal.
*
* WHY sima node script és nem Jest teszt: a Payload csomag (és több
* függősége, pl. @payloadcms/richtext-lexical) ESM-only dist-et ad ki —
* Jest (a next/jest SWC transformjával is) alapból nem transzformálja a
* node_modules-t, így `import { getPayload } from 'payload'` egy Jest
* tesztben `SyntaxError: Cannot use import statement outside a module`-lel
* bukik (kipróbálva). A `transformIgnorePatterns` kiterjesztése a teljes
* Payload-függőségi fára törékeny és karbantartás-igényes lenne — ehelyett
* ugyanazt a bevált mintát követjük, mint a migrate-content-to-payload.ts /
* export-content-snapshot.ts scriptek: `node --import tsx`, valódi Node
* ESM-mel, semmilyen Jest-transzform nem kell.
*
* Futtatás (proto/ mappából, futó MongoDB-vel, migrált tartalommal):
* MONGODB_URI="mongodb://admin:password123@localhost:27018/mozdit?authSource=admin" \
* npm run test:payload
*/
import assert from 'assert/strict'
import { getPayload } from 'payload'
import config from '../src/payload.config'
import {
getCommonContent,
getHomeContent,
getAboutContent,
getServicesContent,
getContactContent,
getLegalPage,
getPartners,
} from '../src/lib/payload-content'
let passed = 0
async function test(name: string, fn: () => Promise<void>) {
try {
await fn()
passed++
console.log(`${name}`)
} catch (error) {
console.error(`${name}`)
throw error
}
}
async function run() {
const payload = await getPayload({ config })
await test('getCommonContent visszaad hu és en tartalmat', async () => {
const hu = await getCommonContent('hu')
const en = await getCommonContent('en')
assert.ok(hu.buttons.webmail, 'hiányzó hu webmail gomb szöveg')
assert.ok(en.buttons.webmail, 'hiányzó en webmail gomb szöveg')
})
await test('getHomeContent unwrap-eli a trustBullets és services.items.features tömböket', async () => {
const home = await getHomeContent('hu')
assert.ok(Array.isArray(home.hero.trustBullets))
assert.ok(home.hero.trustBullets.every((v) => typeof v === 'string'))
for (const item of home.services.items) {
assert.ok(Array.isArray(item.features))
assert.ok(item.features.every((v) => typeof v === 'string'))
}
})
await test('getAboutContent unwrap-eli a story és team paragraphs tömböket', async () => {
const about = await getAboutContent('hu')
assert.ok(Array.isArray(about.story.paragraphs))
assert.ok(Array.isArray(about.team.paragraphs))
})
await test('getServicesContent unwrap-eli a specs.items tömböket', async () => {
const services = await getServicesContent('hu')
for (const service of services.details.services) {
assert.ok(Array.isArray(service.specs.items))
}
})
await test('getContactContent visszaadja a form mezőket', async () => {
const contact = await getContactContent('hu')
assert.ok(contact.form.fields.email.label)
})
await test('getLegalPage megtalálja mindkét jogi oldalt', async () => {
const privacy = await getLegalPage('adatvedelem', 'hu')
const terms = await getLegalPage('hasznalati-feltetelek', 'hu')
assert.equal(privacy?.slug, 'adatvedelem')
assert.equal(terms?.slug, 'hasznalati-feltetelek')
})
await test('getPartners csak logóval rendelkező partnereket ad vissza', async () => {
const partners = await getPartners()
for (const partner of partners) {
assert.ok(partner.logo.url)
assert.ok(partner.name)
}
})
await test('create/findByID/delete ciklus működik (contact-submissions)', async () => {
const created = await payload.create({
collection: 'contact-submissions',
data: {
name: 'Local API Test',
email: 'local-api-test@example.com',
subject: 'MITHOME-96 Local API teszt',
message: 'Ez a rekord a test-payload-local-api.ts futása során jön létre és törlődik.',
gdprConsent: true,
status: 'new',
},
})
assert.ok(created.id)
const found = await payload.findByID({ collection: 'contact-submissions', id: created.id })
assert.equal(found.email, 'local-api-test@example.com')
await payload.delete({ collection: 'contact-submissions', id: created.id })
await assert.rejects(
payload.findByID({ collection: 'contact-submissions', id: created.id })
)
})
console.log(`\n${passed}/${passed} Payload Local API teszt zöld.`)
process.exit(0)
}
run().catch((error) => {
console.error('\nPayload Local API teszt sikertelen:', error)
process.exit(1)
})
@@ -0,0 +1,91 @@
/**
* Regression test for the Content Editor browser script: deleting an array
* item via its ❌ button must reindex the remaining items, otherwise collect()
* produces sparse arrays (null holes) that fail schema validation
* ("$.details.services[1].specs.items[0]: string érték szükséges").
*
* Runs the REAL scripts/cms-editor-client.js in jsdom and clicks the actual
* delete buttons — earlier coverage only exercised reindexItems() directly,
* which missed that the onclick handler removed the node BEFORE looking up
* its container (detached node → closest() === null → no reindex).
*/
import fs from 'fs'
import path from 'path'
const clientJs = fs.readFileSync(path.join(__dirname, '../../../scripts/cms-editor-client.js'), 'utf8')
const service = (n: number) => ({
id: `svc-${n}`,
title: `Szolgáltatás ${n}`,
description: `Leírás ${n}`,
icon: '🔧',
features: [`feature ${n}`],
ctaText: 'CTA',
})
const makeData = () => ({
details: {
title: 'Részletek',
subtitle: 'Alcím',
services: [
{ icon: 'a', title: 's0', description: 'd0', specs: { title: 't0', items: ['a0', 'b0', 'c0'] } },
{ icon: 'b', title: 's1', description: 'd1', specs: { title: 't1', items: ['a1', 'b1', 'c1'] } },
{ icon: 'c', title: 's2', description: 'd2', specs: { title: 't2', items: ['a2', 'b2', 'c2'] } },
],
},
})
function bootClient(data: unknown) {
;(global as any).DATA = data
;(global as any).FILE = 'services'
;(global as any).CSRF_TOKEN = 'test-token'
;(global as any).fetch = jest.fn()
document.body.innerHTML = '<div id="editor"></div>'
// sloppy-mode eval publishes the script's functions on the global object
;(0, eval)(clientJs)
}
function deleteButtonFor(dataPath: string): HTMLButtonElement {
const field = document.querySelector(`[data-path="${CSS.escape(dataPath)}"]`) as HTMLElement
expect(field).not.toBeNull()
const wrap = field.closest('.str-item') as HTMLElement
expect(wrap).not.toBeNull()
return wrap.querySelector('.btn-del') as HTMLButtonElement
}
afterEach(() => {
delete (global as any).DATA
delete (global as any).FILE
delete (global as any).CSRF_TOKEN
})
describe('Content Editor client delete/reindex', () => {
it('deleting a nested string array item keeps the remaining items dense', () => {
const data = makeData()
bootClient(data)
deleteButtonFor('details.services[1].specs.items[0]').click()
const collected = (global as any).collect()
expect(collected.details.services[1].specs.items).toEqual(['b1', 'c1'])
expect(collected.details.services[0].specs.items).toEqual(['a0', 'b0', 'c0'])
expect(collected.details.services[2].specs.items).toEqual(['a2', 'b2', 'c2'])
})
it('deleting an object card reindexes the outer array', () => {
const data = makeData()
bootClient(data)
const cardHeader = Array.from(document.querySelectorAll('.card-header'))
.find(h => h.textContent === 'details.services[1]') as HTMLElement
expect(cardHeader).not.toBeNull()
const card = cardHeader.closest('.obj-card') as HTMLElement
;(card.querySelector('.btn-del-card') as HTMLButtonElement).click()
const collected = (global as any).collect()
expect(collected.details.services).toHaveLength(2)
expect(collected.details.services[0].title).toBe('s0')
expect(collected.details.services[1].title).toBe('s2')
expect(collected.details.services[1].specs.items).toEqual(['a2', 'b2', 'c2'])
})
})
@@ -0,0 +1,102 @@
/**
* Regression tests for the Content Editor keyboard shortcuts (MITHOME-75).
* Runs the real scripts/cms-editor-client.js in jsdom, dispatches actual
* KeyboardEvents and asserts:
* 1. Ctrl+S calls save (fetch /save) and the browser save dialog is
* suppressed (preventDefault)
* 2. Ctrl+P calls publish
* 3. Ctrl+Shift+V opens the versions page in a new tab
* 4. '?' opens the shortcuts overlay, Esc closes it
* 5. plain 's' inside an input does NOT trigger save
*/
import fs from 'fs'
import path from 'path'
const clientJs = fs.readFileSync(path.join(__dirname, '../../../scripts/cms-editor-client.js'), 'utf8')
+ '\n' + fs.readFileSync(path.join(__dirname, '../../../scripts/cms-editor-shortcuts.js'), 'utf8')
const makeData = () => ({
hero: { title: 'T', subtitle: 'S', description: 'D' },
})
function press(target: Document | Element, init: KeyboardEventInit) {
const ev = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init })
target.dispatchEvent(ev)
return ev
}
const flush = () => new Promise(r => setTimeout(r, 0))
// Globals must exist BEFORE the eval — the client script boots immediately
// (render(DATA, …)). The eval runs ONCE: every eval would add another keydown
// listener to the shared jsdom document, and the toggle-style overlay handler
// would then open/close itself multiple times per keypress.
;(global as any).DATA = makeData()
;(global as any).FILE = 'home'
;(global as any).CSRF_TOKEN = 'boot'
;(global as any).CONTENT_HASH = 'x'.repeat(64)
;(global as any).fetch = jest.fn(async () => ({ status: 200, ok: true, json: async () => ({ ok: true }) }))
;(global as any).window = global
document.body.innerHTML = '<div id="editor"></div>'
;(0, eval)(clientJs)
describe('Content Editor keyboard shortcuts', () => {
let fetchCalls: Array<{ url: string; init?: RequestInit }>
beforeEach(() => {
fetchCalls = []
;(global as any).DATA = makeData()
;(global as any).FILE = 'home'
;(global as any).CSRF_TOKEN = 'test-csrf'
;(global as any).CONTENT_HASH = 'x'.repeat(64)
;(global as any).fetch = jest.fn(async (url: string, init?: RequestInit) => {
fetchCalls.push({ url, init })
return { status: 200, ok: true, json: async () => ({ ok: true, contentHash: 'y'.repeat(64) }) }
})
;(global as any).window = global
;(window as any).open = jest.fn()
document.body.innerHTML = '<div id="editor"></div><span id="saveStatus" style="display:none"></span><button id="publishBtn">pub</button>'
})
afterEach(() => {
delete (global as any).DATA
delete (global as any).FILE
delete (global as any).CSRF_TOKEN
delete (global as any).CONTENT_HASH
})
it('Ctrl+S saves via fetch and suppresses the browser save dialog', () => {
const ev = press(document, { key: 's', ctrlKey: true })
expect(ev.defaultPrevented).toBe(true)
expect(fetchCalls.length).toBeGreaterThanOrEqual(1)
expect(fetchCalls[0].url).toContain('/save?file=home')
})
it('Cmd+P publishes', async () => {
const ev = press(document, { key: 'p', metaKey: true })
expect(ev.defaultPrevented).toBe(true)
await flush() // publish awaits save() before its own fetch
expect(fetchCalls.some(c => c.url === '/publish')).toBe(true)
})
it('Ctrl+Shift+V opens the versions page in a new tab', () => {
const ev = press(document, { key: 'V', ctrlKey: true, shiftKey: true })
expect(ev.defaultPrevented).toBe(true)
expect((window as any).open).toHaveBeenCalledWith('/versions?file=home', '_blank')
})
it("'?' opens the shortcuts overlay and Esc closes it", () => {
press(document, { key: '?' })
expect(document.getElementById('shortcuts-overlay')).not.toBeNull()
press(document, { key: 'Escape' })
expect(document.getElementById('shortcuts-overlay')).toBeNull()
})
it('plain typing in an input never triggers save', () => {
const input = document.createElement('input')
document.body.appendChild(input)
const ev = press(input, { key: 's' })
expect(ev.defaultPrevented).toBe(false)
expect(fetchCalls.length).toBe(0)
})
})
+9 -13
View File
@@ -1,10 +1,6 @@
/** /**
* End-to-End tests for the Docker environment * End-to-End tests for the Docker environment
* These tests verify the full application flow in the Docker stack * These tests verify the full application flow in the Docker stack
*
* MITHOME-96: routes are locale-prefixed (/hu/..., /en/...) since the
* Payload CMS migration (MITHOME-91/114) — updated from the old unprefixed
* paths.
*/ */
describe('Docker E2E Tests', () => { describe('Docker E2E Tests', () => {
@@ -31,24 +27,24 @@ describe('Docker E2E Tests', () => {
expect(html).toContain('mozdIT Bt.') expect(html).toContain('mozdIT Bt.')
// Test navigation links exist in homepage // Test navigation links exist in homepage
expect(html).toContain('href="/hu/rolunk"') expect(html).toContain('href="/rolunk"')
expect(html).toContain('href="/hu/szolgaltatasok"') expect(html).toContain('href="/szolgaltatasok"')
expect(html).toContain('href="/hu/kapcsolat"') expect(html).toContain('href="/kapcsolat"')
// Test about page // Test about page
response = await fetch(`${APP_URL}/hu/rolunk`) response = await fetch(`${APP_URL}/rolunk`)
expect(response.status).toBe(200) expect(response.status).toBe(200)
html = await response.text() html = await response.text()
expect(html).toContain('Rólunk') expect(html).toContain('Rólunk')
// Test services page // Test services page
response = await fetch(`${APP_URL}/hu/szolgaltatasok`) response = await fetch(`${APP_URL}/szolgaltatasok`)
expect(response.status).toBe(200) expect(response.status).toBe(200)
html = await response.text() html = await response.text()
expect(html).toContain('Szolgáltatásaink') expect(html).toContain('Szolgáltatásaink')
// Test contact page // Test contact page
response = await fetch(`${APP_URL}/hu/kapcsolat`) response = await fetch(`${APP_URL}/kapcsolat`)
expect(response.status).toBe(200) expect(response.status).toBe(200)
html = await response.text() html = await response.text()
expect(html).toContain('Kapcsolat') expect(html).toContain('Kapcsolat')
@@ -214,9 +210,9 @@ describe('Docker E2E Tests', () => {
const pages = [ const pages = [
{ url: '', title: 'mozdIT Bt.' }, { url: '', title: 'mozdIT Bt.' },
{ url: '/hu/rolunk', title: 'Rólunk' }, { url: '/rolunk', title: 'Rólunk' },
{ url: '/hu/szolgaltatasok', title: 'Szolgáltatásaink' }, { url: '/szolgaltatasok', title: 'Szolgáltatásaink' },
{ url: '/hu/kapcsolat', title: 'Kapcsolat' } { url: '/kapcsolat', title: 'Kapcsolatfelvétel' }
] ]
for (const page of pages) { for (const page of pages) {
+21 -31
View File
@@ -1,14 +1,6 @@
/** /**
* Integration tests for the Docker development environment * Integration tests for the Docker development environment
* These tests run against the real services in the Docker stack * These tests run against the real services in the Docker stack
*
* MITHOME-96: updated for the Payload CMS migration (MITHOME-85 epic) —
* routes are now locale-prefixed (/hu/..., /en/...) and content lives in
* Payload collections/globals (`globals`, `legal-pages`, `partners`,
* `contact-submissions`), not the old raw `site_config`/`contact_submissions`
* Mongoose collections. Assumes the Docker dev DB already has content
* migrated (`npm run migrate:content`) — see docs/backups for a snapshot if
* you need to reseed a throwaway environment.
*/ */
import { MongoClient, ObjectId } from 'mongodb' import { MongoClient, ObjectId } from 'mongodb'
@@ -82,16 +74,12 @@ describe('Docker Environment Integration Tests', () => {
const collections = await mozditDb.listCollections().toArray() const collections = await mozditDb.listCollections().toArray()
const collectionNames = collections.map(c => c.name) const collectionNames = collections.map(c => c.name)
// Payload Globals (Home/About/Services/Contact/Common) live in a expect(collectionNames).toContain('site_config')
// single `globals` collection, one document per global. expect(collectionNames).toContain('contact_submissions')
expect(collectionNames).toContain('globals')
expect(collectionNames).toContain('legal-pages')
expect(collectionNames).toContain('partners')
expect(collectionNames).toContain('contact-submissions')
expect(collectionNames).toContain('users') expect(collectionNames).toContain('users')
}) })
it('should verify the Home global exists and is published', async () => { it('should verify site config data exists', async () => {
if (process.env.NODE_ENV === 'test' && !process.env.INTEGRATION_TESTS) { if (process.env.NODE_ENV === 'test' && !process.env.INTEGRATION_TESTS) {
return return
} }
@@ -102,12 +90,15 @@ describe('Docker Environment Integration Tests', () => {
} }
const mozditDb = mongoClient.db('mozdit') const mozditDb = mongoClient.db('mozdit')
const home = await mozditDb.collection('globals').findOne({ globalType: 'home' }) const siteConfig = await mozditDb.collection('site_config').findOne()
if (!home) throw new Error('Home global document not found') if (!siteConfig) throw new Error('site_config document not found')
expect(home).toHaveProperty('_status', 'published') expect(siteConfig).toBeTruthy()
expect(home).toHaveProperty('hero') expect(siteConfig).toHaveProperty('type', 'site_config')
expect(home.hero).toHaveProperty('title') expect(siteConfig).toHaveProperty('environment', 'development')
expect(siteConfig).toHaveProperty('data')
expect(siteConfig.data).toHaveProperty('general')
expect(siteConfig.data.general).toHaveProperty('name', 'mozdIT Bt.')
}) })
it('should access Mongo Express UI', async () => { it('should access Mongo Express UI', async () => {
@@ -188,7 +179,7 @@ describe('Docker Environment Integration Tests', () => {
} }
const savedSubmission = await mongoClient const savedSubmission = await mongoClient
.db('mozdit') .db('mozdit')
.collection('contact-submissions') .collection('contact_submissions')
.findOne({ _id: new ObjectId(result.submissionId) }) .findOne({ _id: new ObjectId(result.submissionId) })
expect(savedSubmission).toEqual(expect.objectContaining({ expect(savedSubmission).toEqual(expect.objectContaining({
name: contactData.name, name: contactData.name,
@@ -265,7 +256,7 @@ describe('Docker Environment Integration Tests', () => {
const result = await response.json() const result = await response.json()
if (response.status === 400) { if (response.status === 400) {
expect(result).toHaveProperty('error', 'Az üzenet spam gyanús tartalmat tartalmaz.') expect(result).toHaveProperty('error', 'Spam gyanús tartalom észlelve')
} else if (response.status === 429) { } else if (response.status === 429) {
expect(result).toHaveProperty('error') expect(result).toHaveProperty('error')
expect(result.error).toContain('Túl sok') expect(result.error).toContain('Túl sok')
@@ -279,14 +270,13 @@ describe('Docker Environment Integration Tests', () => {
return return
} }
// MITHOME-91/114: bare "/" 307-redirects to "/hu" — fetch follows
// redirects by default, so this still lands on the real homepage.
const response = await fetch(DOCKER_SERVICES.app) const response = await fetch(DOCKER_SERVICES.app)
expect(response.status).toBe(200) expect(response.status).toBe(200)
const html = await response.text() const html = await response.text()
expect(html).toContain('mozdIT Bt.') expect(html).toContain('mozdIT Bt.')
expect(html).toContain('Megbízható web- és email szolgáltatás') expect(html).toContain('Megbízható web és emailszolgáltatás')
expect(html).toContain('Webmail Ugrás')
}) })
it('should load about page', async () => { it('should load about page', async () => {
@@ -294,7 +284,7 @@ describe('Docker Environment Integration Tests', () => {
return return
} }
const response = await fetch(`${DOCKER_SERVICES.app}/hu/rolunk`) const response = await fetch(`${DOCKER_SERVICES.app}/rolunk`)
expect(response.status).toBe(200) expect(response.status).toBe(200)
const html = await response.text() const html = await response.text()
@@ -307,14 +297,14 @@ describe('Docker Environment Integration Tests', () => {
return return
} }
const response = await fetch(`${DOCKER_SERVICES.app}/hu/szolgaltatasok`) const response = await fetch(`${DOCKER_SERVICES.app}/szolgaltatasok`)
expect(response.status).toBe(200) expect(response.status).toBe(200)
const html = await response.text() const html = await response.text()
expect(html).toContain('Szolgáltatásaink') expect(html).toContain('Szolgáltatásaink')
expect(html).toContain('Webtárhely') expect(html).toContain('Web Hosting')
expect(html).toContain('E-mail szolgáltatás') expect(html).toContain('Email Szolgáltatás')
expect(html).toContain('DNS adminisztráció') expect(html).toContain('DNS Adminisztráció')
}) })
it('should load contact page', async () => { it('should load contact page', async () => {
@@ -322,7 +312,7 @@ describe('Docker Environment Integration Tests', () => {
return return
} }
const response = await fetch(`${DOCKER_SERVICES.app}/hu/kapcsolat`) const response = await fetch(`${DOCKER_SERVICES.app}/kapcsolat`)
expect(response.status).toBe(200) expect(response.status).toBe(200)
const html = await response.text() const html = await response.text()
@@ -1,123 +0,0 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import AboutView from '@/components/views/AboutView'
import ServicesView from '@/components/views/ServicesView'
import ContactView from '@/components/views/ContactView'
import LegalPageView from '@/components/views/LegalPageView'
import { resolvePageKey, isLocale, type Locale } from '@/lib/i18n'
import {
getAboutContent,
getServicesContent,
getContactContent,
getHomeContent,
getCommonContent,
getLegalPage,
} from '@/lib/payload-content'
import { siteConfig, getOgLocale } from '@/config/site'
type Params = { locale: string; slug: string }
// WHY force-dynamic: lásd ../layout.tsx — nincs generateStaticParams, minden
// kérés élőben olvassa a Payload-ot, admin publikálás azonnal látszik.
export const dynamic = 'force-dynamic'
const LEGAL_META: Record<'privacy' | 'terms', Record<Locale, string>> = {
privacy: {
hu: 'Adatvédelmi tájékoztató - ismerje meg, hogyan kezeljük személyes adatait.',
en: 'Privacy policy — learn how we handle your personal data.',
},
terms: {
hu: 'Általános Szerződési Feltételek - Ismerje meg a mozdIT Bt. szolgáltatásainak használati feltételeit.',
en: 'Terms of Service — learn about the terms and conditions of mozdIT Bt.s services.',
},
}
export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
const { locale: rawLocale, slug } = await params
if (!isLocale(rawLocale)) return {}
const locale: Locale = rawLocale
const key = resolvePageKey(locale, slug)
if (!key) return {}
const base = (title: string, description: string, ogDescription = description) => ({
title: `${title} | ${siteConfig.general.name}`,
description,
openGraph: {
title: `${title} | ${siteConfig.general.name}`,
description: ogDescription,
url: `${siteConfig.general.url}${'/' + locale}/${slug}`,
locale: getOgLocale(locale),
},
})
if (key === 'about') {
const about = await getAboutContent(locale)
return base(about.meta.title, about.meta.description, about.meta.ogDescription)
}
if (key === 'services') {
const services = await getServicesContent(locale)
return base(services.meta.title, services.meta.description, services.meta.ogDescription)
}
if (key === 'contact') {
const contact = await getContactContent(locale)
return base(contact.meta.title, contact.meta.description)
}
if (key === 'privacy') {
const page = await getLegalPage('adatvedelem', locale)
return base(page?.title ?? 'Adatvédelem', LEGAL_META.privacy[locale])
}
// terms
const page = await getLegalPage('hasznalati-feltetelek', locale)
return base(page?.title ?? 'ÁSZF', LEGAL_META.terms[locale])
}
export default async function CatchAllPage({ params }: { params: Promise<Params> }) {
const { locale: rawLocale, slug } = await params
if (!isLocale(rawLocale)) notFound()
const locale: Locale = rawLocale
const key = resolvePageKey(locale, slug)
if (!key) notFound()
if (key === 'about') {
const content = await getAboutContent(locale)
return <AboutView content={content} locale={locale} />
}
if (key === 'services') {
const [content, home, common] = await Promise.all([
getServicesContent(locale),
getHomeContent(locale),
getCommonContent(locale),
])
return (
<ServicesView
content={content}
homeServices={home.services.items}
featuresLabel={common.labels.features}
webmailHref={home.hero.cta.secondary?.href ?? siteConfig.general.url}
locale={locale}
/>
)
}
if (key === 'contact') {
const [content, home, common] = await Promise.all([
getContactContent(locale),
getHomeContent(locale),
getCommonContent(locale),
])
return (
<ContactView
content={content}
contactEmail={siteConfig.contact.email}
footerAddress={common.footer.address}
webmailHref={home.hero.cta.secondary?.href ?? siteConfig.general.url}
locale={locale}
/>
)
}
const page = await getLegalPage(key === 'privacy' ? 'adatvedelem' : 'hasznalati-feltetelek', locale)
if (!page) notFound()
return <LegalPageView page={page} locale={locale} />
}
@@ -1,63 +0,0 @@
import { notFound } from 'next/navigation'
import Header from '../../../components/Header'
import Footer from '../../../components/Footer'
import { isLocale, localePath, type Locale } from '@/lib/i18n'
import { getCommonContent, getHomeContent } from '@/lib/payload-content'
import { siteConfig, getMainNavigation, getFooterNavigation, getFooterLegalLinks, getSiteDescription } from '@/config/site'
// WHY force-dynamic (MITHOME-97): ezek az oldalak a Payload Local API-t hívják
// (élő MongoDB-olvasás). generateStaticParams + SSG mellett a build időben
// sütött ki minden oldal, és a Payload adminban végzett publikálás csak egy
// teljes redeploy után jelent volna meg a publikus oldalon — ez pont az
// ellentéte az önkiszolgáló szerkesztés céljának, amiért a Payload-migráció
// történt. force-dynamic-kal minden kérés friss Payload-olvasást kap, és a
// Docker build sem függ többé egy build-idejű MongoDB-kapcsolattól.
export const dynamic = 'force-dynamic'
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ locale: string }>
}) {
const { locale: rawLocale } = await params
if (!isLocale(rawLocale)) notFound()
const locale: Locale = rawLocale
const [common, home] = await Promise.all([
getCommonContent(locale),
getHomeContent(locale),
])
const isStaging = process.env.NEXT_PUBLIC_DEPLOY_ENV === 'staging'
return (
<>
{isStaging && (
<div className="bg-amber-400 px-4 py-2 text-center text-xs font-extrabold tracking-[0.18em] text-amber-950 sm:text-sm">
{common.staging.banner}
</div>
)}
<Header
nav={getMainNavigation(locale)}
homeHref={localePath(locale)}
a11y={common.a11y}
/>
<main className="flex-1">
{children}
</main>
<Footer
nav={getFooterNavigation(locale)}
legalLinks={getFooterLegalLinks(locale)}
homeHref={localePath(locale)}
description={getSiteDescription(locale)}
contactEmail={siteConfig.contact.email}
footerAddress={common.footer.address}
footerCopyright={common.footer.copyright}
homeServices={home.services.items.map((item) => ({ id: item.id, title: item.title, icon: item.icon }))}
locale={locale}
/>
</>
)
}
@@ -1,44 +0,0 @@
import type { Metadata } from 'next'
import HomeView from '@/components/views/HomeView'
import { isLocale, type Locale } from '@/lib/i18n'
import { getHomeContent, getPartners } from '@/lib/payload-content'
import { siteConfig, getSiteDescription, getOgLocale } from '@/config/site'
import { notFound } from 'next/navigation'
type Params = { locale: string }
// WHY force-dynamic: lásd [slug]/page.tsx és ../layout.tsx.
export const dynamic = 'force-dynamic'
export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
const { locale: rawLocale } = await params
if (!isLocale(rawLocale)) return {}
const locale: Locale = rawLocale
const description = getSiteDescription(locale)
return {
title: `${siteConfig.general.name} | ${description}`,
description,
openGraph: {
title: siteConfig.general.name,
description,
url: siteConfig.general.url,
siteName: siteConfig.general.name,
images: [{ url: siteConfig.general.ogImage, width: 1200, height: 630, alt: siteConfig.general.name }],
locale: getOgLocale(locale),
type: 'website',
},
}
}
export default async function HomePage({ params }: { params: Promise<Params> }) {
const { locale: rawLocale } = await params
if (!isLocale(rawLocale)) notFound()
const locale: Locale = rawLocale
const [content, partners] = await Promise.all([
getHomeContent(locale),
getPartners(),
])
return <HomeView content={content} partners={partners} locale={locale} />
}
-27
View File
@@ -1,27 +0,0 @@
import Link from 'next/link'
import { DEFAULT_LOCALE, localePath } from '@/lib/i18n'
/**
* MITHOME-91/114: explicit not-found a (frontend) route group szintjén.
*
* WHY kell ez expliciten: notFound() hívásra (érvénytelen locale vagy slug
* a [locale]/[slug] catch-all-ban) Next.js enélkül a beépített, kétértelmű
* fallback UI-t próbálja renderelni — ez a (payload) route group saját
* <html> gyökerével ütközve ugyanazt a "script tag" / dupla-html hibát
* okozta, amit a MITHOME-87-ben már egyszer megoldottunk a (frontend)/
* (payload) szétválasztással. Egy saját not-found.tsx a (frontend) alatt
* egyértelművé teszi, melyik gyökér html-be kell renderelni.
*/
export default function NotFound() {
return (
<div className="flex flex-1 flex-col items-center justify-center py-24 px-4 text-center">
<h1 className="text-4xl font-bold mb-4" style={{ color: 'var(--color-foreground)' }}>404</h1>
<p className="mb-8" style={{ color: 'var(--color-foreground-muted)' }}>
A keresett oldal nem található. / The page youre looking for could not be found.
</p>
<Link href={localePath(DEFAULT_LOCALE)} className="btn btn-primary">
mozdIT Bt.
</Link>
</div>
)
}
-8
View File
@@ -1,8 +0,0 @@
import { redirect } from 'next/navigation'
import { DEFAULT_LOCALE, localePath } from '@/lib/i18n'
// A puszta domain-gyökér (pl. mozdit.hu/) az alapértelmezett nyelvre
// (hu) irányít — a tényleges főoldal a /hu alatt él (MITHOME-91/114).
export default function RootRedirect() {
redirect(localePath(DEFAULT_LOCALE))
}
@@ -1,17 +0,0 @@
import type { Metadata } from 'next'
import config from '@payload-config'
import { NotFoundPage, generatePageMetadata } from '@payloadcms/next/views'
import { importMap } from '../importMap'
type Args = {
params: Promise<{ segments: string[] }>
searchParams: Promise<{ [key: string]: string | string[] }>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const NotFound = ({ params, searchParams }: Args) =>
NotFoundPage({ config, params, searchParams, importMap })
export default NotFound
@@ -1,16 +0,0 @@
import type { Metadata } from 'next'
import config from '@payload-config'
import { RootPage, generatePageMetadata } from '@payloadcms/next/views'
import { importMap } from '../importMap'
type Args = {
params: Promise<{ segments: string[] }>
searchParams: Promise<{ [key: string]: string | string[] }>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const Page = ({ params, searchParams }: Args) => RootPage({ config, params, searchParams, importMap })
export default Page
@@ -1,8 +0,0 @@
import { QuickSearch as QuickSearch_899db48c9ce30e524aae8643dea53f6d } from '../../../../src/components/admin/QuickSearch'
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
/** @type import('payload').ImportMap */
export const importMap = {
"./src/components/admin/QuickSearch#QuickSearch": QuickSearch_899db48c9ce30e524aae8643dea53f6d,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
}
@@ -1,16 +0,0 @@
import config from '@payload-config'
import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} from '@payloadcms/next/routes'
export const GET = REST_GET(config)
export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config)
export const PUT = REST_PUT(config)
export const OPTIONS = REST_OPTIONS(config)
@@ -1,4 +0,0 @@
import config from '@payload-config'
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
export const GET = GRAPHQL_PLAYGROUND_GET(config)
@@ -1,4 +0,0 @@
import config from '@payload-config'
import { GRAPHQL_POST } from '@payloadcms/next/routes'
export const POST = GRAPHQL_POST(config)
-29
View File
@@ -1,29 +0,0 @@
import type { ServerFunctionClient } from 'payload'
import config from '@payload-config'
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
import React from 'react'
import { importMap } from './admin/importMap'
import '@payloadcms/next/css'
type Args = {
children: React.ReactNode
}
const serverFunction: ServerFunctionClient = async function (args) {
'use server'
return handleServerFunctions({
...args,
config,
importMap,
})
}
const Layout = ({ children }: Args) => (
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
{children}
</RootLayout>
)
export default Layout
@@ -1,19 +1,13 @@
import type { Locale } from '@/lib/i18n' import { content } from '@/content'
import type { getLegalPage } from '@/lib/payload-content'
type LegalPageViewProps = { export const metadata = {
page: NonNullable<Awaited<ReturnType<typeof getLegalPage>>> title: `${content.pages.adatvedelem.title} | ${content.common.labels.features || 'mozdIT Bt.'}`,
locale: Locale description: 'Adatvédelmi tájékoztató - ismerje meg, hogyan kezeljük személyes adatait.',
} }
const LAST_UPDATED_LABEL: Record<Locale, string> = { export default function PrivacyPolicy() {
hu: 'Utolsó frissítés', const pageContent = content.pages.adatvedelem
en: 'Last updated',
}
/** Közös nézet az adatvédelmi tájékoztatóhoz és a használati feltételekhez
* mindkettő azonos szerkezetű (title/lastUpdated/sections). */
export default function LegalPageView({ page, locale }: LegalPageViewProps) {
return ( return (
<div className="py-20 lg:py-28 max-w-4xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="py-20 lg:py-28 max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="mb-12"> <div className="mb-12">
@@ -21,18 +15,18 @@ export default function LegalPageView({ page, locale }: LegalPageViewProps) {
className="text-4xl md:text-5xl font-bold mb-4" className="text-4xl md:text-5xl font-bold mb-4"
style={{ color: 'var(--color-foreground)' }} style={{ color: 'var(--color-foreground)' }}
> >
{page.title} {pageContent.title}
</h1> </h1>
<p <p
className="text-sm" className="text-sm"
style={{ color: 'var(--color-foreground-muted)' }} style={{ color: 'var(--color-foreground-muted)' }}
> >
{LAST_UPDATED_LABEL[locale]}: {page.lastUpdated} Utolsó frissítés: {pageContent.lastUpdated}
</p> </p>
</div> </div>
<div className="space-y-12"> <div className="space-y-12">
{page.sections.map((section) => ( {pageContent.sections.map((section) => (
<section key={section.id}> <section key={section.id}>
<h2 <h2
className="text-2xl font-semibold mb-4" className="text-2xl font-semibold mb-4"
+21 -13
View File
@@ -1,6 +1,5 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload' import { getCollection } from '@/lib/mongodb'
import config from '@payload-config'
interface ContactFormData { interface ContactFormData {
name: string name: string
@@ -10,6 +9,16 @@ interface ContactFormData {
gdprConsent: boolean gdprConsent: boolean
} }
interface ContactSubmission {
name: string
email: string
subject: string
message: string
gdprConsent: true
status: 'new'
createdAt: Date
}
// Simple spam protection - rate limiting by IP // Simple spam protection - rate limiting by IP
const rateLimitMap = new Map<string, { count: number; timestamp: number }>() const rateLimitMap = new Map<string, { count: number; timestamp: number }>()
const RATE_LIMIT_WINDOW = 60 * 1000 // 1 minute const RATE_LIMIT_WINDOW = 60 * 1000 // 1 minute
@@ -118,21 +127,20 @@ export async function POST(request: NextRequest) {
) )
} }
const payload = await getPayload({ config }) const submissions = await getCollection<ContactSubmission>('contact_submissions')
const submission = await payload.create({ const submission: ContactSubmission = {
collection: 'contact-submissions', ...sanitizedData,
data: { gdprConsent: true,
...sanitizedData, status: 'new',
gdprConsent: true, createdAt: new Date(),
status: 'new', }
}, const result = await submissions.insertOne(submission)
})
return NextResponse.json( return NextResponse.json(
{ {
message: 'Üzenet sikeresen elküldve!', message: 'Üzenet sikeresen elküldve!',
timestamp: submission.createdAt, timestamp: submission.createdAt.toISOString(),
submissionId: String(submission.id) submissionId: result.insertedId.toHexString()
}, },
{ status: 200 } { status: 200 }
) )
+14 -29
View File
@@ -3,9 +3,8 @@
* These tests focus on testing the business logic without complex mocking * These tests focus on testing the business logic without complex mocking
*/ */
import { POST } from './route' import { POST } from './route'
import { getPayload } from 'payload'
const mockCreate = jest.fn() const mockInsertOne = jest.fn()
// Mock the logger to avoid complex setup // Mock the logger to avoid complex setup
jest.mock('@/lib/logger', () => ({ jest.mock('@/lib/logger', () => ({
@@ -16,28 +15,16 @@ jest.mock('@/lib/logger', () => ({
}) })
})) }))
// WHY mock the resolved relative path instead of the '@payload-config' alias: jest.mock('@/lib/mongodb', () => ({
// next/jest's SWC transform rewrites the tsconfig path alias to a real getCollection: jest.fn(async () => ({ insertOne: mockInsertOne })),
// relative specifier at transform time (Jest itself doesn't understand
// tsconfig `paths`), so a `jest.mock('@payload-config', ...)` never actually
// intercepts what route.ts ends up requiring — it silently falls through to
// the real proto/src/payload.config.ts (mongooseAdapter, Users/LegalPages/etc
// imports), which needs a live MongoDB and PAYLOAD_SECRET, exactly what these
// tests avoid. The route only ever passes this value through to the (also
// mocked) getPayload(), so its actual shape doesn't matter here.
jest.mock('../../../payload.config', () => ({ __esModule: true, default: {} }))
jest.mock('payload', () => ({
getPayload: jest.fn(async () => ({ create: mockCreate })),
})) }))
describe('/api/contact Unit Tests', () => { describe('/api/contact Unit Tests', () => {
beforeEach(() => { beforeEach(() => {
mockCreate.mockReset() mockInsertOne.mockReset()
;(getPayload as jest.Mock).mockClear()
}) })
describe('Payload persistence', () => { describe('MongoDB persistence', () => {
const validData = { const validData = {
name: 'Test User', name: 'Test User',
email: 'test@example.com', email: 'test@example.com',
@@ -47,7 +34,7 @@ describe('/api/contact Unit Tests', () => {
} }
it('persists a valid submission before reporting success', async () => { it('persists a valid submission before reporting success', async () => {
mockCreate.mockResolvedValue({ id: 'submission-123', createdAt: '2026-09-12T00:00:00.000Z' }) mockInsertOne.mockResolvedValue({ insertedId: { toHexString: () => 'submission-123' } })
const request = { const request = {
headers: new Headers({ 'x-forwarded-for': 'persistence-success' }), headers: new Headers({ 'x-forwarded-for': 'persistence-success' }),
json: async () => validData, json: async () => validData,
@@ -58,17 +45,15 @@ describe('/api/contact Unit Tests', () => {
expect(response.status).toBe(200) expect(response.status).toBe(200)
expect(body.submissionId).toBe('submission-123') expect(body.submissionId).toBe('submission-123')
expect(mockCreate).toHaveBeenCalledWith({ expect(mockInsertOne).toHaveBeenCalledWith(expect.objectContaining({
collection: 'contact-submissions', ...validData,
data: expect.objectContaining({ status: 'new',
...validData, createdAt: expect.any(Date),
status: 'new', }))
}),
})
}) })
it('returns a server error when persistence fails', async () => { it('returns a server error when persistence fails', async () => {
mockCreate.mockRejectedValue(new Error('MongoDB unavailable')) mockInsertOne.mockRejectedValue(new Error('MongoDB unavailable'))
const request = { const request = {
headers: new Headers({ 'x-forwarded-for': 'persistence-failure' }), headers: new Headers({ 'x-forwarded-for': 'persistence-failure' }),
json: async () => validData, json: async () => validData,
@@ -77,7 +62,7 @@ describe('/api/contact Unit Tests', () => {
const response = await POST(request) const response = await POST(request)
expect(response.status).toBe(500) expect(response.status).toBe(500)
expect(mockCreate).toHaveBeenCalledTimes(1) expect(mockInsertOne).toHaveBeenCalledTimes(1)
}) })
it('rejects an over-long message before persisting', async () => { it('rejects an over-long message before persisting', async () => {
@@ -90,7 +75,7 @@ describe('/api/contact Unit Tests', () => {
const response = await POST(request) const response = await POST(request)
expect(response.status).toBe(400) expect(response.status).toBe(400)
expect(mockCreate).not.toHaveBeenCalled() expect(mockInsertOne).not.toHaveBeenCalled()
}) })
}) })
@@ -0,0 +1,53 @@
import { content } from '@/content'
export const metadata = {
title: `${content.pages.hasznalatiFeltetelek.title} | ${content.common.labels.features || 'mozdIT Bt.'}`,
description: 'Általános Szerződési Feltételek - Ismerje meg a mozdIT Bt. szolgáltatásainak használati feltételeit.',
}
export default function TermsOfService() {
const pageContent = content.pages.hasznalatiFeltetelek
return (
<div className="py-20 lg:py-28 max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="mb-12">
<h1
className="text-4xl md:text-5xl font-bold mb-4"
style={{ color: 'var(--color-foreground)' }}
>
{pageContent.title}
</h1>
<p
className="text-sm"
style={{ color: 'var(--color-foreground-muted)' }}
>
Utolsó frissítés: {pageContent.lastUpdated}
</p>
</div>
<div className="space-y-12">
{pageContent.sections.map((section) => (
<section key={section.id}>
<h2
className="text-2xl font-semibold mb-4"
style={{ color: 'var(--color-foreground)' }}
>
{section.title}
</h2>
<div
className="prose max-w-none"
style={{ color: 'var(--color-foreground-muted)' }}
dangerouslySetInnerHTML={{
__html: section.content
.replace(/\n\n/g, '</p><p class="mb-4">')
.replace(/• \*\*(.*?)\*\*/g, '<br/>• <strong>$1</strong>')
.replace(/^/, '<p class="mb-4">')
.replace(/$/, '</p>')
}}
/>
</section>
))}
</div>
</div>
)
}
+23
View File
@@ -0,0 +1,23 @@
import { siteConfig } from '@/config/site'
import { content } from '@/content'
import type { Metadata } from 'next'
const { contact: pageContent } = content.pages
export const metadata: Metadata = {
title: `${pageContent.meta.title} | ${siteConfig.general.name}`,
description: pageContent.meta.description,
openGraph: {
title: `${pageContent.meta.title} | ${siteConfig.general.name}`,
description: pageContent.meta.description,
url: `${siteConfig.general.url}/kapcsolat`,
},
}
export default function ContactLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}
@@ -1,25 +1,12 @@
'use client' 'use client'
import { useState } from 'react'
import { siteConfig } from '@/config/site' import { siteConfig } from '@/config/site'
import { localePath, type Locale } from '@/lib/i18n' import { content } from '@/content'
import type { getContactContent } from '@/lib/payload-content' import { useState } from 'react'
type ContactViewProps = { const { contact: pageContent } = content.pages
content: Awaited<ReturnType<typeof getContactContent>>
contactEmail: string
footerAddress: string
webmailHref: string
locale: Locale
}
export default function ContactView({ content: pageContent, contactEmail, footerAddress, webmailHref, locale }: ContactViewProps) { export default function ContactPage() {
// WHY placeholder-csere: a GDPR-szöveg (Payload Contact.form.fields.gdpr.label)
// egy {privacyHref} tokent tartalmaz a beágyazott <a> linkben, mert a
// tényleges adatvédelmi oldal útvonala nyelvenként eltér (MITHOME-114) — a
// korábbi, hardcode-olt "/adatkezelesi-tajekoztato" út sosem egyezett a
// valódi oldallal, ez javítja azt is.
const gdprLabel = pageContent.form.fields.gdpr.label.replace('{privacyHref}', localePath(locale, 'privacy'))
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
email: '', email: '',
@@ -148,7 +135,7 @@ export default function ContactView({ content: pageContent, contactEmail, footer
{submitStatus === 'error' && ( {submitStatus === 'error' && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-md"> <div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-md">
<p className="text-red-800"> <p className="text-red-800">
{pageContent.form.errorMessage.replace('{email}', contactEmail)} {pageContent.form.errorMessage.replace('{email}', siteConfig.contact.email)}
</p> </p>
</div> </div>
)} )}
@@ -237,7 +224,7 @@ export default function ContactView({ content: pageContent, contactEmail, footer
/> />
<span <span
className="text-sm text-gray-700" className="text-sm text-gray-700"
dangerouslySetInnerHTML={{ __html: gdprLabel + ' *' }} dangerouslySetInnerHTML={{ __html: pageContent.form.fields.gdpr.label + ' *' }}
/> />
</label> </label>
{errors.gdprConsent && <p className="mt-1 text-sm text-red-600">{errors.gdprConsent}</p>} {errors.gdprConsent && <p className="mt-1 text-sm text-red-600">{errors.gdprConsent}</p>}
@@ -269,10 +256,10 @@ export default function ContactView({ content: pageContent, contactEmail, footer
<div> <div>
<h3 className="font-semibold text-gray-900 mb-1">{pageContent.info.email.title}</h3> <h3 className="font-semibold text-gray-900 mb-1">{pageContent.info.email.title}</h3>
<a <a
href={`mailto:${contactEmail}`} href={`mailto:${siteConfig.contact.email}`}
className="text-blue-600 hover:text-blue-700" className="text-blue-600 hover:text-blue-700"
> >
{contactEmail} {siteConfig.contact.email}
</a> </a>
<p className="text-sm text-gray-600 mt-1"> <p className="text-sm text-gray-600 mt-1">
{pageContent.info.email.responseTime} {pageContent.info.email.responseTime}
@@ -287,7 +274,7 @@ export default function ContactView({ content: pageContent, contactEmail, footer
<div> <div>
<h3 className="font-semibold text-gray-900 mb-1">{pageContent.info.company.title}</h3> <h3 className="font-semibold text-gray-900 mb-1">{pageContent.info.company.title}</h3>
<p className="text-gray-700">{siteConfig.general.name}</p> <p className="text-gray-700">{siteConfig.general.name}</p>
<p className="text-sm text-gray-600">{footerAddress}</p> <p className="text-sm text-gray-600">{content.common.footer.address}</p>
</div> </div>
</div> </div>
@@ -298,11 +285,7 @@ export default function ContactView({ content: pageContent, contactEmail, footer
<div> <div>
<h3 className="font-semibold text-gray-900 mb-1">{pageContent.info.webmail.title}</h3> <h3 className="font-semibold text-gray-900 mb-1">{pageContent.info.webmail.title}</h3>
<a <a
// WHY webmailHref: a migráció előtti kód itt is a href={content.pages.home.hero.cta.primary.href}
// "/kapcsolat" hrefet használta (ugyanaz a bug, mint a
// Szolgáltatások oldalon) — javítva a tényleges webmail
// URL-re.
href={webmailHref}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-700" className="text-blue-600 hover:text-blue-700"
@@ -324,7 +307,7 @@ export default function ContactView({ content: pageContent, contactEmail, footer
</h2> </h2>
<div className="space-y-4"> <div className="space-y-4">
{(pageContent.faq.items ?? []).map((item, index) => ( {pageContent.faq.items.map((item, index) => (
<div key={index}> <div key={index}>
<h3 className="font-semibold text-gray-900 mb-2">{item.question}</h3> <h3 className="font-semibold text-gray-900 mb-2">{item.question}</h3>
<p className="text-gray-600 text-sm">{item.answer}</p> <p className="text-gray-600 text-sm">{item.answer}</p>
@@ -2,8 +2,11 @@ import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { ThemeProvider } from "../../components/ThemeProvider"; import Header from "../components/Header";
import { siteConfig, getSiteDescription } from "../../config/site"; import Footer from "../components/Footer";
import { ThemeProvider } from "../components/ThemeProvider";
import { siteConfig } from "../config/site";
import { common } from "../content";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -15,18 +18,47 @@ const geistMono = Geist_Mono({
subsets: ["latin"], subsets: ["latin"],
}); });
// WHY hu itt: ez a legkülső, nyelv-független layout (html/body/theme-script
// csak egyszer, MITHOME-91/114) — a metadataBase és az alap description a
// magyar (alapértelmezett) nyelvet tükrözi, de minden [locale]/[slug]
// oldal a saját generateMetadata()-jával felülírja title/description-t
// nyelvhelyesen. Ez csak a legelső, JS nélküli betöltéskori fallback.
export const metadata: Metadata = { export const metadata: Metadata = {
// WHY metadataBase: without it Next resolves relative OG/twitter image URLs // WHY metadataBase: without it Next resolves relative OG/twitter image URLs
// against localhost, producing broken social previews in production. // against localhost, producing broken social previews in production.
metadataBase: new URL(siteConfig.general.url), metadataBase: new URL(siteConfig.general.url),
title: siteConfig.general.name, title: `${siteConfig.general.name} | ${siteConfig.general.description}`,
description: getSiteDescription("hu"), description: siteConfig.general.description,
authors: [{ name: siteConfig.general.name }], authors: [{ name: siteConfig.general.name }],
keywords: ["web hosting", "email szolgáltatás", "DNS adminisztráció", "IT szolgáltatás", "mozdIT"],
openGraph: {
title: siteConfig.general.name,
description: siteConfig.general.description,
url: siteConfig.general.url,
siteName: siteConfig.general.name,
images: [
{
url: siteConfig.general.ogImage,
width: 1200,
height: 630,
alt: siteConfig.general.name,
},
],
locale: siteConfig.general.locale,
type: "website",
},
twitter: {
card: "summary_large_image",
title: siteConfig.general.name,
description: siteConfig.general.description,
images: [siteConfig.general.ogImage],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
}; };
// Keep Safari's browser chrome neutral; only the in-page staging strip is amber. // Keep Safari's browser chrome neutral; only the in-page staging strip is amber.
@@ -39,6 +71,7 @@ export default function RootLayout({
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const isStaging = process.env.NEXT_PUBLIC_DEPLOY_ENV === 'staging';
return ( return (
<html lang="hu" suppressHydrationWarning> <html lang="hu" suppressHydrationWarning>
<head> <head>
@@ -62,7 +95,16 @@ export default function RootLayout({
style={{ background: 'var(--color-background)', color: 'var(--color-foreground)' }} style={{ background: 'var(--color-background)', color: 'var(--color-foreground)' }}
> >
<ThemeProvider> <ThemeProvider>
{children} {isStaging && (
<div className="bg-amber-400 px-4 py-2 text-center text-xs font-extrabold tracking-[0.18em] text-amber-950 sm:text-sm">
{common.staging.banner}
</div>
)}
<Header />
<main className="flex-1">
{children}
</main>
<Footer />
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>
@@ -1,25 +1,9 @@
import { content } from '@/content'
import Image from 'next/image' import Image from 'next/image'
import { localePath, type Locale } from '@/lib/i18n'
import type { getHomeContent, PartnerView } from '@/lib/payload-content'
type HomeViewProps = { const { home: pageContent } = content.pages
content: Awaited<ReturnType<typeof getHomeContent>>
partners: PartnerView[]
locale: Locale
}
// WHY hardcoded itt: a Partners collection (MITHOME-89) csak name/url/logo-t export default function Home() {
// tárol, a szekció saját címe/alcíme sosem volt Payload-tartalom (a Home
// Globalból is szándékosan kimaradt, lásd src/globals/Home.ts) — ugyanaz a
// minta, mint a config/site.ts navigáció-feliratoknál.
const PARTNERS_COPY: Record<Locale, { title: string; subtitle: string }> = {
hu: { title: 'Partnereink', subtitle: 'Akikkel együtt dolgozunk' },
en: { title: 'Our Partners', subtitle: 'Who we work with' },
}
export default function HomeView({ content: pageContent, partners, locale }: HomeViewProps) {
const contactHref = localePath(locale, 'contact')
const partnersCopy = PARTNERS_COPY[locale]
return ( return (
<div className="space-y-0"> <div className="space-y-0">
{/* Hero Section */} {/* Hero Section */}
@@ -110,7 +94,7 @@ export default function HomeView({ content: pageContent, partners, locale }: Hom
</h2> </h2>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 stagger-children"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 stagger-children">
{(pageContent.about.usps ?? []).map((usp, index) => ( {pageContent.about.usps.map((usp, index) => (
<div <div
key={usp.id} key={usp.id}
className="group text-center p-6 rounded-xl transition-all duration-300 hover-lift animate-fade-in-up" className="group text-center p-6 rounded-xl transition-all duration-300 hover-lift animate-fade-in-up"
@@ -207,7 +191,7 @@ export default function HomeView({ content: pageContent, partners, locale }: Hom
</ul> </ul>
</div> </div>
<a <a
href={contactHref} href="/kapcsolat"
className="inline-flex items-center font-medium transition-all duration-200 group/link" className="inline-flex items-center font-medium transition-all duration-200 group/link"
style={{ color: 'var(--color-primary-600)' }} style={{ color: 'var(--color-primary-600)' }}
> >
@@ -228,23 +212,17 @@ export default function HomeView({ content: pageContent, partners, locale }: Hom
</section> </section>
{/* Partners Section */} {/* Partners Section */}
{partners.length > 0 && ( {pageContent.partners.items.length > 0 && (
<section className="py-16"> <section className="py-16">
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 className="text-3xl font-bold text-center mb-3" style={{ color: 'var(--color-foreground)' }}> <h2 className="text-3xl font-bold text-center mb-3" style={{ color: 'var(--color-foreground)' }}>
{partnersCopy.title} {pageContent.partners.title}
</h2> </h2>
<p className="text-center mb-10" style={{ color: 'var(--color-foreground-muted)' }}> <p className="text-center mb-10" style={{ color: 'var(--color-foreground-muted)' }}>
{partnersCopy.subtitle} {pageContent.partners.subtitle}
</p> </p>
{/* WHY grid + w-fit + mx-auto: a felhasználó kérésére valódi <div className="flex flex-wrap justify-center items-center gap-10">
mátrix (max 3 oszlop), nem folyó flex-wrap. `w-fit` zsugorítja {pageContent.partners.items.map((partner) => (
a rácsot a tényleges (legfeljebb 3 oszlopnyi) tartalom
szélességére, a `mx-auto` pedig ettől tudja középre venni
sima `inline-grid`-en az auto margó nem középre igazítana,
mert az egy inline-szintű doboz. */}
<div className="grid w-fit grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-10 justify-items-center mx-auto">
{partners.map((partner) => (
<a <a
key={partner.name} key={partner.name}
href={partner.url} href={partner.url}
@@ -255,8 +233,8 @@ export default function HomeView({ content: pageContent, partners, locale }: Hom
> >
<span className="relative h-12 w-40"> <span className="relative h-12 w-40">
<Image <Image
src={partner.logo.url} src={partner.logo}
alt={partner.logo.alt} alt={partner.name}
fill fill
sizes="160px" sizes="160px"
className="object-contain" className="object-contain"
@@ -307,7 +285,7 @@ export default function HomeView({ content: pageContent, partners, locale }: Hom
{pageContent.cta.subtitle} {pageContent.cta.subtitle}
</p> </p>
<a <a
href={contactHref} href="/kapcsolat"
className="btn btn-primary text-lg px-8 py-4 hover-glow" className="btn btn-primary text-lg px-8 py-4 hover-glow"
> >
{pageContent.cta.button} {pageContent.cta.button}
@@ -1,13 +1,20 @@
import { localePath, type Locale } from '@/lib/i18n' import { siteConfig } from '@/config/site'
import type { getAboutContent } from '@/lib/payload-content' import { content } from '@/content'
import type { Metadata } from 'next'
type AboutViewProps = { const { about: pageContent } = content.pages
content: Awaited<ReturnType<typeof getAboutContent>>
locale: Locale export const metadata: Metadata = {
title: `${pageContent.meta.title} | ${siteConfig.general.name}`,
description: pageContent.meta.description,
openGraph: {
title: `${pageContent.meta.title} | ${siteConfig.general.name}`,
description: pageContent.meta.ogDescription,
url: `${siteConfig.general.url}/rolunk`,
},
} }
export default function AboutView({ content: pageContent, locale }: AboutViewProps) { export default function AboutPage() {
const contactHref = localePath(locale, 'contact')
return ( return (
<div className="space-y-0"> <div className="space-y-0">
{/* Hero Section */} {/* Hero Section */}
@@ -85,7 +92,7 @@ export default function AboutView({ content: pageContent, locale }: AboutViewPro
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 stagger-children"> <div className="grid grid-cols-1 md:grid-cols-3 gap-8 stagger-children">
{(pageContent.mission.values ?? []).map((item, index) => ( {pageContent.mission.values.map((item, index) => (
<div <div
key={item.title} key={item.title}
className="group text-center p-8 rounded-xl hover-lift animate-fade-in-up" className="group text-center p-8 rounded-xl hover-lift animate-fade-in-up"
@@ -175,7 +182,7 @@ export default function AboutView({ content: pageContent, locale }: AboutViewPro
{pageContent.cta.subtitle} {pageContent.cta.subtitle}
</p> </p>
<a <a
href={contactHref} href="/kapcsolat"
className="btn btn-primary text-lg px-8 py-4 hover-glow" className="btn btn-primary text-lg px-8 py-4 hover-glow"
> >
{pageContent.cta.button} {pageContent.cta.button}
@@ -1,16 +1,20 @@
import { localePath, type Locale } from '@/lib/i18n' import { siteConfig } from '@/config/site'
import type { getHomeContent, getServicesContent } from '@/lib/payload-content' import { content } from '@/content'
import type { Metadata } from 'next'
type ServicesViewProps = { const { services: pageContent } = content.pages
content: Awaited<ReturnType<typeof getServicesContent>>
homeServices: Awaited<ReturnType<typeof getHomeContent>>['services']['items'] export const metadata: Metadata = {
featuresLabel: string title: `${pageContent.meta.title} | ${siteConfig.general.name}`,
webmailHref: string description: pageContent.meta.description,
locale: Locale openGraph: {
title: `${pageContent.meta.title} | ${siteConfig.general.name}`,
description: pageContent.meta.ogDescription,
url: `${siteConfig.general.url}/szolgaltatasok`,
},
} }
export default function ServicesView({ content: pageContent, homeServices, featuresLabel, webmailHref, locale }: ServicesViewProps) { export default function ServicesPage() {
const contactHref = localePath(locale, 'contact')
return ( return (
<div className="space-y-16 py-8"> <div className="space-y-16 py-8">
{/* Hero Section */} {/* Hero Section */}
@@ -28,7 +32,7 @@ export default function ServicesView({ content: pageContent, homeServices, featu
{/* Services Grid */} {/* Services Grid */}
<section className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8"> <section className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{homeServices.map((service) => ( {content.pages.home.services.items.map((service) => (
<div key={service.id} className="bg-white rounded-xl shadow-sm border border-gray-200 p-8 hover:shadow-md transition-shadow"> <div key={service.id} className="bg-white rounded-xl shadow-sm border border-gray-200 p-8 hover:shadow-md transition-shadow">
<div className="w-16 h-16 bg-blue-100 rounded-lg flex items-center justify-center mb-6"> <div className="w-16 h-16 bg-blue-100 rounded-lg flex items-center justify-center mb-6">
<span className="text-2xl">{service.icon}</span> <span className="text-2xl">{service.icon}</span>
@@ -38,7 +42,7 @@ export default function ServicesView({ content: pageContent, homeServices, featu
<p className="text-gray-600 leading-relaxed mb-6">{service.description}</p> <p className="text-gray-600 leading-relaxed mb-6">{service.description}</p>
<div className="mb-6"> <div className="mb-6">
<h3 className="text-lg font-semibold text-gray-900 mb-3">{featuresLabel}</h3> <h3 className="text-lg font-semibold text-gray-900 mb-3">{content.common.labels.features}</h3>
<ul className="space-y-2"> <ul className="space-y-2">
{service.features.map((feature, index) => ( {service.features.map((feature, index) => (
<li key={index} className="flex items-start"> <li key={index} className="flex items-start">
@@ -50,7 +54,7 @@ export default function ServicesView({ content: pageContent, homeServices, featu
</div> </div>
<a <a
href={contactHref} href="/kapcsolat"
className="inline-flex items-center text-blue-600 hover:text-blue-700 font-medium transition-colors" className="inline-flex items-center text-blue-600 hover:text-blue-700 font-medium transition-colors"
> >
{service.ctaText} {service.ctaText}
@@ -111,7 +115,7 @@ export default function ServicesView({ content: pageContent, homeServices, featu
{pageContent.support.subtitle} {pageContent.support.subtitle}
</p> </p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 text-sm"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6 text-sm">
{(pageContent.support.channels ?? []).map((channel) => ( {pageContent.support.channels.map((channel) => (
<div key={channel.title}> <div key={channel.title}>
<h3 className="font-semibold text-gray-900 mb-2">{channel.title}</h3> <h3 className="font-semibold text-gray-900 mb-2">{channel.title}</h3>
<p className="text-gray-600">{channel.description}</p> <p className="text-gray-600">{channel.description}</p>
@@ -132,17 +136,13 @@ export default function ServicesView({ content: pageContent, homeServices, featu
</p> </p>
<div className="flex flex-col sm:flex-row gap-4 justify-center"> <div className="flex flex-col sm:flex-row gap-4 justify-center">
<a <a
href={contactHref} href="/kapcsolat"
className="inline-block bg-blue-600 hover:bg-blue-700 text-white font-medium px-8 py-3 rounded-md transition-colors" className="inline-block bg-blue-600 hover:bg-blue-700 text-white font-medium px-8 py-3 rounded-md transition-colors"
> >
{pageContent.cta.primaryButton} {pageContent.cta.primaryButton}
</a> </a>
<a <a
// WHY webmailHref és nem "/kapcsolat" ismét: a migráció előtti href={content.pages.home.hero.cta.primary.href}
// kód itt (bugként) a primary CTA hrefjét (/kapcsolat) használta
// target="_blank"-kal a "Webmail belépés" gombhoz — javítva a
// tényleges webmail URL-re.
href={webmailHref}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-block border-2 border-white text-white hover:bg-white hover:text-gray-900 font-medium px-8 py-3 rounded-md transition-colors" className="inline-block border-2 border-white text-white hover:bg-white hover:text-gray-900 font-medium px-8 py-3 rounded-md transition-colors"
@@ -1,59 +0,0 @@
import type { CollectionConfig } from 'payload'
/**
* MITHOME-94 — a /api/contact beérkező üzeneteinek tárolója.
*
* WHY nincs egyedi `access` blokk: a Payload alapértelmezése (csak
* bejelentkezett usernek engedélyezett minden művelet) itt pont a kívánt
* viselkedés — az ügyfél az admin felületen látja a beérkezéseket, kívülről
* senki nem olvashatja/írhatja a REST API-n keresztül. A src/app/api/contact
* route.ts a Local API-t használja (`payload.create`), ami alapból
* `overrideAccess: true`-val fut — ez a szerver-oldali írás nem ütközik az
* access control-lal, csak a publikus REST hozzáférés van letiltva.
*
* WHY nincs mezőnkénti hosszkorlát/validáció itt duplikálva: a rate limiting
* és a bemenet-validáció (hossz, email formátum, spam-szűrés) szándékosan
* az API route-on marad (lásd a ticket leírását) — ez a collection csak a
* már ellenőrzött adat tárolója.
*
* WHY nincs versions/drafts: ez tényadat (egy beérkezett üzenet), nem
* szerkesztendő tartalom — a `status` mező követi a feldolgozás állapotát.
*/
export const ContactSubmissions: CollectionConfig = {
slug: 'contact-submissions',
admin: {
useAsTitle: 'subject',
defaultColumns: ['subject', 'name', 'email', 'status', 'createdAt'],
group: 'Kapcsolatfelvételek',
},
defaultSort: '-createdAt',
fields: [
{ name: 'name', type: 'text', required: true },
{ name: 'email', type: 'text', required: true },
{ name: 'subject', type: 'text', required: true },
{ name: 'message', type: 'textarea', required: true },
{
name: 'gdprConsent',
type: 'checkbox',
required: true,
admin: {
description: 'Az űrlapon elfogadott adatkezelési hozzájárulás — a route csak true értékkel enged menteni.',
},
},
{
name: 'status',
type: 'select',
required: true,
defaultValue: 'new',
options: [
{ label: 'Új', value: 'new' },
{ label: 'Elolvasva', value: 'read' },
{ label: 'Megválaszolva', value: 'replied' },
{ label: 'Archiválva', value: 'archived' },
],
admin: {
description: 'Kézzel karbantartott feldolgozási állapot — a route mindig "new"-ként hozza létre.',
},
},
],
}
-53
View File
@@ -1,53 +0,0 @@
import type { CollectionConfig } from 'payload'
import { collectionVersions } from '../lib/payload-versions'
/**
* Mirrors LegalPageContent (proto/src/content/types.ts) — MITHOME-88.
* Slug-alapú collection a jogi oldalaknak (adatvedelem, hasznalati-feltetelek).
*
* WHY `content` textarea és nem lexical richText: a frontend
* (proto/src/components/views/LegalPageView.tsx) a szekció-tartalmat egy
* kézzel írt regex-alapú "•"/"**bold**" -> HTML konverzióval rendereli, nem
* valódi Markdown- vagy richText-parserrel. Ugyanaz a döntés, mint a
* Contact global gdpr.label mezőjénél (MITHOME-87) — a bootstrap szinten
* nem vezetünk be külön szerializációt egy még nem létező render-rétegért.
*/
export const LegalPages: CollectionConfig = {
slug: 'legal-pages',
admin: {
useAsTitle: 'title',
group: 'Oldalak',
},
versions: collectionVersions,
fields: [
{
name: 'slug',
type: 'text',
required: true,
unique: true,
admin: {
description: 'URL-azonosító, pl. "adatvedelem" vagy "hasznalati-feltetelek".',
},
},
{ name: 'title', type: 'text', required: true },
{
name: 'lastUpdated',
type: 'text',
required: true,
admin: {
description: 'Szabad szöveg (pl. "2026. augusztus 22."), nem dátum mező — így marad a JSON forrással kompatibilis.',
},
},
{
name: 'sections',
type: 'array',
required: true,
fields: [
{ name: 'id', type: 'text', required: true },
{ name: 'title', type: 'text', required: true },
{ name: 'content', type: 'textarea', required: true },
],
},
],
}
-46
View File
@@ -1,46 +0,0 @@
import type { CollectionConfig } from 'payload'
/**
* MITHOME-89 (MVP scope) — Payload beépített upload collection.
*
* WHY nincs saját `staticDir` a `proto/public/`-ba mutatva: a fájlokat a
* Payload a saját REST route-jain (src/app/(payload)/api/[...slug]/route.ts)
* szolgálja ki, nem közvetlen statikus mappából — így nem ütközik a régi
* content-editor.js-féle /public/partners/*.png fájlokkal, amik MITHOME-93
* leépítéséig még élnek.
*
* A tényleges vágás/forgatás/áttetszővé tétel szerkesztő NEM ide tartozik —
* lásd MITHOME-118 (külön, egyedi admin field component).
*/
export const Media: CollectionConfig = {
slug: 'media',
admin: {
group: 'Média',
},
// WHY explicit public read access: Payload alapértelmezés szerint minden
// collection read-je csak bejelentkezett usernek engedélyezett
// (defaultAccess = Boolean(user)) — enélkül a /api/media/file/*
// REST route 403-at adott VOLNA vissza kijelentkezve, tehát a publikus
// oldalon a partner logók (Home) sosem töltődtek volna be (a Next.js
// image-optimizer szerver-oldali proxyja sem küld auth cookie-t).
// Valódi böngészős teszttel derült ki (MITHOME-89 óta jelen lévő hiba,
// csak eddig senki nem nézte meg közelről a renderelt <img>-et). Write
// műveletek (create/update/delete) változatlanul csak bejelentkezett
// usernek engedélyezettek (Payload alapértelmezés).
access: {
read: () => true,
},
upload: {
mimeTypes: ['image/*'],
},
fields: [
{
name: 'alt',
type: 'text',
required: true,
admin: {
description: 'Alternatív szöveg (accessibility) — pl. "Angele Pihenőház logó".',
},
},
],
}
-44
View File
@@ -1,44 +0,0 @@
import type { CollectionConfig } from 'payload'
import { collectionVersions } from '../lib/payload-versions'
/**
* Mirrors home.json `partners.items` (proto/src/content/pages/home.json) —
* MITHOME-89 (MVP). Korábban a Home Global szándékosan NEM tartalmazta ezt
* a listát (lásd src/globals/Home.ts megjegyzése) — itt lesz az önálló
* collection, logo = Media upload reference.
*/
export const Partners: CollectionConfig = {
slug: 'partners',
admin: {
useAsTitle: 'name',
group: 'Oldalak',
},
versions: collectionVersions,
fields: [
{ name: 'name', type: 'text', required: true },
{ name: 'url', type: 'text', required: true },
{
name: 'logo',
type: 'upload',
relationTo: 'media',
// WHY opcionális (felhasználói döntés): így elmenthető egy partner
// név+URL-lel, logó nélkül is — pl. amíg a logó előkészítése
// (remove.bg stb.) még folyamatban van. A frontend (getPartners,
// src/lib/payload-content.ts) eleve kiszűri a logó nélküli
// partnereket, tehát ilyenkor egyszerűen nem jelenik meg a publikus
// oldalon, amíg nincs logó feltöltve — nem törik el semmi.
//
// WHY nincs beépített vágó/áttetszővé tevő szerkesztő: a régi CMS-ben
// ez kliens-oldali canvas-logika volt (scripts/cms-logo-client.js),
// amit tudatosan nem ültettünk át Payload admin komponensként —
// egyedi, ritkán használt funkcióhoz aránytalan a karbantartási teher
// (lásd MITHOME-118, lezárva). Helyette: már kész, átlátszó hátterű
// PNG-t kell feltölteni, azt egy külső eszközzel (pl. remove.bg)
// előkészítve.
admin: {
description: 'Már kész, átlátszó hátterű PNG-t tölts fel (pl. remove.bg-vel előkészítve) — az admin felület nem vág/forgat/tesz átlátszóvá. Logó nélkül is elmenthető a partner, de a publikus oldalon csak logóval jelenik meg.',
},
},
],
}
-60
View File
@@ -1,60 +0,0 @@
import type { Access, CollectionConfig } from 'payload'
/**
* Admin bejelentkezés + access control (MITHOME-90). A projekt egyetlen
* "admin" szerepkörrel dolgozik (nincs role-hierarchia — lásd MITHOME-85
* epic döntés); ez a collection az ügyfél és a fejlesztők közös, teljes
* jogú admin bejelentkezését adja.
*/
/** Bejelentkezett felhasználó — a REST/GraphQL create endpoint így nem
* használható publikus regisztrációra. Az admin UI "create first user"
* folyamata ettől függetlenül működik (Payload kivételt kezel, ha még
* nincs egyetlen user sem). */
const requireAuthenticatedUser: Access = ({ req: { user } }) => Boolean(user)
export const Users: CollectionConfig = {
slug: 'users',
auth: {
// WHY explicit (nem csak a Payload defaultra hagyva): a fiókzárolás
// szabályait tudatos döntésnek szánjuk, nem implicit könyvtár-alapértéknek.
maxLoginAttempts: 5,
lockTime: 10 * 60 * 1000, // 10 perc
cookies: {
// WHY NODE_ENV-alapú: helyi fejlesztésben (http://localhost) a secure
// cookie sosem menne át; staging/production HTTPS mögött viszont
// kötelező. MITHOME-15 (production domain/HTTPS) előfeltétele ennek
// valódi hatásba lépéséhez.
secure: process.env.NODE_ENV === 'production',
sameSite: 'Lax',
},
},
admin: {
useAsTitle: 'email',
},
// WHY minden op explicit requireAuthenticatedUser, holott ez a Payload
// defaultAccess-szel (Boolean(user)) megegyezik: az egyetlen "admin"
// szerepkör modellben ez helyes és elégséges — bármely bejelentkezett
// user már admin. Ismert, nyitott Payload advisory (GHSA-jg8r-5jh2-v2xj,
// <=3.88.0): az "unlock" op alapból bármely bejelentkezett usernek
// engedi más fiókok zárolásának feloldását — TÖBB szerepkör esetén ez
// jogosultság-átlépés lenne, de itt nincs "kevésbé jogosult" bejelentkezett
// user, akitől védeni kellene. Ha a MITHOME-46 (központi IDM/SSO) miatt
// több szerepkör/collection jön be, ezt itt újra kell értékelni.
access: {
create: requireAuthenticatedUser,
read: requireAuthenticatedUser,
update: requireAuthenticatedUser,
delete: requireAuthenticatedUser,
unlock: requireAuthenticatedUser,
},
fields: [
{
name: 'name',
type: 'text',
admin: {
description: 'Opcionális megjelenítendő név (pl. audit naplóban, admin fejlécben).',
},
},
],
}
+10 -27
View File
@@ -1,44 +1,27 @@
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom' import '@testing-library/jest-dom'
import Footer from './Footer' import Footer from './Footer'
import { common, content } from '@/content' import { common } from '@/content'
import { getFooterNavigation, getFooterLegalLinks, getSiteDescription } from '@/config/site'
// MITHOME-91/114: Footer lett props-alapú (locale-aware tartalom a szülő
// [locale] layoutból jön, Payload Local API-n keresztül) — a teszt a
// content/*.json fixture-öket + a valódi config/site.ts helper-eket adja
// át, hogy a viselkedés a ténylegeshez hasonló maradjon.
const footerProps = {
nav: getFooterNavigation('hu'),
legalLinks: getFooterLegalLinks('hu'),
homeHref: '/hu',
description: getSiteDescription('hu'),
contactEmail: 'info@mozdit.hu',
footerAddress: common.footer.address,
footerCopyright: common.footer.copyright,
homeServices: content.pages.home.services.items.map((item) => ({ id: item.id, title: item.title, icon: item.icon })),
locale: 'hu' as const,
}
describe('Footer', () => { describe('Footer', () => {
it('should render company information', () => { it('should render company information', () => {
render(<Footer {...footerProps} />) render(<Footer />)
expect(screen.getByText('mozdIT Bt.')).toBeInTheDocument() expect(screen.getByText('mozdIT Bt.')).toBeInTheDocument()
expect(screen.getByText('Megbízható web- és email szolgáltatás személyre szabott támogatással. Stabil tárhely, üzembiztos levelezés és DNS adminisztráció gyors reakcióval.')).toBeInTheDocument() expect(screen.getByText('Megbízható web- és email szolgáltatás személyre szabott támogatással. Stabil tárhely, üzembiztos levelezés és DNS adminisztráció gyors reakcióval.')).toBeInTheDocument()
}) })
it('should render email and company details', () => { it('should render email and company details', () => {
render(<Footer {...footerProps} />) render(<Footer />)
expect(screen.getByText('info@mozdit.hu')).toBeInTheDocument() expect(screen.getByText('info@mozdit.hu')).toBeInTheDocument()
expect(screen.getAllByText('mozdIT Bt.').length).toBeGreaterThan(0) expect(screen.getAllByText('mozdIT Bt.').length).toBeGreaterThan(0)
// Address is CMS-editable (Payload Common global footer.address) — assert the source value // Address is CMS-editable (common.json footer.address) — assert the source value
expect(screen.getByText(common.footer.address)).toBeInTheDocument() expect(screen.getByText(common.footer.address)).toBeInTheDocument()
}) })
it('should render navigation links', () => { it('should render navigation links', () => {
render(<Footer {...footerProps} />) render(<Footer />)
expect(screen.getByText('Kezdőlap')).toBeInTheDocument() expect(screen.getByText('Kezdőlap')).toBeInTheDocument()
expect(screen.getByText('Rólunk')).toBeInTheDocument() expect(screen.getByText('Rólunk')).toBeInTheDocument()
@@ -47,7 +30,7 @@ describe('Footer', () => {
}) })
it('should render service sections', () => { it('should render service sections', () => {
render(<Footer {...footerProps} />) render(<Footer />)
expect(screen.getByText('Webtárhely (Hosting)')).toBeInTheDocument() expect(screen.getByText('Webtárhely (Hosting)')).toBeInTheDocument()
expect(screen.getByText('E-mail szolgáltatás')).toBeInTheDocument() expect(screen.getByText('E-mail szolgáltatás')).toBeInTheDocument()
@@ -56,21 +39,21 @@ describe('Footer', () => {
}) })
it('should render copyright notice from common.json with the current year', () => { it('should render copyright notice from common.json with the current year', () => {
render(<Footer {...footerProps} />) render(<Footer />)
const currentYear = new Date().getFullYear() const currentYear = new Date().getFullYear()
expect(screen.getByText(`© 2002${currentYear} mozdIT Bt. Minden jog fenntartva.`)).toBeInTheDocument() expect(screen.getByText(`© 2002${currentYear} mozdIT Bt. Minden jog fenntartva.`)).toBeInTheDocument()
}) })
it('should render legal links', () => { it('should render legal links', () => {
render(<Footer {...footerProps} />) render(<Footer />)
expect(screen.getAllByText('Adatvédelmi tájékoztató')).toHaveLength(2) // Appears in both sections expect(screen.getAllByText('Adatvédelmi tájékoztató')).toHaveLength(2) // Appears in both sections
expect(screen.getAllByText('Használati feltételek')).toHaveLength(2) // Appears in both sections expect(screen.getAllByText('Használati feltételek')).toHaveLength(2) // Appears in both sections
}) })
it('should render with proper grid layout', () => { it('should render with proper grid layout', () => {
const { container } = render(<Footer {...footerProps} />) const { container } = render(<Footer />)
const gridContainer = container.querySelector('.grid.grid-cols-1.md\\:grid-cols-4') const gridContainer = container.querySelector('.grid.grid-cols-1.md\\:grid-cols-4')
expect(gridContainer).toBeInTheDocument() expect(gridContainer).toBeInTheDocument()
@@ -80,7 +63,7 @@ describe('Footer', () => {
}) })
it('should render with proper semantic structure', () => { it('should render with proper semantic structure', () => {
const { container } = render(<Footer {...footerProps} />) const { container } = render(<Footer />)
// Should have a footer element // Should have a footer element
const footer = container.firstChild as HTMLElement const footer = container.firstChild as HTMLElement
+15 -36
View File
@@ -1,31 +1,10 @@
'use client' 'use client'
import { siteConfig } from '@/config/site' import { siteConfig } from '@/config/site'
import { content } from '@/content'
import Link from 'next/link' import Link from 'next/link'
import type { NavigationItem } from '@/types/site'
import type { Locale } from '@/lib/i18n'
type FooterServiceItem = { id: string; title: string; icon: string } export default function Footer() {
type FooterProps = {
nav: NavigationItem[]
legalLinks: NavigationItem[]
homeHref: string
description: string
contactEmail: string
footerAddress: string
footerCopyright: string
homeServices: FooterServiceItem[]
locale: Locale
}
const SECTION_LABELS: Record<Locale, { navigation: string; services: string; support: string }> = {
hu: { navigation: 'Navigáció', services: 'Szolgáltatások', support: 'Műszaki támogatás' },
en: { navigation: 'Navigation', services: 'Services', support: 'Technical support' },
}
export default function Footer({ nav, legalLinks, homeHref, description, contactEmail, footerAddress, footerCopyright, homeServices, locale }: FooterProps) {
const t = SECTION_LABELS[locale]
return ( return (
<footer <footer
className="border-t" className="border-t"
@@ -39,7 +18,7 @@ export default function Footer({ nav, legalLinks, homeHref, description, contact
{/* Company Info */} {/* Company Info */}
<div className="md:col-span-2"> <div className="md:col-span-2">
<Link <Link
href={homeHref} href="/"
className="inline-flex items-center gap-2 text-xl font-bold mb-4 transition-colors duration-200" className="inline-flex items-center gap-2 text-xl font-bold mb-4 transition-colors duration-200"
style={{ color: 'var(--color-primary-600)' }} style={{ color: 'var(--color-primary-600)' }}
> >
@@ -53,11 +32,11 @@ export default function Footer({ nav, legalLinks, homeHref, description, contact
className="mb-6 max-w-md leading-relaxed" className="mb-6 max-w-md leading-relaxed"
style={{ color: 'var(--color-foreground-muted)' }} style={{ color: 'var(--color-foreground-muted)' }}
> >
{description} {siteConfig.general.description}
</p> </p>
<div className="space-y-2 text-sm" style={{ color: 'var(--color-foreground-muted)' }}> <div className="space-y-2 text-sm" style={{ color: 'var(--color-foreground-muted)' }}>
<a <a
href={`mailto:${contactEmail}`} href={`mailto:${siteConfig.contact.email}`}
className="flex items-center gap-2 group transition-colors duration-200 hover:text-blue-600" className="flex items-center gap-2 group transition-colors duration-200 hover:text-blue-600"
style={{ color: 'var(--color-foreground-secondary)' }} style={{ color: 'var(--color-foreground-secondary)' }}
> >
@@ -67,7 +46,7 @@ export default function Footer({ nav, legalLinks, homeHref, description, contact
> >
</span> </span>
<span>{contactEmail}</span> <span>{siteConfig.contact.email}</span>
</a> </a>
<div <div
className="flex items-center gap-2" className="flex items-center gap-2"
@@ -79,7 +58,7 @@ export default function Footer({ nav, legalLinks, homeHref, description, contact
> >
🏢 🏢
</span> </span>
<span>{footerAddress}</span> <span>{content.common.footer.address}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -90,10 +69,10 @@ export default function Footer({ nav, legalLinks, homeHref, description, contact
className="text-sm font-semibold uppercase tracking-wider mb-4" className="text-sm font-semibold uppercase tracking-wider mb-4"
style={{ color: 'var(--color-foreground)' }} style={{ color: 'var(--color-foreground)' }}
> >
{t.navigation} Navigáció
</h3> </h3>
<ul className="space-y-3"> <ul className="space-y-3">
{nav.map((item) => ( {siteConfig.navigation.footer.map((item) => (
<li key={item.href}> <li key={item.href}>
<a <a
href={item.href} href={item.href}
@@ -123,10 +102,10 @@ export default function Footer({ nav, legalLinks, homeHref, description, contact
className="text-sm font-semibold uppercase tracking-wider mb-4" className="text-sm font-semibold uppercase tracking-wider mb-4"
style={{ color: 'var(--color-foreground)' }} style={{ color: 'var(--color-foreground)' }}
> >
{t.services} Szolgáltatások
</h3> </h3>
<ul className="space-y-3"> <ul className="space-y-3">
{homeServices.map((service) => ( {content.pages.home.services.items.map((service) => (
<li <li
key={service.id} key={service.id}
className="flex items-center gap-2 text-sm" className="flex items-center gap-2 text-sm"
@@ -141,7 +120,7 @@ export default function Footer({ nav, legalLinks, homeHref, description, contact
style={{ color: 'var(--color-foreground-muted)' }} style={{ color: 'var(--color-foreground-muted)' }}
> >
<span className="text-base">🛠</span> <span className="text-base">🛠</span>
{t.support} Műszaki támogatás
</li> </li>
</ul> </ul>
</div> </div>
@@ -157,11 +136,11 @@ export default function Footer({ nav, legalLinks, homeHref, description, contact
className="text-sm" className="text-sm"
style={{ color: 'var(--color-foreground-muted)' }} style={{ color: 'var(--color-foreground-muted)' }}
> >
{/* Copyright text is CMS-editable (Payload Common global); {year} resolves to the current year */} {/* Copyright text is CMS-editable (common.json); {year} resolves to the current year */}
{footerCopyright.replace('{year}', String(new Date().getFullYear()))} {content.common.footer.copyright.replace('{year}', String(new Date().getFullYear()))}
</p> </p>
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
{legalLinks.map((link) => ( {siteConfig.footer.links.map((link) => (
<a <a
key={link.href} key={link.href}
href={link.href} href={link.href}
+14 -24
View File
@@ -3,7 +3,6 @@ import '@testing-library/jest-dom'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import Header from './Header' import Header from './Header'
import { common } from '@/content' import { common } from '@/content'
import { getMainNavigation } from '@/config/site'
// Mock Next.js Link component // Mock Next.js Link component
jest.mock('next/link', () => { jest.mock('next/link', () => {
@@ -12,23 +11,14 @@ jest.mock('next/link', () => {
) )
}) })
// MITHOME-91/114: Header lett props-alapú (locale-aware nav/a11y a szülő
// [locale] layoutból jön) — a teszt a valódi getMainNavigation('hu')-t adja
// át, hogy a feliratok/hrefek a tényleges alkalmazás-viselkedést tükrözzék.
const headerProps = {
nav: getMainNavigation('hu'),
homeHref: '/hu',
a11y: common.a11y,
}
describe('Header', () => { describe('Header', () => {
it('should render the company logo', () => { it('should render the company logo', () => {
render(<Header {...headerProps} />) render(<Header />)
expect(screen.getByAltText('mozdIT Bt.')).toBeInTheDocument() expect(screen.getByAltText('mozdIT Bt.')).toBeInTheDocument()
}) })
it('should render all navigation links in desktop menu', () => { it('should render all navigation links in desktop menu', () => {
render(<Header {...headerProps} />) render(<Header />)
// Desktop menu should contain all links with specific structures // Desktop menu should contain all links with specific structures
const desktopMenu = document.querySelector('.hidden.md\\:flex') const desktopMenu = document.querySelector('.hidden.md\\:flex')
@@ -42,7 +32,7 @@ describe('Header', () => {
}) })
it('should render contact button with correct styling', () => { it('should render contact button with correct styling', () => {
render(<Header {...headerProps} />) render(<Header />)
const contactButtons = screen.getAllByText('Kapcsolat') const contactButtons = screen.getAllByText('Kapcsolat')
expect(contactButtons.length).toBeGreaterThan(0) expect(contactButtons.length).toBeGreaterThan(0)
@@ -59,7 +49,7 @@ describe('Header', () => {
}) })
it('should render hamburger menu button on mobile', () => { it('should render hamburger menu button on mobile', () => {
render(<Header {...headerProps} />) render(<Header />)
// The hamburger menu button is hidden by default in desktop view // The hamburger menu button is hidden by default in desktop view
// We can test its presence even if not visible // We can test its presence even if not visible
@@ -68,14 +58,14 @@ describe('Header', () => {
}) })
it('should have proper accessibility attributes', () => { it('should have proper accessibility attributes', () => {
render(<Header {...headerProps} />) render(<Header />)
const hamburgerButton = screen.getByRole('button', { name: new RegExp(common.a11y.openMenu, 'i') }) const hamburgerButton = screen.getByRole('button', { name: new RegExp(common.a11y.openMenu, 'i') })
expect(hamburgerButton).toHaveAttribute('aria-expanded', 'false') expect(hamburgerButton).toHaveAttribute('aria-expanded', 'false')
}) })
it('should render with proper semantic structure', () => { it('should render with proper semantic structure', () => {
const { container } = render(<Header {...headerProps} />) const { container } = render(<Header />)
// Should have header element with proper structure // Should have header element with proper structure
const header = container.firstChild as HTMLElement const header = container.firstChild as HTMLElement
@@ -91,7 +81,7 @@ describe('Header', () => {
it('should toggle mobile menu when hamburger button is clicked', async () => { it('should toggle mobile menu when hamburger button is clicked', async () => {
const user = userEvent.setup() const user = userEvent.setup()
render(<Header {...headerProps} />) render(<Header />)
const hamburgerButton = screen.getByRole('button', { name: new RegExp(common.a11y.openMenu, 'i') }) const hamburgerButton = screen.getByRole('button', { name: new RegExp(common.a11y.openMenu, 'i') })
@@ -109,7 +99,7 @@ describe('Header', () => {
it('should close mobile menu when navigation link is clicked', async () => { it('should close mobile menu when navigation link is clicked', async () => {
const user = userEvent.setup() const user = userEvent.setup()
render(<Header {...headerProps} />) render(<Header />)
const hamburgerButton = screen.getByRole('button', { name: new RegExp(common.a11y.openMenu, 'i') }) const hamburgerButton = screen.getByRole('button', { name: new RegExp(common.a11y.openMenu, 'i') })
@@ -130,31 +120,31 @@ describe('Header', () => {
}) })
it('should have correct navigation links with proper hrefs', () => { it('should have correct navigation links with proper hrefs', () => {
render(<Header {...headerProps} />) render(<Header />)
// Check for home link // Check for home link
const homeLinks = screen.getAllByText('Kezdőlap') const homeLinks = screen.getAllByText('Kezdőlap')
expect(homeLinks.length).toBeGreaterThan(0) expect(homeLinks.length).toBeGreaterThan(0)
expect(homeLinks[0].closest('a')).toHaveAttribute('href', '/hu') expect(homeLinks[0].closest('a')).toHaveAttribute('href', '/')
// Check for about link // Check for about link
const aboutLinks = screen.getAllByText('Rólunk') const aboutLinks = screen.getAllByText('Rólunk')
expect(aboutLinks.length).toBeGreaterThan(0) expect(aboutLinks.length).toBeGreaterThan(0)
expect(aboutLinks[0].closest('a')).toHaveAttribute('href', '/hu/rolunk') expect(aboutLinks[0].closest('a')).toHaveAttribute('href', '/rolunk')
// Check for services link // Check for services link
const servicesLinks = screen.getAllByText('Szolgáltatások') const servicesLinks = screen.getAllByText('Szolgáltatások')
expect(servicesLinks.length).toBeGreaterThan(0) expect(servicesLinks.length).toBeGreaterThan(0)
expect(servicesLinks[0].closest('a')).toHaveAttribute('href', '/hu/szolgaltatasok') expect(servicesLinks[0].closest('a')).toHaveAttribute('href', '/szolgaltatasok')
// Check for contact link // Check for contact link
const contactLinks = screen.getAllByText('Kapcsolat') const contactLinks = screen.getAllByText('Kapcsolat')
expect(contactLinks.length).toBeGreaterThan(0) expect(contactLinks.length).toBeGreaterThan(0)
expect(contactLinks[0].closest('a')).toHaveAttribute('href', '/hu/kapcsolat') expect(contactLinks[0].closest('a')).toHaveAttribute('href', '/kapcsolat')
}) })
it('should have proper responsive classes', () => { it('should have proper responsive classes', () => {
const { container } = render(<Header {...headerProps} />) const { container } = render(<Header />)
// Desktop menu should be hidden on mobile // Desktop menu should be hidden on mobile
const desktopMenu = container.querySelector('.hidden.md\\:flex') const desktopMenu = container.querySelector('.hidden.md\\:flex')
+6 -12
View File
@@ -1,19 +1,13 @@
'use client' 'use client'
import { siteConfig } from '@/config/site' import { siteConfig } from '@/config/site'
import { common } from '@/content'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { ThemeToggle } from './ThemeProvider' import { ThemeToggle } from './ThemeProvider'
import Link from 'next/link' import Link from 'next/link'
import Image from 'next/image' import Image from 'next/image'
import type { NavigationItem } from '@/types/site'
type HeaderProps = { export default function Header() {
nav: NavigationItem[]
homeHref: string
a11y: { openMenu: string; closeMenu: string }
}
export default function Header({ nav, homeHref, a11y }: HeaderProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false) const [isMenuOpen, setIsMenuOpen] = useState(false)
const [isScrolled, setIsScrolled] = useState(false) const [isScrolled, setIsScrolled] = useState(false)
@@ -55,7 +49,7 @@ export default function Header({ nav, homeHref, a11y }: HeaderProps) {
{/* Logo */} {/* Logo */}
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<Link <Link
href={homeHref} href="/"
className="group flex items-center gap-2 transition-all duration-200" className="group flex items-center gap-2 transition-all duration-200"
> >
<Image <Image
@@ -70,7 +64,7 @@ export default function Header({ nav, homeHref, a11y }: HeaderProps) {
{/* Desktop Navigation */} {/* Desktop Navigation */}
<div className="hidden md:flex items-center space-x-1"> <div className="hidden md:flex items-center space-x-1">
{nav.map((item) => ( {siteConfig.navigation.main.map((item) => (
<a <a
key={item.href} key={item.href}
href={item.href} href={item.href}
@@ -121,7 +115,7 @@ export default function Header({ nav, homeHref, a11y }: HeaderProps) {
}} }}
aria-expanded={isMenuOpen} aria-expanded={isMenuOpen}
> >
<span className="sr-only">{isMenuOpen ? a11y.closeMenu : a11y.openMenu}</span> <span className="sr-only">{isMenuOpen ? common.a11y.closeMenu : common.a11y.openMenu}</span>
<div className="relative w-6 h-6"> <div className="relative w-6 h-6">
{/* Hamburger to X animation */} {/* Hamburger to X animation */}
<span <span
@@ -157,7 +151,7 @@ export default function Header({ nav, homeHref, a11y }: HeaderProps) {
className="py-3 space-y-1 border-t" className="py-3 space-y-1 border-t"
style={{ borderColor: 'var(--color-border)' }} style={{ borderColor: 'var(--color-border)' }}
> >
{nav.map((item, index) => ( {siteConfig.navigation.main.map((item, index) => (
<a <a
key={item.href} key={item.href}
href={item.href} href={item.href}
-9
View File
@@ -38,13 +38,6 @@ export function ThemeProvider({ children, defaultTheme = 'system' }: ThemeProvid
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
useEffect(() => { useEffect(() => {
// WHY: this effect synchronizes React state with two external systems —
// the DOM (hydration-safe mount flag) and localStorage (persisted theme
// preference) — read once on mount. There is no subscription to wrap the
// setState calls in, so the new react-hooks/set-state-in-effect rule
// (added with the Next.js 16 / eslint-config-next upgrade) is a false
// positive here; suppress rather than restructure a working component.
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true) setMounted(true)
const savedTheme = localStorage.getItem('theme') as Theme | null const savedTheme = localStorage.getItem('theme') as Theme | null
if (savedTheme) { if (savedTheme) {
@@ -66,8 +59,6 @@ export function ThemeProvider({ children, defaultTheme = 'system' }: ThemeProvid
root.setAttribute('data-theme', theme) root.setAttribute('data-theme', theme)
} }
// WHY: syncs React state with the resolved DOM/OS theme — see note above.
// eslint-disable-next-line react-hooks/set-state-in-effect
setResolvedTheme(resolved) setResolvedTheme(resolved)
}, [theme, mounted]) }, [theme, mounted])
-308
View File
@@ -1,308 +0,0 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { siteConfig } from '@/config/site'
/**
* MITHOME-120 — teljes szöveges gyorskeresés a Payload admin tetején.
*
* WHY kliens-oldali, egyszerű megoldás és nem `@payloadcms/plugin-search`:
* az a plugin egy külön "search" collection-t tart karban hookokkal
* szinkronban — szerver-oldali index, extra karbantartási teher. Ennek a
* projektnek 5 Global + 2 kis Collection a teljes tartalma (lásd
* payload.config.ts) — ennyi dokumentumnál egyszerűbb és megbízhatóbb
* minden alkalommal frissen lekérdezni a REST API-t (a bejelentkezett admin
* session-jével, cookie-alapú auth, nincs külön hitelesítési logika itt),
* kliens-oldalon szöveges mezőkre lapítani, és substring-alapján szűrni.
*
* Regisztrálva: payload.config.ts `admin.components.header` — minden admin
* oldalon megjelenik. Az importMap.js-t a `payload generate:importmap`
* generálja újra, ha ez a fájl elmozdul/átnevezik.
*
* A keresés mellett egy "Honlap megnyitása" link is itt kapott helyet
* (felhasználói kérés) — korábban semmilyen link nem vezetett az admin
* felületről a publikus oldalra. `siteConfig.general.url` a
* NEXT_PUBLIC_SITE_URL-ből jön, tehát környezetenként (dev/staging/prod)
* automatikusan a helyes címre mutat.
*/
type Locale = 'hu' | 'en'
const LOCALES: Locale[] = ['hu', 'en']
type SearchTarget =
| { type: 'global'; slug: string; label: string }
| { type: 'collection'; slug: string; label: string }
// A projekt tényleges Globals/Collections listája (payload.config.ts) —
// szándékosan nincs dinamikusan introspektálva, mert ahhoz szerver-oldali
// config-hozzáférés kellene ebből a kliens komponensből.
const SEARCH_TARGETS: SearchTarget[] = [
{ type: 'global', slug: 'home', label: 'Home' },
{ type: 'global', slug: 'about', label: 'About' },
{ type: 'global', slug: 'services', label: 'Services' },
{ type: 'global', slug: 'contact', label: 'Contact' },
{ type: 'global', slug: 'common', label: 'Common' },
{ type: 'collection', slug: 'legal-pages', label: 'Legal Pages' },
{ type: 'collection', slug: 'partners', label: 'Partners' },
]
// Payload belső/rendszer mezői — nem érdekesek szöveges keresésre, és csak
// zajt jelentenének (id-k, időbélyegek, belső flag-ek).
const SKIP_KEYS = new Set([
'id',
'_id',
'createdAt',
'updatedAt',
'globalType',
'blockType',
'_status',
'sizes',
])
type SearchEntry = {
key: string
source: string
editHref: string
locale: Locale
fieldPath: string
value: string
}
function flatten(
value: unknown,
path: string,
out: { fieldPath: string; value: string }[]
): void {
if (value == null) return
if (typeof value === 'string') {
if (value.trim().length > 0) out.push({ fieldPath: path, value })
return
}
if (typeof value === 'number' || typeof value === 'boolean') return
if (Array.isArray(value)) {
value.forEach((item, index) => flatten(item, `${path}[${index}]`, out))
return
}
if (typeof value === 'object') {
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
if (SKIP_KEYS.has(key)) continue
flatten(val, path ? `${path}.${key}` : key, out)
}
}
}
async function fetchIndex(): Promise<SearchEntry[]> {
const entries: SearchEntry[] = []
await Promise.all(
SEARCH_TARGETS.flatMap((target) =>
LOCALES.map(async (locale) => {
try {
if (target.type === 'global') {
const res = await fetch(`/api/globals/${target.slug}?locale=${locale}&depth=0`, {
credentials: 'include',
})
if (!res.ok) return
const doc = await res.json()
const flat: { fieldPath: string; value: string }[] = []
flatten(doc, '', flat)
for (const f of flat) {
entries.push({
key: `${target.slug}:${locale}:${f.fieldPath}`,
source: target.label,
editHref: `/admin/globals/${target.slug}`,
locale,
fieldPath: f.fieldPath,
value: f.value,
})
}
} else {
const res = await fetch(
`/api/${target.slug}?locale=${locale}&depth=0&limit=200`,
{ credentials: 'include' }
)
if (!res.ok) return
const { docs } = await res.json()
for (const doc of docs ?? []) {
const flat: { fieldPath: string; value: string }[] = []
flatten(doc, '', flat)
for (const f of flat) {
entries.push({
key: `${target.slug}:${doc.id}:${locale}:${f.fieldPath}`,
source: `${target.label}${doc.name ?? doc.title ?? doc.id}`,
editHref: `/admin/collections/${target.slug}/${doc.id}`,
locale,
fieldPath: f.fieldPath,
value: f.value,
})
}
}
}
} catch {
// Egy célpont hibája (pl. időleges hálózati hiba) ne akassza meg a
// többi találatot — csendben kihagyjuk.
}
})
)
)
return entries
}
function snippetAround(value: string, query: string, radius = 40): string {
const idx = value.toLowerCase().indexOf(query.toLowerCase())
if (idx === -1) return value.length > 80 ? `${value.slice(0, 80)}` : value
const start = Math.max(0, idx - radius)
const end = Math.min(value.length, idx + query.length + radius)
const prefix = start > 0 ? '…' : ''
const suffix = end < value.length ? '…' : ''
return `${prefix}${value.slice(start, end)}${suffix}`
}
export function QuickSearch() {
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
// WHY state és nem ref: a keresési index közvetlenül a render kimenetét
// (a találati listát) befolyásolja, tehát a react-hooks/refs szabály
// szerint is state-nek kell lennie, nem ref-nek (ref olvasása render
// közben nem váltana ki újra-renderelést, ha közben módosulna).
const [index, setIndex] = useState<SearchEntry[] | null>(null)
// Csak azt jelzi, hogy a fetch elindult-e már — ez NEM befolyásolja a
// render kimenetét, csak elkerüli a duplikált egyidejű lekérdezést, ezért
// maradhat ref.
const fetchStartedRef = useRef(false)
const containerRef = useRef<HTMLDivElement>(null)
const ensureIndex = useCallback(async () => {
if (fetchStartedRef.current) return
fetchStartedRef.current = true
setLoading(true)
try {
const entries = await fetchIndex()
setIndex(entries)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
function onClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', onClickOutside)
return () => document.removeEventListener('mousedown', onClickOutside)
}, [])
const results =
query.trim().length >= 2 && index
? index.filter((e) => e.value.toLowerCase().includes(query.toLowerCase())).slice(0, 40)
: []
return (
<div
ref={containerRef}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
maxWidth: 620,
margin: '0 auto',
padding: '10px 16px',
}}
>
<div style={{ position: 'relative', flex: 1 }}>
<input
type="search"
value={query}
placeholder="🔍 Keresés a teljes tartalomban… (min. 2 karakter)"
onFocus={() => {
setOpen(true)
void ensureIndex()
}}
onChange={(e) => {
setQuery(e.target.value)
setOpen(true)
void ensureIndex()
}}
style={{
width: '100%',
boxSizing: 'border-box',
padding: '8px 12px',
fontSize: 14,
borderRadius: 4,
border: '1px solid var(--theme-elevation-150, #ccc)',
background: 'var(--theme-input-bg, #fff)',
color: 'var(--theme-text, #000)',
}}
/>
{open && query.trim().length >= 2 && (
<div
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
marginTop: 4,
maxHeight: 420,
overflowY: 'auto',
background: 'var(--theme-elevation-0, #fff)',
border: '1px solid var(--theme-elevation-150, #ccc)',
borderRadius: 4,
boxShadow: '0 4px 16px rgba(0,0,0,0.15)',
zIndex: 100,
}}
>
{loading && !index && (
<div style={{ padding: 12, fontSize: 13, opacity: 0.7 }}>Tartalom betöltése</div>
)}
{index && results.length === 0 && (
<div style={{ padding: 12, fontSize: 13, opacity: 0.7 }}>Nincs találat.</div>
)}
{results.map((r) => (
<Link
key={r.key}
href={r.editHref}
onClick={() => setOpen(false)}
style={{
display: 'block',
padding: '8px 12px',
borderBottom: '1px solid var(--theme-elevation-100, #eee)',
textDecoration: 'none',
color: 'inherit',
}}
>
<div style={{ fontSize: 12, opacity: 0.65 }}>
{r.source} · {r.fieldPath} · {r.locale}
</div>
<div style={{ fontSize: 14 }}>{snippetAround(r.value, query)}</div>
</Link>
))}
</div>
)}
</div>
<a
href={siteConfig.general.url}
target="_blank"
rel="noopener noreferrer"
style={{
flexShrink: 0,
padding: '8px 12px',
fontSize: 14,
borderRadius: 4,
border: '1px solid var(--theme-elevation-150, #ccc)',
color: 'var(--theme-text, #000)',
textDecoration: 'none',
whiteSpace: 'nowrap',
}}
>
🌐 Honlap megnyitása
</a>
</div>
)
}
export default QuickSearch
+36 -62
View File
@@ -1,73 +1,47 @@
import type { NavigationItem } from '@/types/site' import { SiteConfig } from '@/types/site'
import { localePath, type Locale } from '@/lib/i18n'
/** /**
* Site Configuration - statikus, nyelvfüggetlen alapadatok. * Site Configuration - Centralized configuration for all public content
* * All content is easily modifiable without code changes
* MITHOME-91/114: a navigáció és a lábláb jogi linkjei nyelvfüggővé váltak * This structure supports easy expansion for CMS integration later
* (a szlögök nyelvenként eltérnek — lásd src/lib/i18n.ts PAGE_SLUGS), ezért
* ezek most függvények, nem statikus tömbök. A navigáció-feliratok itt
* maradnak (nem Payload-tartalom) — ez sosem volt része a JSON content
* rendszernek, csak ez a config fájl, ezért a MITHOME-91 hatóköre ("JSON
* content rendszer kivezetése") nem érinti; a hu/en feliratpárok itt kézzel
* tartott, statikus fordítások.
*/ */
export const siteConfig = { export const siteConfig: SiteConfig = {
general: { general: {
name: 'mozdIT Bt.', name: 'mozdIT Bt.',
description: 'Megbízható web- és email szolgáltatás személyre szabott támogatással. Stabil tárhely, üzembiztos levelezés és DNS adminisztráció gyors reakcióval.',
url: process.env.NEXT_PUBLIC_SITE_URL || 'https://localhost:3000', url: process.env.NEXT_PUBLIC_SITE_URL || 'https://localhost:3000',
ogImage: '/mozdit_logo_text.png', ogImage: '/mozdit_logo_text.png',
locale: 'hu-HU'
}, },
navigation: {
main: [
{ label: 'Kezdőlap', href: '/' },
{ label: 'Rólunk', href: '/rolunk' },
{ label: 'Szolgáltatások', href: '/szolgaltatasok' },
{ label: 'Kapcsolat', href: '/kapcsolat', primary: true }
],
footer: [
{ label: 'Kezdőlap', href: '/' },
{ label: 'Rólunk', href: '/rolunk' },
{ label: 'Szolgáltatások', href: '/szolgaltatasok' },
{ label: 'Kapcsolat', href: '/kapcsolat' },
{ label: 'Adatvédelmi tájékoztató', href: '/adatvedelem' },
{ label: 'Használati feltételek', href: '/felhasznalasi-feltetelek' }
]
},
footer: {
// Copyright text lives in content/common.json (CMS-editable) — only links remain here.
links: [
{ label: 'Adatvédelmi tájékoztató', href: '/adatvedelem' },
{ label: 'Használati feltételek', href: '/felhasznalasi-feltetelek' }
]
},
contact: { contact: {
email: process.env.NEXT_PUBLIC_CONTACT_EMAIL || 'info@mozdit.hu', email: process.env.NEXT_PUBLIC_CONTACT_EMAIL || 'info@mozdit.hu',
// address lives in the Payload Common global (footer.address) — CMS-editable. // address lives in content/common.json (footer.address) — CMS-editable.
}, // The form fields live in content/pages/contact.json.
} }
const DESCRIPTIONS: Record<Locale, string> = {
hu: 'Megbízható web- és email szolgáltatás személyre szabott támogatással. Stabil tárhely, üzembiztos levelezés és DNS adminisztráció gyors reakcióval.',
en: 'Reliable web hosting and business email with personal support. Stable hosting, dependable mail delivery and DNS administration with a fast response.',
}
export function getSiteDescription(locale: Locale): string {
return DESCRIPTIONS[locale]
}
export function getOgLocale(locale: Locale): string {
return locale === 'hu' ? 'hu-HU' : 'en-US'
}
const NAV_LABELS: Record<Locale, { home: string; about: string; services: string; contact: string; privacy: string; terms: string }> = {
hu: { home: 'Kezdőlap', about: 'Rólunk', services: 'Szolgáltatások', contact: 'Kapcsolat', privacy: 'Adatvédelmi tájékoztató', terms: 'Használati feltételek' },
en: { home: 'Home', about: 'About', services: 'Services', contact: 'Contact', privacy: 'Privacy Policy', terms: 'Terms of Service' },
}
export function getMainNavigation(locale: Locale): NavigationItem[] {
const t = NAV_LABELS[locale]
return [
{ label: t.home, href: localePath(locale) },
{ label: t.about, href: localePath(locale, 'about') },
{ label: t.services, href: localePath(locale, 'services') },
{ label: t.contact, href: localePath(locale, 'contact'), primary: true },
]
}
export function getFooterNavigation(locale: Locale): NavigationItem[] {
const t = NAV_LABELS[locale]
return [
{ label: t.home, href: localePath(locale) },
{ label: t.about, href: localePath(locale, 'about') },
{ label: t.services, href: localePath(locale, 'services') },
{ label: t.contact, href: localePath(locale, 'contact') },
{ label: t.privacy, href: localePath(locale, 'privacy') },
{ label: t.terms, href: localePath(locale, 'terms') },
]
}
export function getFooterLegalLinks(locale: Locale): NavigationItem[] {
const t = NAV_LABELS[locale]
return [
{ label: t.privacy, href: localePath(locale, 'privacy') },
{ label: t.terms, href: localePath(locale, 'terms') },
]
} }
+1 -1
View File
@@ -35,7 +35,7 @@
"errorMinLength": "Az üzenet legalább 10 karakter hosszú legyen" "errorMinLength": "Az üzenet legalább 10 karakter hosszú legyen"
}, },
"gdpr": { "gdpr": {
"label": "Elfogadom az <a href=\"{privacyHref}\" class=\"text-blue-600 hover:text-blue-700 underline\">adatkezelési tájékoztatót</a> és hozzájárulok személyes adataim kezeléséhez a kapcsolatfelvétel céljából.", "label": "Elfogadom az <a href=\"/adatkezelesi-tajekoztato\" class=\"text-blue-600 hover:text-blue-700 underline\">adatkezelési tájékoztatót</a> és hozzájárulok személyes adataim kezeléséhez a kapcsolatfelvétel céljából.",
"error": "Az adatkezelési tájékoztató elfogadása kötelező" "error": "Az adatkezelési tájékoztató elfogadása kötelező"
} }
}, },
+2 -6
View File
@@ -1,9 +1,5 @@
// Shared runtime schema for the content JSON files (proto/src/content/*.json). // Shared runtime schema for the content JSON files.
// Kept dependency-free — used by src/content/index.ts (test fixtures for // Kept dependency-free so it can run in both Next.js and content-editor.js.
// Header/Footer, MITHOME-96) and scripts/test-content-schema.js. The JSON
// files themselves remain the source for scripts/migrate-content-to-payload.ts.
// The standalone content-editor.js CMS that used to run this too was retired
// in MITHOME-93 (superseded by Payload CMS).
const string = { type: 'string' }; const string = { type: 'string' };
const boolean = { type: 'boolean' }; const boolean = { type: 'boolean' };
const array = items => ({ type: 'array', items }); const array = items => ({ type: 'array', items });
-75
View File
@@ -1,75 +0,0 @@
import type { GlobalConfig } from 'payload'
import { stringArrayField } from './fields/stringArray'
import { globalVersions } from '../lib/payload-versions'
/** Mirrors AboutPageContent (proto/src/content/types.ts) — MITHOME-87. */
export const About: GlobalConfig = {
slug: 'about',
admin: {
group: 'Oldalak',
},
versions: globalVersions,
fields: [
{
name: 'meta',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'textarea', required: true },
{ name: 'ogDescription', type: 'textarea', required: true },
],
},
{
name: 'hero',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'subtitle', type: 'text', required: true },
],
},
{
name: 'story',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
stringArrayField('paragraphs', 'Paragraphs'),
],
},
{
name: 'mission',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'subtitle', type: 'text', required: true },
{
name: 'values',
type: 'array',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'textarea', required: true },
{ name: 'icon', type: 'text', required: true },
],
},
],
},
{
name: 'team',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'subtitle', type: 'text', required: true },
stringArrayField('paragraphs', 'Paragraphs'),
],
},
{
name: 'cta',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'subtitle', type: 'textarea', required: true },
{ name: 'button', type: 'text', required: true },
],
},
],
}
-84
View File
@@ -1,84 +0,0 @@
import type { GlobalConfig } from 'payload'
import { globalVersions } from '../lib/payload-versions'
/**
* Mirrors CommonContent (proto/src/content/types.ts) — MITHOME-87.
*
* `buttons.*` localized: true (MITHOME-110) — ez a lokalizáció-mechanizmus
* bizonyítéka (admin nyelvváltó + Local/REST API locale param), és egyben
* előremutató, helyes darabja a MITHOME-111 teljes retrofitnak: ezek
* ténylegesen fordítandó UI-feliratok. A többi mező itt még nem lokalizált,
* azt a MITHOME-111 teljes körűen elvégzi.
*/
export const Common: GlobalConfig = {
slug: 'common',
admin: {
group: 'Oldalak',
},
versions: globalVersions,
fields: [
{
name: 'buttons',
type: 'group',
fields: [
{ name: 'contact', type: 'text', required: true, localized: true },
{ name: 'learnMore', type: 'text', required: true, localized: true },
{ name: 'webmail', type: 'text', required: true, localized: true },
{ name: 'sendMessage', type: 'text', required: true, localized: true },
],
},
{
name: 'labels',
type: 'group',
fields: [
{ name: 'required', type: 'text', required: true },
{ name: 'features', type: 'text', required: true },
],
},
{
name: 'validation',
type: 'group',
fields: [
{ name: 'required', type: 'text', required: true },
{ name: 'invalidEmail', type: 'text', required: true },
{
name: 'minLength',
type: 'text',
required: true,
admin: {
description: 'A {min} placeholder futásidőben cserélődik ki.',
},
},
],
},
{
name: 'staging',
type: 'group',
fields: [{ name: 'banner', type: 'text', required: true }],
},
{
name: 'footer',
type: 'group',
fields: [
{
name: 'copyright',
type: 'text',
required: true,
admin: {
description: 'A {year} placeholder futásidőben cserélődik ki.',
},
},
{ name: 'address', type: 'text', required: true },
],
},
{
name: 'a11y',
type: 'group',
fields: [
{ name: 'openMenu', type: 'text', required: true },
{ name: 'closeMenu', type: 'text', required: true },
],
},
],
}
-145
View File
@@ -1,145 +0,0 @@
import type { GlobalConfig } from 'payload'
import { globalVersions } from '../lib/payload-versions'
/**
* Mirrors ContactPageContent (proto/src/content/types.ts) — MITHOME-87.
*
* WHY `gdpr.label` textarea és nem richText: a JSON forrás egy kézzel
* beírt <a> taget tartalmazó HTML string (lásd contact.json). A lexical
* richText mező más (JSON node-fa) szerializációt használna, ami extra
* migrációs/render logikát igényelne — ezt itt, bootstrap szinten nem
* bontjuk ki, marad egyszerű (HTML-t tartalmazó) szöveg mező.
*/
export const Contact: GlobalConfig = {
slug: 'contact',
admin: {
group: 'Oldalak',
},
versions: globalVersions,
fields: [
{
name: 'meta',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'textarea', required: true },
],
},
{
name: 'hero',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'subtitle', type: 'text', required: true },
],
},
{
name: 'form',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'successMessage', type: 'text', required: true },
{ name: 'errorMessage', type: 'text', required: true },
{
name: 'fields',
type: 'group',
fields: [
{
name: 'name',
type: 'group',
fields: [
{ name: 'label', type: 'text', required: true },
{ name: 'placeholder', type: 'text', required: true },
{ name: 'error', type: 'text', required: true },
],
},
{
name: 'email',
type: 'group',
fields: [
{ name: 'label', type: 'text', required: true },
{ name: 'placeholder', type: 'text', required: true },
{ name: 'errorRequired', type: 'text', required: true },
{ name: 'errorInvalid', type: 'text', required: true },
],
},
{
name: 'subject',
type: 'group',
fields: [
{ name: 'label', type: 'text', required: true },
{ name: 'placeholder', type: 'text', required: true },
{ name: 'error', type: 'text', required: true },
],
},
{
name: 'message',
type: 'group',
fields: [
{ name: 'label', type: 'text', required: true },
{ name: 'placeholder', type: 'text', required: true },
{ name: 'errorRequired', type: 'text', required: true },
{ name: 'errorMinLength', type: 'text', required: true },
],
},
{
name: 'gdpr',
type: 'group',
fields: [
{ name: 'label', type: 'textarea', required: true },
{ name: 'error', type: 'text', required: true },
],
},
],
},
{ name: 'submitButton', type: 'text', required: true },
{ name: 'submittingButton', type: 'text', required: true },
],
},
{
name: 'info',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{
name: 'email',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'responseTime', type: 'text', required: true },
],
},
{
name: 'company',
type: 'group',
fields: [{ name: 'title', type: 'text', required: true }],
},
{
name: 'webmail',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'linkText', type: 'text', required: true },
{ name: 'subtitle', type: 'text', required: true },
],
},
],
},
{
name: 'faq',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{
name: 'items',
type: 'array',
fields: [
{ name: 'question', type: 'text', required: true },
{ name: 'answer', type: 'textarea', required: true },
],
},
],
},
],
}
-107
View File
@@ -1,107 +0,0 @@
import type { GlobalConfig } from 'payload'
import { stringArrayField } from './fields/stringArray'
import { globalVersions } from '../lib/payload-versions'
/**
* Mirrors HomePageContent (proto/src/content/types.ts) — MITHOME-87.
*
* WHY nincs `partners` mező: a home.partners.items a JSON-ban ma egy
* beágyazott lista, de a Payload oldalon önálló `Partners` collection lesz
* (logó = Media upload reference) — lásd MITHOME-89. Itt szándékosan
* kihagyjuk, nehogy két, egymásnak ellentmondó forrás legyen.
*/
export const Home: GlobalConfig = {
slug: 'home',
admin: {
group: 'Oldalak',
},
versions: globalVersions,
fields: [
{
name: 'hero',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'subtitle', type: 'text', required: true },
{ name: 'description', type: 'textarea', required: true },
stringArrayField('trustBullets', 'Trust bullets'),
{
name: 'cta',
type: 'group',
fields: [
{
name: 'primary',
type: 'group',
fields: [
{ name: 'text', type: 'text', required: true },
{ name: 'href', type: 'text', required: true },
{ name: 'external', type: 'checkbox', defaultValue: false },
],
},
{
name: 'secondary',
type: 'group',
fields: [
{ name: 'text', type: 'text', required: true },
{ name: 'href', type: 'text', required: true },
{ name: 'external', type: 'checkbox', defaultValue: false },
],
},
],
},
],
},
{
name: 'about',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{
name: 'usps',
type: 'array',
fields: [
{ name: 'id', type: 'text', required: true },
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'textarea', required: true },
{ name: 'icon', type: 'text', required: true },
],
},
],
},
{
name: 'services',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'subtitle', type: 'text', required: true },
{
name: 'items',
type: 'array',
fields: [
{ name: 'id', type: 'text', required: true },
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'textarea', required: true },
{ name: 'icon', type: 'text', required: true },
stringArrayField('features', 'Features'),
{ name: 'ctaText', type: 'text', required: true },
],
},
],
},
{
name: 'cta',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'subtitle', type: 'textarea', required: true },
{ name: 'button', type: 'text', required: true },
],
},
{
name: 'serviceFeatures',
type: 'group',
fields: [{ name: 'title', type: 'text', required: true }],
},
],
}
-84
View File
@@ -1,84 +0,0 @@
import type { GlobalConfig } from 'payload'
import { stringArrayField } from './fields/stringArray'
import { globalVersions } from '../lib/payload-versions'
/** Mirrors ServicesPageContent (proto/src/content/types.ts) — MITHOME-87. */
export const Services: GlobalConfig = {
slug: 'services',
admin: {
group: 'Oldalak',
},
versions: globalVersions,
fields: [
{
name: 'meta',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'textarea', required: true },
{ name: 'ogDescription', type: 'textarea', required: true },
],
},
{
name: 'hero',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'subtitle', type: 'text', required: true },
],
},
{
name: 'details',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'subtitle', type: 'text', required: true },
{
name: 'services',
type: 'array',
fields: [
{ name: 'icon', type: 'text', required: true },
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'textarea', required: true },
{
name: 'specs',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
stringArrayField('items', 'Items'),
],
},
],
},
],
},
{
name: 'support',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'subtitle', type: 'text', required: true },
{
name: 'channels',
type: 'array',
fields: [
{ name: 'icon', type: 'text', required: true },
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'text', required: true },
],
},
],
},
{
name: 'cta',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'subtitle', type: 'textarea', required: true },
{ name: 'primaryButton', type: 'text', required: true },
{ name: 'secondaryButton', type: 'text', required: true },
],
},
],
}
-24
View File
@@ -1,24 +0,0 @@
import type { ArrayField } from 'payload'
/**
* Payload has no native "array of plain strings" field — the closest
* built-in shape is an array of one-field objects. Used for every
* string[] in the old content/types.ts (trustBullets, paragraphs,
* features, spec items, …) so the JSON -> Payload migration script has a
* single, consistent shape to transform into.
*/
export function stringArrayField(name: string, label?: string, required = false): ArrayField {
return {
name,
type: 'array',
label,
fields: [
{
name: 'value',
type: 'text',
required: true,
},
],
required,
}
}
-52
View File
@@ -1,52 +0,0 @@
/**
* MITHOME-91/114: locale + útvonal segédfüggvények.
*
* URL-stratégia (2026-09-10, felülírja a korábbi "hu prefix nélkül" döntést):
* mindkét nyelv prefixet kap, szimmetrikusan (`/hu/...`, `/en/...`), a
* szlögök nyelvenként lefordítva (pl. /hu/rolunk vs /en/about). Nincs
* redirect a régi, prefix nélküli URL-ekről — a projekt még nincs
* production-ben (MITHOME-15 backlog).
*/
export const LOCALES = ['hu', 'en'] as const
export type Locale = (typeof LOCALES)[number]
export const DEFAULT_LOCALE: Locale = 'hu'
export function isLocale(value: string): value is Locale {
return (LOCALES as readonly string[]).includes(value)
}
/** A catch-all [slug] route alá tartozó oldalak azonosítói. */
export type PageKey = 'about' | 'services' | 'contact' | 'privacy' | 'terms'
/** Nyelvenkénti szlög minden oldalhoz — a nyelvváltó (MITHOME-115) és a
* sitemap/hreflang (MITHOME-116) is ezt a táblát fogja használni. */
export const PAGE_SLUGS: Record<PageKey, Record<Locale, string>> = {
about: { hu: 'rolunk', en: 'about' },
services: { hu: 'szolgaltatasok', en: 'services' },
contact: { hu: 'kapcsolat', en: 'contact' },
privacy: { hu: 'adatvedelem', en: 'privacy-policy' },
terms: { hu: 'felhasznalasi-feltetelek', en: 'terms-of-service' },
}
/** slug -> PageKey visszakeresés egy adott nyelven belül. */
export function resolvePageKey(locale: Locale, slug: string): PageKey | undefined {
return (Object.keys(PAGE_SLUGS) as PageKey[]).find((key) => PAGE_SLUGS[key][locale] === slug)
}
/** Útvonal a főoldalhoz vagy egy PageKey-hez, adott nyelven. */
export function localePath(locale: Locale, key?: PageKey): string {
if (!key) return `/${locale}`
return `/${locale}/${PAGE_SLUGS[key][locale]}`
}
/**
* Ugyanaz az oldal a másik nyelven — a nyelvváltóhoz (MITHOME-115).
* Ha a jelenlegi útvonal nem ismert PageKey (pl. 404), a másik nyelv
* főoldalára esik vissza.
*/
export function switchLocalePath(currentLocale: Locale, targetLocale: Locale, slug?: string): string {
if (!slug) return localePath(targetLocale)
const key = resolvePageKey(currentLocale, slug)
return localePath(targetLocale, key)
}
+10
View File
@@ -93,6 +93,16 @@ describe('MongoDB Connection (Unit Tests)', () => {
expect(typeof result.collection).toBe('function') expect(typeof result.collection).toBe('function')
}) })
it('should return collection from database', async () => {
const { getCollection } = await import('./mongodb')
const collection = await getCollection('test_collection')
expect(collection).toBeDefined()
expect(mockDb.collection).toHaveBeenCalledWith('test_collection')
expect(typeof collection.findOne).toBe('function')
})
it('should check MongoDB connection successfully', async () => { it('should check MongoDB connection successfully', async () => {
const { checkMongoConnection } = await import('./mongodb') const { checkMongoConnection } = await import('./mongodb')
+6 -7
View File
@@ -1,10 +1,4 @@
import { MongoClient, Db } from 'mongodb' import { MongoClient, Db, Collection, Document } from 'mongodb'
// WHY this file only exposes getDb/checkMongoConnection now: it used to also
// export getCollection(), used by src/app/api/contact/route.ts for a raw,
// Payload-external `contact_submissions` collection. MITHOME-94 moved that
// write to a proper Payload collection (ContactSubmissions) via the Local
// API — getDb() itself stays alive only for /api/health's connectivity check.
const options = { const options = {
maxPoolSize: 10, maxPoolSize: 10,
@@ -56,6 +50,11 @@ export async function getDb(): Promise<Db> {
return client.db(process.env.MONGODB_DB || 'mozdit') return client.db(process.env.MONGODB_DB || 'mozdit')
} }
export async function getCollection<T extends Document = Document>(collectionName: string): Promise<Collection<T>> {
const db = await getDb()
return db.collection<T>(collectionName)
}
// Health check for MongoDB connection // Health check for MongoDB connection
export async function checkMongoConnection(): Promise<boolean> { export async function checkMongoConnection(): Promise<boolean> {
try { try {
-103
View File
@@ -1,103 +0,0 @@
/**
* MITHOME-91: Payload Local API adat-adapter réteg.
*
* WHY adapterek: a Payload Globals/Collections mezői (stringArrayField,
* lásd src/globals/fields/stringArray.ts) `{ value: string }[]` alakban
* tárolják azt, ami a JSON content rendszerben egyszerű `string[]` volt.
* Ezek a getterek visszaadaptálják az eredeti alakra, hogy a page
* komponensek JSX-e (ami a régi content/types.ts formát várja) NE
* változzon — csak az adatforrás.
*/
import { getPayload } from 'payload'
import config from '@payload-config'
import type { Locale } from './i18n'
let cached: ReturnType<typeof getPayload> | undefined
function payloadClient() {
cached ??= getPayload({ config })
return cached
}
function unwrap(items: readonly { value: string }[] | null | undefined): string[] {
return (items ?? []).map((item) => item.value)
}
export async function getCommonContent(locale: Locale) {
const payload = await payloadClient()
return payload.findGlobal({ slug: 'common', locale })
}
export async function getHomeContent(locale: Locale) {
const payload = await payloadClient()
const home = await payload.findGlobal({ slug: 'home', locale })
return {
...home,
hero: { ...home.hero, trustBullets: unwrap(home.hero?.trustBullets) },
services: {
...home.services,
items: (home.services?.items ?? []).map((item) => ({
...item,
features: unwrap(item.features),
})),
},
}
}
export async function getAboutContent(locale: Locale) {
const payload = await payloadClient()
const about = await payload.findGlobal({ slug: 'about', locale })
return {
...about,
story: { ...about.story, paragraphs: unwrap(about.story?.paragraphs) },
team: { ...about.team, paragraphs: unwrap(about.team?.paragraphs) },
}
}
export async function getServicesContent(locale: Locale) {
const payload = await payloadClient()
const services = await payload.findGlobal({ slug: 'services', locale })
return {
...services,
details: {
...services.details,
services: (services.details?.services ?? []).map((service) => ({
...service,
specs: { ...service.specs, items: unwrap(service.specs?.items) },
})),
},
}
}
export async function getContactContent(locale: Locale) {
const payload = await payloadClient()
return payload.findGlobal({ slug: 'contact', locale })
}
export async function getLegalPage(slug: 'adatvedelem' | 'hasznalati-feltetelek', locale: Locale) {
const payload = await payloadClient()
const result = await payload.find({
collection: 'legal-pages',
where: { slug: { equals: slug } },
locale,
limit: 1,
})
return result.docs[0]
}
export type PartnerView = { name: string; url: string; logo: { url: string; alt: string } }
export async function getPartners(): Promise<PartnerView[]> {
const payload = await payloadClient()
const result = await payload.find({ collection: 'partners', limit: 100, sort: 'name' })
return result.docs
.filter((doc) => typeof doc.logo === 'object' && doc.logo?.url)
.map((doc) => ({
name: doc.name,
url: doc.url,
logo: {
url: (doc.logo as { url: string }).url,
alt: (doc.logo as { alt?: string }).alt ?? doc.name,
},
}))
}
-27
View File
@@ -1,27 +0,0 @@
import type { GlobalConfig, CollectionConfig } from 'payload'
/**
* MITHOME-92: közös draft/verziózás beállítás — ez az elődje a régi egyedi
* CMS "Verziók panel"-jének (MITHOME-64: mentéslista, diff-nézet, egy-
* kattintásos visszaállítás). A Payload beépített verziózása/draft-rendszere
* ugyanezt adja natívan (Payload admin "Versions" fül minden dokumentumon),
* nem kellett újraépíteni.
*
* WHY `autosave` nincs bekapcsolva: az ügyfél explicit "Save Draft" /
* "Publish" gombokkal dolgozzon, ne automatikus mentés — ugyanaz a
* szándékos, kézi mentés-modell, mint a régi CMS-nél volt.
*
* WHY a Local API hívásaink (src/lib/payload-content.ts) nem törnek el
* ettől: `findGlobal`/`find` alapból a PUBLISHED verziót adja vissza, amíg
* explicit `draft: true` paramétert nem adunk át — ezt sehol nem tesszük,
* így a publikus oldal renderelése változatlan marad.
*/
export const globalVersions: GlobalConfig['versions'] = {
drafts: true,
max: 50,
}
export const collectionVersions: CollectionConfig['versions'] = {
drafts: true,
maxPerDoc: 50,
}
-145
View File
@@ -1,145 +0,0 @@
/**
* MITHOME-96 — Payload collection/global config tesztek.
*
* WHY nem a teljes payload.config.ts-t importáljuk: az a valódi `payload` és
* `@payloadcms/db-mongodb` csomagokat importálja értékként (nem csak
* típusként), amik ESM-only, natív node_modules-forrást futtatnak — Jest
* (a next/jest transzformmal is) nem tudja lefordítani őket anélkül, hogy a
* teljes payload-függőségi fát is a transformIgnorePatterns kivételévé
* tennénk (próbáltuk: `SyntaxError: Cannot use import statement outside a
* module` a payload csomag saját forrásából). Az egyes Collection/Global
* fájlok viszont csak `import type` formában hivatkoznak a `payload`
* csomagra — ez típus-only import, a build kitörli, így ezek a fájlok
* önmagukban, gyors unit tesztként importálhatók, élő MongoDB/PAYLOAD_SECRET
* nélkül. Ez fedi le a ticket "Payload config tesztek" részét; a Local API
* ellen futó, élő adatbázist igénylő tesztek külön (integrációs) fájlban
* vannak — lásd src/__tests__/payload-local-api.test.ts.
*
* Ezek a tesztek szándékosan regresszió-őrök is: mindkettő egy-egy valódi,
* élesben megtalált hibát rögzít (MITHOME-121: Media publikus olvasása,
* Partners.logo opcionalitása), hogy soha többé ne térjenek vissza észrevétlenül.
*/
import { Users } from './collections/Users'
import { LegalPages } from './collections/LegalPages'
import { Media } from './collections/Media'
import { Partners } from './collections/Partners'
import { ContactSubmissions } from './collections/ContactSubmissions'
import { Home } from './globals/Home'
import { About } from './globals/About'
import { Services } from './globals/Services'
import { Contact } from './globals/Contact'
import { Common } from './globals/Common'
type FieldLike = { name?: string; required?: boolean; type?: string }
function findField(fields: unknown[], name: string): FieldLike | undefined {
return (fields as FieldLike[]).find((f) => f.name === name)
}
describe('Payload collections', () => {
it('all collection slugs are present and unique', () => {
const slugs = [Users, LegalPages, Media, Partners, ContactSubmissions].map((c) => c.slug)
expect(slugs).toEqual(['users', 'legal-pages', 'media', 'partners', 'contact-submissions'])
expect(new Set(slugs).size).toBe(slugs.length)
})
describe('Media', () => {
// Regresszió-őr — MITHOME-121: a Media collection read access-e sokáig
// (MITHOME-89 óta) alapértelmezetten csak bejelentkezett usernek volt
// engedélyezett, emiatt a partner logók sosem töltődtek be a publikus
// oldalon (403 -> a Next.js image-optimizer "nem érvényes kép" hibája).
it('allows public (unauthenticated) read access', () => {
expect(Media.access?.read).toBeDefined()
const result = Media.access!.read!({ req: { user: null } } as never)
expect(result).toBe(true)
})
it('only accepts image uploads', () => {
expect(Media.upload).toEqual(expect.objectContaining({ mimeTypes: ['image/*'] }))
})
})
describe('Partners', () => {
it('requires name and url', () => {
expect(findField(Partners.fields, 'name')?.required).toBe(true)
expect(findField(Partners.fields, 'url')?.required).toBe(true)
})
// Regresszió-őr — MITHOME-121: a felhasználó kérésére a logo mező
// opcionálissá vált, hogy egy partner logó nélkül is menthető legyen.
it('does not require a logo', () => {
const logo = findField(Partners.fields, 'logo')
expect(logo).toBeDefined()
expect(logo?.required).not.toBe(true)
})
})
describe('ContactSubmissions', () => {
// WHY nincs egyedi access blokk itt tesztelve mint "hiányzik": a Payload
// defaultAccess (csak bejelentkezett user) itt a kívánt viselkedés —
// lásd a collection saját WHY-kommentjét. Nincs mit tesztelni rajta
// (nincs felülírás), de a mezőszerkezetet igen.
it('requires the core submission fields', () => {
for (const name of ['name', 'email', 'subject', 'message', 'gdprConsent']) {
expect(findField(ContactSubmissions.fields, name)?.required).toBe(true)
}
})
it('defaults status to "new"', () => {
const status = findField(ContactSubmissions.fields, 'status') as FieldLike & {
defaultValue?: string
options?: { value: string }[]
}
expect(status?.defaultValue).toBe('new')
expect(status?.options?.map((o) => o.value)).toEqual(['new', 'read', 'replied', 'archived'])
})
})
describe('LegalPages', () => {
it('has a required, unique slug field', () => {
const slug = findField(LegalPages.fields, 'slug') as FieldLike & { unique?: boolean }
expect(slug?.required).toBe(true)
expect(slug?.unique).toBe(true)
})
})
describe('Users', () => {
it('locks accounts after 5 failed attempts for 10 minutes', () => {
expect(Users.auth).toEqual(
expect.objectContaining({ maxLoginAttempts: 5, lockTime: 10 * 60 * 1000 })
)
})
it('requires an authenticated user for every access-controlled operation', () => {
const access = Users.access!
for (const op of ['create', 'read', 'update', 'delete', 'unlock'] as const) {
expect(access[op]!({ req: { user: null } } as never)).toBe(false)
expect(access[op]!({ req: { user: {} } } as never)).toBe(true)
}
})
})
})
describe('Payload globals', () => {
it('all global slugs are present and unique', () => {
const slugs = [Home, About, Services, Contact, Common].map((g) => g.slug)
expect(slugs).toEqual(['home', 'about', 'services', 'contact', 'common'])
expect(new Set(slugs).size).toBe(slugs.length)
})
it('Home does not duplicate the Partners collection', () => {
// MITHOME-89 döntés: a partnerek önálló collection-ök, a Home global
// szándékosan nem tartalmaz "partners" mezőt.
expect(findField(Home.fields, 'partners')).toBeUndefined()
})
it('Common.buttons.* fields are localized (MITHOME-110 bizonyíték)', () => {
const buttonsGroup = findField(Common.fields, 'buttons') as FieldLike & {
fields?: (FieldLike & { localized?: boolean })[]
}
expect(buttonsGroup?.fields?.length).toBeGreaterThan(0)
for (const field of buttonsGroup!.fields!) {
expect(field.localized).toBe(true)
}
})
})
-92
View File
@@ -1,92 +0,0 @@
/**
* Payload CMS configuration (MITHOME-86 — alapinstalláció).
*
* WHY a saját MongoDB kapcsolatot használjuk: a projekt már MongoDB-t futtat
* (proto/src/lib/mongodb.ts) — Payload a `mongooseAdapter`-en keresztül
* ugyanabba az adatbázisba ír, nincs szükség külön DB-re/szerverre.
*
* MITHOME-87: Home/About/Services/Contact/Common Globals hozzáadva — ezek
* tükrözik a proto/src/content/pages/*.json + common.json struktúráját.
* MITHOME-88: LegalPages collection hozzáadva (adatvedelem, hasznalati-
* feltetelek).
* MITHOME-89: Partners + Media collection (MVP — feltöltés, a vágás/
* áttetszővé tétel szerkesztő külön, MITHOME-118).
* MITHOME-110: lokalizáció bekapcsolva (hu alapértelmezett, en). A mezőnkénti
* `localized: true` retrofit a Globals/LegalPages configokon külön ticket
* (MITHOME-111/112) — ez a ticket csak magát a mechanizmust kapcsolja be.
* MITHOME-120: admin gyorskeresés (QuickSearch) az admin.components.header
* slotba regisztrálva — teljes szöveges keresés minden Global/Collection
* mezőjében, mindkét locale-ban.
* MITHOME-94: ContactSubmissions collection — a /api/contact korábban egy
* nyers, Payload-on kívüli Mongo collection-be (`contact_submissions`,
* proto/src/lib/mongodb.ts) írt, amit az ügyfél nem látott sehol az admin
* felületen. Mostantól Payload collection, a route.ts a Local API-n ír bele.
*/
import path from 'path'
import { fileURLToPath } from 'url'
import { buildConfig } from 'payload'
import { mongooseAdapter } from '@payloadcms/db-mongodb'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import sharp from 'sharp'
import { Users } from './collections/Users'
import { LegalPages } from './collections/LegalPages'
import { Media } from './collections/Media'
import { Partners } from './collections/Partners'
import { ContactSubmissions } from './collections/ContactSubmissions'
import { Home } from './globals/Home'
import { About } from './globals/About'
import { Services } from './globals/Services'
import { Contact } from './globals/Contact'
import { Common } from './globals/Common'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfig({
// Admin felület — bejelentkezés az Users collection-nel. Access control /
// jelszó-politika: MITHOME-90 (src/collections/Users.ts).
admin: {
user: Users.slug,
components: {
// MITHOME-120: gyorskeresés minden admin oldal tetején.
// WHY 'src/...' és nem './components/...': a Payload komponens-útvonal
// az admin.importMap.baseDir-hez relatív, ami alapértelmezésben
// process.cwd() (a `generate:importmap` a proto/ mappából fut, NEM a
// payload.config.ts mappájából).
header: ['./src/components/admin/QuickSearch#QuickSearch'],
},
},
editor: lexicalEditor(),
collections: [Users, LegalPages, Media, Partners, ContactSubmissions],
globals: [Home, About, Services, Contact, Common],
// MITHOME-109/110: hu alapértelmezett (URL-prefix nélkül), en /en/ alatt
// (Next.js routing oldala: MITHOME-114). fallback:true -> amíg egy mezőnek
// nincs angol fordítása, a magyar érték jelenik meg — nem üres oldal.
localization: {
locales: ['hu', 'en'],
defaultLocale: 'hu',
fallback: true,
},
secret: process.env.PAYLOAD_SECRET || '',
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
db: mongooseAdapter({
url: process.env.MONGODB_URI || '',
}),
// WHY: Payload sends anonymous usage telemetry to its own servers by
// default — GDPR-tudatosan kikapcsolva, mivel a projekt self-hosted és
// az ügyfél adatai nem hagyhatják el a saját infrastruktúránkat.
telemetry: false,
sharp,
})
+5 -22
View File
@@ -1,11 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "ES2017",
"lib": [ "lib": ["dom", "dom.iterable", "esnext"],
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@@ -15,7 +11,7 @@
"module": "esnext", "module": "esnext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"isolatedModules": true, "isolatedModules": true,
"jsx": "react-jsx", "jsx": "preserve",
"incremental": true, "incremental": true,
"plugins": [ "plugins": [
{ {
@@ -23,22 +19,9 @@
} }
], ],
"paths": { "paths": {
"@/*": [ "@/*": ["./src/*"]
"./src/*"
],
"@payload-config": [
"./src/payload.config.ts"
]
} }
}, },
"include": [ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"next-env.d.ts", "exclude": ["node_modules"]
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }
+135
View File
@@ -0,0 +1,135 @@
// Security and infrastructure helpers for the Content Editor, extracted so
// content-editor.js stays focused on HTTP routing (file-size limits).
// Dependencies (validateLogin, hasValidSession) are injected to avoid cycles.
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const CMS_USER = process.env.CMS_USER;
const CMS_PASS = process.env.CMS_PASS;
const CMS_DEPLOY_ENV = process.env.CMS_DEPLOY_ENV;
const CSRF_TOKEN = process.env.CMS_CSRF_TOKEN || crypto.randomBytes(32).toString('hex');
const rateLimits = new Map();
function securityConfigIsValid() {
return Boolean(CMS_USER && CMS_PASS && ['staging', 'production'].includes(CMS_DEPLOY_ENV));
}
function getClientAddress(req) {
// The editor only listens on 127.0.0.1; the staging Nginx proxy supplies this header.
// WHY: take the LAST entry. Nginx ($proxy_add_x_forwarded_for) appends the real client
// IP to the list, so the first entry may be a spoofed value sent by the client — using
// it would let attackers bypass the rate limiter with a fresh "IP" per request.
const forwarded = req.headers['x-forwarded-for'];
if (typeof forwarded === 'string' && forwarded.trim()) {
const parts = forwarded.split(',').map(part => part.trim()).filter(Boolean);
if (parts.length > 0) return parts[parts.length - 1];
}
return req.socket.remoteAddress || 'unknown';
}
function isRateLimited(key, limit, windowMs) {
const now = Date.now();
const attempts = (rateLimits.get(key) || []).filter(time => now - time < windowMs);
if (attempts.length === 0) {
rateLimits.delete(key);
return false;
}
rateLimits.set(key, attempts);
return attempts.length >= limit;
}
function recordRateLimitAttempt(key, windowMs) {
const now = Date.now();
const attempts = (rateLimits.get(key) || []).filter(time => now - time < windowMs);
attempts.push(now);
rateLimits.set(key, attempts);
}
function exceedsRateLimit(key, limit, windowMs) {
const now = Date.now();
const attempts = (rateLimits.get(key) || []).filter(time => now - time < windowMs);
attempts.push(now);
rateLimits.set(key, attempts);
return attempts.length > limit;
}
function hasValidCredentials(req, validateLogin) {
const b64auth = (req.headers.authorization || '').split(' ')[1] || '';
const str = Buffer.from(b64auth, 'base64').toString();
const colonIdx = str.indexOf(':');
const login = colonIdx !== -1 ? str.slice(0, colonIdx) : str;
const password = colonIdx !== -1 ? str.slice(colonIdx + 1) : '';
return validateLogin(login, password, CMS_USER, CMS_PASS);
}
function isBrowserNavigation(req) {
return req.method === 'GET' && String(req.headers.accept || '').includes('text/html');
}
// WHY: Safari (and other browsers) cache Basic Auth credentials and resend them
// automatically, which would let an already-logged-out browser straight back in.
// Browser navigations therefore authenticate ONLY via the session cookie, so
// logout is final. Non-browser requests (curl, API clients) keep Basic Auth.
function makeIsAuthenticated(hasValidSession, validateLogin) {
return function isAuthenticated(req) {
if (isBrowserNavigation(req)) return hasValidSession(req);
return hasValidCredentials(req, validateLogin) || hasValidSession(req);
};
}
function hasValidCsrfToken(req) {
const token = req.headers['x-csrf-token'];
return typeof token === 'string'
&& token.length === CSRF_TOKEN.length
&& crypto.timingSafeEqual(Buffer.from(token), Buffer.from(CSRF_TOKEN));
}
function makeWriteAudit(auditFile) {
return function writeAudit(event, details = {}) {
const record = { timestamp: new Date().toISOString(), event, ...details };
fs.appendFileSync(auditFile, JSON.stringify(record) + '\n', { encoding: 'utf8', mode: 0o600 });
};
}
function backupAndWriteAtomically(targetFile, data, backupDir) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupName = `${path.basename(targetFile, '.json')}.${timestamp}.json`;
const backupFile = path.join(backupDir, backupName);
const tempFile = `${targetFile}.${process.pid}.${Date.now()}.tmp`;
fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
fs.copyFileSync(targetFile, backupFile);
fs.writeFileSync(tempFile, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
fs.renameSync(tempFile, targetFile);
return backupFile;
}
const RATE_LIMIT_GC_INTERVAL_MS = 5 * 60 * 1000;
setInterval(() => {
const now = Date.now();
for (const [key, attempts] of rateLimits) {
const valid = attempts.filter(t => now - t < 15 * 60 * 1000);
if (valid.length === 0) rateLimits.delete(key);
else rateLimits.set(key, valid);
}
}, RATE_LIMIT_GC_INTERVAL_MS).unref();
module.exports = {
CMS_USER,
CMS_PASS,
CMS_DEPLOY_ENV,
CSRF_TOKEN,
securityConfigIsValid,
getClientAddress,
isRateLimited,
recordRateLimitAttempt,
exceedsRateLimit,
hasValidCredentials,
isBrowserNavigation,
makeIsAuthenticated,
hasValidCsrfToken,
makeWriteAudit,
backupAndWriteAtomically,
};
+65
View File
@@ -0,0 +1,65 @@
// Dependency-free line diff (LCS) for the CMS version comparison view.
// Input lines are plain text; output entries are typed add/del/ctx rows.
function diffLines(oldLines, newLines) {
const n = oldLines.length;
const m = newLines.length;
// LCS lengths DP (files are small, a few hundred lines — O(n*m) is fine)
const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
dp[i][j] = oldLines[i] === newLines[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
const out = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (oldLines[i] === newLines[j]) {
out.push({ type: 'ctx', text: oldLines[i] });
i++;
j++;
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
out.push({ type: 'del', text: oldLines[i] });
i++;
} else {
out.push({ type: 'add', text: newLines[j] });
j++;
}
}
while (i < n) { out.push({ type: 'del', text: oldLines[i] }); i++; }
while (j < m) { out.push({ type: 'add', text: newLines[j] }); j++; }
return out;
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// Keep only ±contextAround context lines around changes to keep pages small.
function trimContext(entries, contextAround = 3) {
const keep = new Array(entries.length).fill(false);
entries.forEach((e, idx) => {
if (e.type !== 'ctx') {
for (let k = Math.max(0, idx - contextAround); k <= Math.min(entries.length - 1, idx + contextAround); k++) keep[k] = true;
}
});
const out = [];
let skipping = false;
entries.forEach((e, idx) => {
if (keep[idx]) { out.push(e); skipping = false; }
else if (!skipping) { out.push({ type: 'skip', text: '…' }); skipping = true; }
});
return out;
}
function renderDiffHtml(oldText, newText) {
const entries = trimContext(diffLines(oldText.split('\n'), newText.split('\n')));
return entries.map(e => `<div class="diff-${e.type}">${escapeHtml(e.text) || '&nbsp;'}</div>`).join('\n');
}
module.exports = { diffLines, trimContext, renderDiffHtml, escapeHtml };
+345
View File
@@ -0,0 +1,345 @@
// Browser-side script of the Content Editor editor page.
// Inlined into the HTML template at render time by content-editor.js.
// Test coverage: scripts/test-content-editor-serializer.js runs this exact code.
// ── Render ──────────────────────────────────────────────────────────────────
function render(obj, container) {
container.innerHTML = '';
renderObject(obj, container, '');
}
function renderObject(obj, container, prefix) {
for (const [key, val] of Object.entries(obj)) {
const path = prefix ? prefix + '.' + key : key;
if (Array.isArray(val)) {
renderArray(key, val, container, path);
} else if (typeof val === 'object' && val !== null) {
renderObject(val, container, path);
} else {
renderPrimitive(path, val, container);
}
}
}
function renderPrimitive(path, val, container) {
const isLong = String(val).length > 80 || String(val).includes('<');
const div = document.createElement('div');
div.className = 'field';
const type = val === null ? 'null' : typeof val;
let control;
if (type === 'boolean') {
control = `<input type="checkbox" data-path="${esc(path)}" data-type="boolean" ${val ? 'checked' : ''}>`;
} else if (type === 'number') {
control = `<input type="number" data-path="${esc(path)}" data-type="number" value="${esc(val)}">`;
} else {
control = isLong
? `<textarea data-path="${esc(path)}" data-type="${type}" rows="${Math.min(8,Math.max(2,Math.ceil(String(val).length/80)))}">${esc(val ?? '')}<\/textarea>`
: `<input type="text" data-path="${esc(path)}" data-type="${type}" value="${esc(val ?? '')}">`;
}
div.innerHTML = `
<label>${path}</label>
${control}
`;
container.appendChild(div);
}
function renderArray(key, arr, container, path) {
const section = document.createElement('div');
section.className = 'array-section';
section.dataset.arrayPath = path;
const label = document.createElement('div');
label.className = 'array-label';
label.textContent = path;
section.appendChild(label);
const items = document.createElement('div');
items.className = 'array-items';
items.dataset.arrayItems = path;
section.appendChild(items);
arr.forEach((item, i) => {
if (typeof item === 'object' && item !== null) {
items.appendChild(makeObjCard(item, i, path));
} else {
items.appendChild(makeStrItem(item, i, path));
}
});
// Template for adding new items
const sample = arr.length > 0 ? arr[arr.length - 1] : '';
const isObj = typeof sample === 'object' && sample !== null;
const addBtn = document.createElement('button');
addBtn.className = 'btn-add';
addBtn.textContent = ' Új elem hozzáadása';
addBtn.onclick = () => {
const idx = items.children.length;
if (isObj) {
const blank = blankLike(sample);
items.appendChild(makeObjCard(blank, idx, path));
} else {
items.appendChild(makeStrItem('', idx, path));
}
reindexItems(items);
};
section.appendChild(addBtn);
container.appendChild(section);
}
function blankLike(value) {
if (Array.isArray(value)) return [];
if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, blankLike(child)]));
}
if (typeof value === 'boolean') return false;
if (typeof value === 'number') return 0;
return '';
}
function makeStrItem(val, idx, path) {
const wrap = document.createElement('div');
wrap.className = 'str-item';
const type = val === null ? 'null' : typeof val;
const ta = type === 'boolean' ? document.createElement('input') : document.createElement('textarea');
ta.dataset.path = path + '[' + idx + ']';
ta.dataset.type = type;
if (type === 'boolean') {
ta.type = 'checkbox';
ta.checked = val;
} else {
ta.value = val ?? '';
ta.rows = Math.min(6, Math.max(2, Math.ceil(String(val ?? '').length / 80)));
}
const del = document.createElement('button');
del.className = 'btn-del';
del.textContent = '❌';
del.title = 'Törlés';
del.onclick = () => {
// WHY: capture the container BEFORE removing — a detached node has no
// ancestors, so closest() would return null and reindexing would silently
// not run (sparse arrays → schema errors on save).
const container = wrap.closest('.array-items');
wrap.remove();
reindexItems(container);
};
wrap.appendChild(ta);
wrap.appendChild(del);
return wrap;
}
function makeObjCard(obj, idx, path) {
const card = document.createElement('div');
card.className = 'obj-card';
const hdr = document.createElement('div');
hdr.className = 'card-header';
hdr.textContent = path + '[' + idx + ']';
card.appendChild(hdr);
renderObject(obj, card, path + '[' + idx + ']');
const del = document.createElement('button');
del.className = 'btn-del-card';
del.textContent = '❌ Törlés';
del.onclick = () => {
// Same as above: capture before detaching, or reindexing is skipped.
const container = card.closest('.array-items');
card.remove();
reindexItems(container);
};
card.appendChild(del);
return card;
}
function reindexItems(itemsEl) {
if (!itemsEl) return;
const path = itemsEl.dataset.arrayItems;
// WHY: rewrite only the index that directly follows THIS array's own path prefix.
// A generic "replace first [n]" rule corrupts nested arrays (e.g. deleting from
// services[1].specs.items rewrites the OUTER services index and scatters paths
// across services[0..n], producing sparse arrays and schema errors).
const prefix = path + '[';
Array.from(itemsEl.children).forEach((child, i) => {
child.querySelectorAll('[data-path]').forEach(el => {
const old = el.dataset.path;
if (typeof old !== 'string' || !old.startsWith(prefix)) return;
const rest = old.slice(prefix.length);
const bracketEnd = rest.indexOf(']');
const suffix = bracketEnd === -1 ? '' : rest.slice(bracketEnd);
el.dataset.path = prefix + i + suffix;
});
// Update card header
const hdr = child.querySelector('.card-header');
if (hdr) hdr.textContent = path + '[' + i + ']';
});
}
// ── Collect & Save ───────────────────────────────────────────────────────────
function collect() {
const result = JSON.parse(JSON.stringify(DATA)); // deep clone as base
// Wipe all arrays so we rebuild them from DOM
clearArrays(result);
document.querySelectorAll('[data-path]').forEach(el => {
setPath(result, el.dataset.path, readValue(el));
});
return result;
}
function readValue(el) {
switch (el.dataset.type) {
case 'boolean': return el.checked;
case 'number': return Number(el.value);
case 'null': return el.value === '' ? null : el.value;
default: return el.value;
}
}
function clearArrays(obj) {
for (const k of Object.keys(obj)) {
if (Array.isArray(obj[k])) obj[k] = [];
else if (typeof obj[k] === 'object' && obj[k] !== null) clearArrays(obj[k]);
}
}
function setPath(obj, path, value) {
const parts = parsePath(path);
let cur = obj;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (cur[part] === undefined || cur[part] === null) {
cur[part] = typeof parts[i + 1] === 'number' ? [] : {};
}
cur = cur[part];
}
cur[parts[parts.length - 1]] = value;
}
function parsePath(path) {
const parts = [];
let token = '';
let inIndex = false;
for (const char of path) {
if (char === '.') {
if (!inIndex && token) parts.push(token);
token = '';
} else if (char === '[') {
if (token) parts.push(token);
token = '';
inIndex = true;
} else if (char === ']') {
parts.push(Number(token));
token = '';
inIndex = false;
} else {
token += char;
}
}
if (token) parts.push(token);
return parts;
}
async function save() {
const status = document.getElementById('saveStatus');
try {
const data = collect();
const res = await fetch('/save?file=' + FILE, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF_TOKEN, 'X-Content-Hash': CONTENT_HASH },
body: JSON.stringify(data, null, 2)
});
if (res.status === 401) { location.href = '/login'; return false; }
if (res.status === 409) {
if (confirm('A tartalom megváltozott, mióta ez a lap megnyílt (pl. deploy vagy másik fül mentett).\n\nOK = lap frissítése az új tartalommal (a szerkesztésed elvész)\nMégse = maradsz ezen a lapon, a mentés nem történt meg.')) {
location.reload();
}
return false;
}
const json = await res.json();
if (json.ok) {
// Refresh the optimistic-lock fingerprint with the server-computed hash of
// the saved content, so the user's own subsequent saves don't trip 409.
if (json.contentHash) CONTENT_HASH = json.contentHash;
status.textContent = '✅ Mentve!';
status.style.color = '#10b981';
status.style.visibility = 'visible';
setTimeout(() => status.style.visibility = 'hidden', 3000);
return true;
} else {
status.textContent = '❌ Hiba: ' + json.error;
status.style.color = '#f87171';
status.style.visibility = 'visible';
setTimeout(() => status.style.visibility = 'hidden', 5000);
return false;
}
} catch (e) {
status.textContent = '❌ Hálózati hiba mentéskor';
status.style.color = '#f87171';
status.style.visibility = 'visible';
setTimeout(() => status.style.visibility = 'hidden', 5000);
return false;
}
}
async function publish() {
const btn = document.getElementById('publishBtn');
const status = document.getElementById('saveStatus');
// Save first — abort publish if save failed (e.g. 409 conflict, validation error)
const saved = await save();
if (!saved) return;
// WHY: lock the button width and remember the label so the running state
// neither resizes the bottom bar nor permanently swaps the env-specific label.
const originalLabel = btn.textContent;
btn.style.minWidth = btn.offsetWidth + 'px';
btn.textContent = '⏳ Élesítés folyamatban...';
btn.disabled = true;
try {
const res = await fetch('/publish', { method: 'POST', headers: { 'X-CSRF-Token': CSRF_TOKEN } });
if (res.status === 401) { location.href = '/login'; return; }
const json = await res.json();
if (json.ok) {
status.textContent = '🚀 Sikeresen elküldve a szerverre!';
status.style.color = '#10b981';
} else {
status.textContent = '❌ Hiba az élesítésnél: ' + json.error;
status.style.color = '#f87171';
}
} catch (e) {
status.textContent = '❌ Hálózati hiba';
status.style.color = '#f87171';
}
btn.textContent = originalLabel;
btn.style.minWidth = '';
btn.disabled = false;
status.style.visibility = 'visible';
setTimeout(() => status.style.visibility = 'hidden', 5000);
}
async function logout() {
if (!confirm('Biztosan ki szeretnél lépni?')) return;
try {
// Invalidates the server-side session cookie (Basic Auth cache is not
// affected — the login page is public, no 401-overwrite is needed).
await fetch('/logout', { method: 'POST', headers: { 'X-CSRF-Token': CSRF_TOKEN } });
} catch (e) { /* network error — continue to the login page */ }
location.href = '/login';
}
function esc(v) {
return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Boot
render(DATA, document.getElementById('editor'));
// Auto-dismiss toast
const toast = document.querySelector('.toast');
if (toast) setTimeout(() => toast.remove(), 3500);
+56
View File
@@ -0,0 +1,56 @@
// Keyboard shortcuts for the Content Editor editor page. Inlined after the
// main client script; all referenced functions are global at that point.
// Ctrl/Cmd+S save · Ctrl/Cmd+P publish · Ctrl/Cmd+Shift+V versions · ? help
// Plain typing in inputs never triggers actions — the handler requires the
// modifier key (or, for '?', a non-editing target).
function showShortcutsOverlay() {
const existing = document.getElementById('shortcuts-overlay');
if (existing) { existing.remove(); return; }
const overlay = document.createElement('div');
overlay.id = 'shortcuts-overlay';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(15,17,23,.75);z-index:300;display:flex;align-items:center;justify-content:center;padding:24px;';
overlay.innerHTML = `
<div style="background:#1a2035;border:1px solid #2d3748;border-radius:14px;padding:28px 32px;max-width:420px;width:100%;font-size:14px;line-height:2;color:#e2e8f0;">
<h2 style="font-size:16px;color:#93c5fd;margin-bottom:12px;">⌨️ Gyorsbillentyűk</h2>
<div><kbd style="background:#0f1420;border:1px solid #2d3748;border-radius:5px;padding:2px 8px;font-family:monospace;">Ctrl/Cmd + S</kbd> — Mentés</div>
<div><kbd style="background:#0f1420;border:1px solid #2d3748;border-radius:5px;padding:2px 8px;font-family:monospace;">Ctrl/Cmd + P</kbd> — Publikálás</div>
<div><kbd style="background:#0f1420;border:1px solid #2d3748;border-radius:5px;padding:2px 8px;font-family:monospace;">Ctrl/Cmd + Shift + V</kbd> — Verziók</div>
<div><kbd style="background:#0f1420;border:1px solid #2d3748;border-radius:5px;padding:2px 8px;font-family:monospace;">?</kbd> — ez a súgó (Esc: bezárás)</div>
</div>`;
overlay.addEventListener('click', () => overlay.remove());
document.body.appendChild(overlay);
}
document.addEventListener('keydown', e => {
// Esc closes the shortcut overlay if open
if (e.key === 'Escape') {
const overlay = document.getElementById('shortcuts-overlay');
if (overlay) { overlay.remove(); e.preventDefault(); }
return;
}
const mod = e.ctrlKey || e.metaKey;
if (mod && !e.shiftKey && !e.altKey && (e.key === 's' || e.key === 'S')) {
e.preventDefault();
save();
return;
}
if (mod && !e.shiftKey && !e.altKey && (e.key === 'p' || e.key === 'P')) {
e.preventDefault();
publish();
return;
}
if (mod && e.shiftKey && (e.key === 'v' || e.key === 'V')) {
e.preventDefault();
window.open('/versions?file=' + encodeURIComponent(FILE), '_blank');
return;
}
if (!mod && !e.ctrlKey && !e.metaKey && !e.altKey && e.key === '?') {
const target = e.target;
const isEditing = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable);
if (!isEditing) {
e.preventDefault();
showShortcutsOverlay();
}
}
});
+345
View File
@@ -0,0 +1,345 @@
function setMsg(target, text, ok) {
const el = document.getElementById('msg-' + target);
el.textContent = text;
el.className = 'msg ' + (ok ? 'ok' : 'err');
}
async function upload(target) {
const file = document.getElementById('file-' + target).files[0];
const btn = document.getElementById('btn-' + target);
const origText = btn.textContent;
const msg = t => setMsg(target, t, false);
if (!file) { msg('Először válassz egy új PNG fájlt a mentéshez.'); return; }
if (file.type !== 'image/png') { msg('Csak PNG fájl tölthető fel.'); return; }
if (file.size > 1024 * 1024) { msg('A fájl nagyobb, mint 1 MB.'); return; }
btn.disabled = true;
btn.textContent = '⏳ Mentés folyamatban...';
try {
const bytes = new Uint8Array(await file.arrayBuffer());
await sendLogoBinary(target, bytes);
} catch (e) { msg('❌ Hálózati hiba mentés közben'); }
btn.disabled = false;
btn.textContent = origText;
}
async function sendLogoBinary(target, bytes) {
const res = await fetch('/logo?target=' + target, {
method: 'POST',
headers: { 'Content-Type': 'image/png', 'X-CSRF-Token': CSRF_TOKEN },
body: bytes
});
if (res.status === 401) { location.href = '/login'; return; }
const json = await res.json();
if (json.ok) {
setMsg(target, '✅ Logó sikeresen elmentve! (A weboldalon a Publikálás után jelenik meg.)', true);
const variantParam = target === 'header' ? 'variant=header&' : '';
document.getElementById('prev-' + target).src = '/logo.png?' + variantParam + 't=' + Date.now();
const meta = document.getElementById('meta-' + target);
if (meta) meta.textContent = 'Módosítva (' + (bytes.length / 1024).toFixed(1) + ' KB) — elmentve';
} else setMsg(target, '❌ ' + json.error, false);
}
document.querySelectorAll('input[type=file]').forEach(inp => {
inp.addEventListener('change', () => {
const target = inp.id.replace('file-', '');
const f = inp.files[0];
const meta = document.getElementById('meta-' + target);
const prev = document.getElementById('prev-' + target);
if (!f) return;
if (f.type !== 'image/png') {
setMsg(target, 'Csak PNG formátumú kép választható ki.', false);
if (meta) meta.textContent = '';
return;
}
if (f.size > 1024 * 1024) {
setMsg(target, 'A fájl nagyobb 1 MB-nál.', false);
if (meta) meta.textContent = '';
return;
}
setMsg(target, 'Új fájl kiválasztva. Kattints a Mentés vagy a ✏️ Szerkesztés gombra.', true);
if (meta) meta.textContent = f.name + ' — ' + (f.size / 1024).toFixed(1) + ' KB (még nincs mentve)';
prev.src = URL.createObjectURL(f);
});
});
/* ── Interactive Canvas Editor Logic ────────────────────────────── */
let currentEditTarget = 'header';
let editImg = new Image();
let editState = {
zoom: 1, panX: 0, panY: 0, rotation: 0,
flipH: 1, flipV: 1, padding: 0, aspect: 0,
brightness: 100, contrast: 100, invert: false
};
const canvas = document.getElementById('edit-canvas');
const ctx = canvas.getContext('2d');
const wrap = document.getElementById('canvas-wrap');
let isDragging = false, startX = 0, startY = 0;
function openEditor(target) {
currentEditTarget = target;
document.getElementById('modal-title').textContent = '🎨 Logó szerkesztése — ' + (target === 'header' ? 'Weboldal fejléc' : 'CMS ikon');
editState.aspect = (target === 'icon' ? 1 : 0);
updateAspectBtns();
resetFilters();
resetPan();
const fileInput = document.getElementById('file-' + target);
if (fileInput.files && fileInput.files[0]) {
const reader = new FileReader();
reader.onload = e => { loadImg(e.target.result); };
reader.readAsDataURL(fileInput.files[0]);
} else {
const previewSrc = document.getElementById('prev-' + target).src;
loadImg(previewSrc);
}
}
function loadImg(src) {
editImg = new Image();
editImg.crossOrigin = 'anonymous';
editImg.onload = () => {
document.getElementById('editor-modal').classList.add('open');
fitToCrop();
render();
};
editImg.src = src;
}
function closeEditor() {
document.getElementById('editor-modal').classList.remove('open');
}
function setAspect(ratio) {
editState.aspect = ratio;
updateAspectBtns();
render();
}
function updateAspectBtns() {
document.querySelectorAll('#aspect-btns button').forEach(b => {
const a = parseFloat(b.dataset.aspect);
b.classList.toggle('active', (editState.aspect === 0 && a === 0) || (Math.abs(editState.aspect - a) < 0.01));
});
}
function setZoom(val) {
editState.zoom = parseFloat(val);
document.getElementById('zoom-val').textContent = Math.round(editState.zoom * 100) + '%';
render();
}
function setPadding(val) {
editState.padding = parseInt(val, 10);
document.getElementById('pad-val').textContent = editState.padding + 'px';
render();
}
function setFilter(name, val) {
editState[name] = parseInt(val, 10);
document.getElementById(name.slice(0, 6) + '-val').textContent = val + '%';
render();
}
function toggleInvert() {
editState.invert = !editState.invert;
document.getElementById('btn-invert').classList.toggle('active', editState.invert);
render();
}
// WHY: many partner/site logos arrive with a solid (usually white) background.
// Removing it lets the logo sit cleanly on any page background. Pixels brighter
// than the threshold become transparent; a soft ramp just below it keeps the
// edges smooth instead of jagged.
function makeTransparent() {
if (!editImg.width) return;
const off = document.createElement('canvas');
off.width = editImg.width;
off.height = editImg.height;
const octx = off.getContext('2d');
octx.drawImage(editImg, 0, 0);
const imgData = octx.getImageData(0, 0, off.width, off.height);
const d = imgData.data;
const thresh = 235; // fully transparent above this luminance
const soft = 30; // smooth ramp below the threshold
for (let i = 0; i < d.length; i += 4) {
const lum = (d[i] + d[i + 1] + d[i + 2]) / 3;
if (lum > thresh) {
d[i + 3] = 0;
} else if (lum > thresh - soft) {
const t = (lum - (thresh - soft)) / soft; // 0..1
d[i + 3] = Math.round(d[i + 3] * (1 - t));
}
}
octx.putImageData(imgData, 0, 0);
const next = new Image();
next.onload = () => { editImg = next; render(); };
next.src = off.toDataURL('image/png');
}
function resetFilters() {
editState.brightness = 100; editState.contrast = 100; editState.invert = false; editState.padding = 0;
document.getElementById('bright-range').value = 100; document.getElementById('bright-val').textContent = '100%';
document.getElementById('contrast-range').value = 100; document.getElementById('contrast-val').textContent = '100%';
document.getElementById('pad-range').value = 0; document.getElementById('pad-val').textContent = '0px';
document.getElementById('btn-invert').classList.remove('active');
render();
}
function rotate(deg) {
editState.rotation = (editState.rotation + deg) % 360;
render();
}
function toggleFlip(dir) {
if (dir === 'h') editState.flipH *= -1;
if (dir === 'v') editState.flipV *= -1;
render();
}
function resetPan() {
editState.panX = 0; editState.panY = 0;
render();
}
function getCropRect() {
const cw = canvas.width, ch = canvas.height;
let rw = cw * 0.85, rh = ch * 0.85;
if (editState.aspect > 0) {
if (rw / rh > editState.aspect) rw = rh * editState.aspect;
else rh = rw / editState.aspect;
}
return { x: (cw - rw) / 2, y: (ch - rh) / 2, w: rw, h: rh };
}
function fitToCrop() {
if (!editImg.width || !editImg.height) return;
const crop = getCropRect();
const isRotated = Math.abs(editState.rotation) === 90 || Math.abs(editState.rotation) === 270;
const iw = isRotated ? editImg.height : editImg.width;
const ih = isRotated ? editImg.width : editImg.height;
const scale = Math.min(crop.w / iw, crop.h / ih);
editState.zoom = Math.max(0.3, Math.min(3, scale));
document.getElementById('zoom-range').value = editState.zoom;
document.getElementById('zoom-val').textContent = Math.round(editState.zoom * 100) + '%';
editState.panX = 0; editState.panY = 0;
render();
}
function render() {
if (!editImg.width) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
const crop = getCropRect();
// Draw image
ctx.save();
ctx.filter = 'brightness(' + editState.brightness + '%) contrast(' + editState.contrast + '%)' + (editState.invert ? ' invert(100%)' : '');
ctx.translate(canvas.width / 2 + editState.panX, canvas.height / 2 + editState.panY);
ctx.rotate((editState.rotation * Math.PI) / 180);
ctx.scale(editState.zoom * editState.flipH, editState.zoom * editState.flipV);
const pad = editState.padding / (editState.zoom || 1);
const dw = Math.max(10, editImg.width - pad * 2);
const dh = Math.max(10, editImg.height - pad * 2);
ctx.drawImage(editImg, -dw / 2, -dh / 2, dw, dh);
ctx.restore();
// Dark overlay outside crop rect
ctx.save();
ctx.fillStyle = 'rgba(15, 17, 23, 0.75)';
ctx.fillRect(0, 0, canvas.width, crop.y);
ctx.fillRect(0, crop.y + crop.h, canvas.width, canvas.height - (crop.y + crop.h));
ctx.fillRect(0, crop.y, crop.x, crop.h);
ctx.fillRect(crop.x + crop.w, crop.y, canvas.width - (crop.x + crop.w), crop.h);
// Crop border
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.strokeRect(crop.x, crop.y, crop.w, crop.h);
ctx.restore();
}
// Drag & Pan handlers
wrap.addEventListener('mousedown', e => { isDragging = true; startX = e.clientX - editState.panX; startY = e.clientY - editState.panY; wrap.classList.add('grabbing'); });
window.addEventListener('mousemove', e => { if (!isDragging) return; editState.panX = e.clientX - startX; editState.panY = e.clientY - startY; render(); });
window.addEventListener('mouseup', () => { isDragging = false; wrap.classList.remove('grabbing'); });
wrap.addEventListener('wheel', e => {
e.preventDefault();
const delta = e.deltaY < 0 ? 0.05 : -0.05;
setZoom(Math.max(0.3, Math.min(3, editState.zoom + delta)));
document.getElementById('zoom-range').value = editState.zoom;
}, { passive: false });
async function saveEditedLogo() {
const crop = getCropRect();
const outCanvas = document.createElement('canvas');
outCanvas.width = Math.round(crop.w * 2); // 2x for retina sharpness
outCanvas.height = Math.round(crop.h * 2);
const octx = outCanvas.getContext('2d');
octx.save();
octx.scale(2, 2);
octx.translate(-crop.x, -crop.y);
octx.filter = 'brightness(' + editState.brightness + '%) contrast(' + editState.contrast + '%)' + (editState.invert ? ' invert(100%)' : '');
octx.translate(canvas.width / 2 + editState.panX, canvas.height / 2 + editState.panY);
octx.rotate((editState.rotation * Math.PI) / 180);
octx.scale(editState.zoom * editState.flipH, editState.zoom * editState.flipV);
const pad = editState.padding / (editState.zoom || 1);
const dw = Math.max(10, editImg.width - pad * 2);
const dh = Math.max(10, editImg.height - pad * 2);
octx.drawImage(editImg, -dw / 2, -dh / 2, dw, dh);
octx.restore();
const saveBtn = document.getElementById('modal-save-btn');
saveBtn.disabled = true;
saveBtn.textContent = '⏳ Mentés folyamatban...';
outCanvas.toBlob(async blob => {
if (!blob) { alert('Hiba a kép exportálásakor'); saveBtn.disabled = false; return; }
try {
const bytes = new Uint8Array(await blob.arrayBuffer());
await sendLogoBinary(currentEditTarget, bytes);
closeEditor();
} catch (e) {
alert('Hiba történt a mentés során.');
}
saveBtn.disabled = false;
saveBtn.textContent = '💾 Szerkesztett logó mentése';
}, 'image/png');
}
async function uploadPartner() {
const name = document.getElementById('partner-name').value.trim();
const file = document.getElementById('file-partner').files[0];
const msg = document.getElementById('msg-partner');
const pathOut = document.getElementById('path-partner');
const btn = document.getElementById('btn-partner');
pathOut.textContent = '';
if (!name) { msg.textContent = '❌ Adj meg egy fájlnevet.'; msg.className = 'msg err'; return; }
if (!file) { msg.textContent = '❌ Válassz PNG fájlt.'; msg.className = 'msg err'; return; }
if (file.type !== 'image/png') { msg.textContent = '❌ Csak PNG tölthető fel.'; msg.className = 'msg err'; return; }
btn.disabled = true;
try {
const bytes = new Uint8Array(await file.arrayBuffer());
const res = await fetch('/partner-logo?name=' + encodeURIComponent(name), {
method: 'POST',
headers: { 'Content-Type': 'image/png', 'X-CSRF-Token': CSRF_TOKEN },
body: bytes
});
if (res.status === 401) { location.href = '/login'; return; }
const json = await res.json();
if (json.ok) {
msg.textContent = '✅ Feltöltve.';
msg.className = 'msg ok';
pathOut.textContent = 'Elérési út: ' + json.path;
} else {
msg.textContent = '❌ ' + json.error;
msg.className = 'msg err';
}
} catch (e) {
msg.textContent = '❌ Hálózati hiba';
msg.className = 'msg err';
}
btn.disabled = false;
}
+203
View File
@@ -0,0 +1,203 @@
// Branding page for the Content Editor: upload/replace and edit logos with
// interactive Canvas editor (crop, zoom/pan, rotate/flip, padding, filters).
// The browser-side editor script is inlined from cms-logo-client.js.
const fs = require('fs');
const path = require('path');
const { LOGO_TARGETS } = require('./cms-logo');
const logoClientJs = fs.readFileSync(path.join(__dirname, 'cms-logo-client.js'), 'utf8');
const LOGO_PAGE = (csrfToken) => `<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>mozdIT — Logó kezelése</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; line-height: 1.6; padding-bottom: 64px; }
header { background: linear-gradient(135deg,#1a1f2e,#252d40); border-bottom: 1px solid #2d3748; padding: 14px 32px; display: flex; align-items: center; gap: 12px; position: sticky; top: 0; z-index: 10; }
header h1 { font-size: 17px; font-weight: 700; background: linear-gradient(135deg,#60a5fa,#a78bfa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
header a { color: #94a3b8; text-decoration: none; font-size: 14px; margin-left: auto; }
header a:hover { color: #e2e8f0; }
main { max-width: 720px; margin: 0 auto; padding: 28px 24px; }
.note { color: #94a3b8; font-size: 14px; margin-bottom: 22px; }
.card { background: #1a2035; border: 1px solid #2d3748; border-radius: 12px; padding: 20px 22px; margin-bottom: 18px; }
.card h2 { font-size: 16px; color: #93c5fd; margin-bottom: 4px; }
.card .where { color: #64748b; font-size: 13px; margin-bottom: 14px; }
.field-label { display: block; font-size: 13px; color: #94a3b8; margin: 12px 0 6px; }
#partner-name { width: 100%; background: #0f1420; border: 1px solid #2d3748; border-radius: 8px; color: #e2e8f0; padding: 9px 12px; font-size: 14px; margin-bottom: 10px; }
.path-out { font-family: monospace; font-size: 13px; color: #6ee7b7; margin-top: 10px; word-break: break-all; }
.preview { background: repeating-conic-gradient(#1e293b 0% 25%, #0f1420 0% 50%) 50% / 22px 22px; border: 1px solid #2d3748; border-radius: 10px; padding: 16px; margin-bottom: 14px; text-align: center; min-height: 90px; }
.preview img { max-width: 100%; max-height: 72px; }
input[type=file] { color: #94a3b8; font-size: 14px; margin-bottom: 12px; width: 100%; }
.meta { font-size: 13px; color: #94a3b8; min-height: 20px; margin-bottom: 12px; }
.actions-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.btn-save { background: linear-gradient(135deg,#059669,#10b981); color: #fff; border: none; border-radius: 8px; padding: 9px 18px; font-size: 14px; font-weight: 700; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
.btn-edit { background: linear-gradient(135deg,#3b82f6,#6366f1); color: #fff; border: none; border-radius: 8px; padding: 9px 16px; font-size: 14px; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
.btn-secondary { background: #334155; color: #e2e8f0; border: 1px solid #475569; border-radius: 8px; padding: 9px 16px; font-size: 14px; font-weight: 600; cursor: pointer; }
button:hover { filter: brightness(1.1); }
button:disabled { opacity: .5; cursor: wait; }
.msg { font-size: 14px; margin-top: 12px; min-height: 20px; }
.ok { color: #6ee7b7; } .err { color: #fca5a5; }
/* Modal Styles */
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.85); backdrop-filter: blur(4px); z-index: 1000; display: none; align-items: center; justify-content: center; padding: 14px; }
.modal-backdrop.open { display: flex; }
.modal-box { background: #1a2035; border: 1px solid #334155; border-radius: 14px; width: 100%; max-width: 860px; max-height: 92vh; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 20px 40px rgba(0,0,0,0.6); }
.modal-header { padding: 12px 20px; border-bottom: 1px solid #2d3748; display: flex; align-items: center; justify-content: space-between; background: #141824; }
.modal-header h3 { font-size: 16px; color: #93c5fd; }
.btn-close { background: transparent; border: none; color: #94a3b8; font-size: 18px; cursor: pointer; padding: 4px 8px; }
.modal-body { display: grid; grid-template-columns: 1fr 280px; gap: 16px; padding: 16px; overflow-y: auto; max-height: calc(92vh - 120px); }
@media (max-width: 720px) { .modal-body { grid-template-columns: 1fr; } }
.canvas-container { background: repeating-conic-gradient(#1e293b 0% 25%, #0f1420 0% 50%) 50% / 20px 20px; border: 1px solid #334155; border-radius: 10px; display: flex; align-items: center; justify-content: center; min-height: 320px; position: relative; overflow: hidden; cursor: grab; user-select: none; }
.canvas-container.grabbing { cursor: grabbing; }
canvas { max-width: 100%; max-height: 100%; display: block; }
.editor-controls { display: flex; flex-direction: column; gap: 14px; font-size: 13px; color: #cbd5e1; }
.ctrl-group { background: #141824; border: 1px solid #2d3748; border-radius: 8px; padding: 10px 12px; }
.ctrl-group h4 { font-size: 12px; text-transform: uppercase; color: #94a3b8; margin-bottom: 8px; letter-spacing: 0.5px; }
.btn-row { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 6px; }
.btn-tool { background: #1e293b; border: 1px solid #475569; color: #cbd5e1; border-radius: 6px; padding: 5px 9px; font-size: 12px; cursor: pointer; }
.btn-tool.active { background: #2563eb; color: #fff; border-color: #3b82f6; }
.slider-row { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
.slider-row label { width: 68px; font-size: 12px; color: #94a3b8; }
.slider-row input[type=range] { flex: 1; accent-color: #3b82f6; }
.slider-row span { width: 38px; font-size: 11px; text-align: right; color: #cbd5e1; }
.modal-footer { padding: 12px 20px; border-top: 1px solid #2d3748; background: #141824; display: flex; justify-content: flex-end; gap: 10px; align-items: center; }
</style>
</head>
<body>
<header>
<h1>🎨 Logó kezelése</h1>
<a href="/">← Vissza a szerkesztőhöz</a>
</header>
<main>
<p class="note">Csak <strong>PNG</strong> fájl tölthető fel (max. 1 MB). A régi logóról automatikus biztonsági mentés készül. A szerkesztővel közvetlenül vágatod, méretezheted, forgathatod és korrigálhatod a logókat a mentés előtt. A változás a <strong>weboldalon a Publikálás (deploy) után</strong> jelenik meg.</p>
<div class="card">
<h2>Weboldal fejléc logója (szöveges)</h2>
<p class="where">Használat: weboldal fejléc — jelenlegi fájl: /${LOGO_TARGETS.header}</p>
<div class="preview"><img id="prev-header" src="/logo.png?variant=header&t=${Date.now()}" alt="fejléc logó előnézet"></div>
<input type="file" id="file-header" accept="image/png">
<div class="meta" id="meta-header"></div>
<div class="actions-row">
<button class="btn-save" id="btn-header" onclick="upload('header')">💾 Fejléc logó mentése</button>
<button class="btn-edit" onclick="openEditor('header')">✏️ Logó szerkesztése</button>
</div>
<p class="msg" id="msg-header"></p>
</div>
<div class="card">
<h2>CMS logó (ikon)</h2>
<p class="where">Használat: CMS bejelentkező oldal — jelenlegi fájl: /${LOGO_TARGETS.icon}</p>
<div class="preview"><img id="prev-icon" src="/logo.png?t=${Date.now()}" alt="ikon logó előnézet"></div>
<input type="file" id="file-icon" accept="image/png">
<div class="meta" id="meta-icon"></div>
<div class="actions-row">
<button class="btn-save" id="btn-icon" onclick="upload('icon')">💾 Ikon logó mentése</button>
<button class="btn-edit" onclick="openEditor('icon')">✏️ Logó szerkesztése</button>
</div>
<p class="msg" id="msg-icon"></p>
</div>
<div class="card">
<h2>Partner logó feltöltése</h2>
<p class="where">Használat: a Kezdőlap „Partnereink" szekciójához. A feltöltés után a visszaadott elérési utat másold a partner „logo" mezőjébe (pl. /partners/nev.png).</p>
<label for="partner-name" class="field-label">Fájlnév (szóközök nélkül, pl. „acme")</label>
<input type="text" id="partner-name" placeholder="acme">
<input type="file" id="file-partner" accept="image/png">
<div class="meta" id="meta-partner"></div>
<button class="btn-save" id="btn-partner" onclick="uploadPartner()">⬆ Partner logó feltöltése</button>
<p class="msg" id="msg-partner"></p>
<p class="path-out" id="path-partner"></p>
</div>
</main>
<!-- Editor Modal -->
<div class="modal-backdrop" id="editor-modal">
<div class="modal-box">
<div class="modal-header">
<h3 id="modal-title">🎨 Logó szerkesztése</h3>
<button class="btn-close" onclick="closeEditor()">✕</button>
</div>
<div class="modal-body">
<div class="canvas-container" id="canvas-wrap">
<canvas id="edit-canvas" width="500" height="350"></canvas>
</div>
<div class="editor-controls">
<div class="ctrl-group">
<h4>Képarány / Vágás</h4>
<div class="btn-row" id="aspect-btns">
<button class="btn-tool" data-aspect="0" onclick="setAspect(0)">Szabad</button>
<button class="btn-tool active" data-aspect="1" onclick="setAspect(1)">1:1</button>
<button class="btn-tool" data-aspect="3" onclick="setAspect(3)">3:1</button>
<button class="btn-tool" data-aspect="4" onclick="setAspect(4)">4:1</button>
<button class="btn-tool" data-aspect="1.777" onclick="setAspect(1.777)">16:9</button>
</div>
</div>
<div class="ctrl-group">
<h4>Méret és Pozíció</h4>
<div class="slider-row">
<label>Nagyítás:</label>
<input type="range" id="zoom-range" min="0.3" max="3" step="0.05" value="1" oninput="setZoom(this.value)">
<span id="zoom-val">100%</span>
</div>
<div class="btn-row" style="margin-top:8px;">
<button class="btn-tool" onclick="resetPan()">✛ Középre</button>
<button class="btn-tool" onclick="fitToCrop()">⊡ Keretbe illesztés</button>
</div>
</div>
<div class="ctrl-group">
<h4>Forgatás & Tükrözés</h4>
<div class="btn-row">
<button class="btn-tool" onclick="rotate(-90)">↺ Balra 90°</button>
<button class="btn-tool" onclick="rotate(90)">↻ Jobbra 90°</button>
<button class="btn-tool" onclick="toggleFlip('h')">⇋ Vízszintes</button>
<button class="btn-tool" onclick="toggleFlip('v')">⇅ Függőleges</button>
</div>
</div>
<div class="ctrl-group">
<h4>Margó / Padding</h4>
<div class="slider-row">
<label>Margó:</label>
<input type="range" id="pad-range" min="0" max="60" step="2" value="0" oninput="setPadding(this.value)">
<span id="pad-val">0px</span>
</div>
</div>
<div class="ctrl-group">
<h4>Képkorrekció</h4>
<div class="slider-row">
<label>Fényerő:</label>
<input type="range" id="bright-range" min="50" max="200" value="100" oninput="setFilter('brightness', this.value)">
<span id="bright-val">100%</span>
</div>
<div class="slider-row">
<label>Kontraszt:</label>
<input type="range" id="contrast-range" min="50" max="200" value="100" oninput="setFilter('contrast', this.value)">
<span id="contrast-val">100%</span>
</div>
<div class="btn-row" style="margin-top:8px;">
<button class="btn-tool" id="btn-invert" onclick="toggleInvert()">🌓 Invertálás</button>
<button class="btn-tool" onclick="resetFilters()">↺ Alaphelyzet</button>
<button class="btn-tool" onclick="makeTransparent()">⚪ Háttér átlátszóvá (fehér)</button>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick="closeEditor()">Mégse</button>
<button class="btn-save" id="modal-save-btn" onclick="saveEditedLogo()">💾 Szerkesztett logó mentése</button>
</div>
</div>
</div>
<script>
const CSRF_TOKEN = "${csrfToken}";
${logoClientJs}
</script>
</body>
</html>`;
module.exports = { LOGO_PAGE };
+156
View File
@@ -0,0 +1,156 @@
// Logo upload handling for the Content Editor: PNG validation, timestamped
// backup and atomic binary replace.
const fs = require('fs');
const path = require('path');
const MAX_LOGO_BYTES = 1024 * 1024; // 1 MiB — plenty for a logo
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
// WHY fixed targets instead of a client-supplied filename: arbitrary write
// paths would be a traversal risk; the two known logos are the only assets
// the site consumes.
const LOGO_TARGETS = {
icon: 'mozdit_logo.png', // CMS login page
header: 'mozdit_logo_text.png', // website Header
};
function isPng(buffer) {
return Buffer.isBuffer(buffer) && buffer.length >= PNG_MAGIC.length && buffer.subarray(0, PNG_MAGIC.length).equals(PNG_MAGIC);
}
function saveLogoAtomically(publicDir, targetKey, buffer, backupDir) {
const fileName = LOGO_TARGETS[targetKey];
if (!fileName) throw new Error('Ismeretlen logó célpont');
const targetFile = path.join(publicDir, fileName);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupName = `${fileName}.${timestamp}.bak`;
fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
fs.copyFileSync(targetFile, path.join(backupDir, backupName));
const tempFile = `${targetFile}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempFile, buffer, { mode: 0o644 });
fs.renameSync(tempFile, targetFile);
return { targetFile, backupName };
}
// WHY: partner logos are a variable set — the filename comes from the editor,
// so it must be sanitized to a safe slug (no traversal, no separators).
function slugifyName(raw) {
return String(raw)
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64);
}
function savePartnerLogo(publicDir, filename, buffer) {
const slug = slugifyName(filename) || `partner-${Date.now()}`;
const dir = path.join(publicDir, 'partners');
fs.mkdirSync(dir, { recursive: true, mode: 0o755 });
const targetFile = path.join(dir, `${slug}.png`);
const tempFile = `${targetFile}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempFile, buffer, { mode: 0o644 });
fs.renameSync(tempFile, targetFile);
return `/partners/${slug}.png`;
}
// WHY: route handling lives here so content-editor.js stays under the
// 400-line limit. Returns true when the request was handled.
function handleLogoRoutes({ req, res, u, publicDir, backupDir, writeAudit, clientAddress, user, logoPage }) {
if (req.method === 'GET' && u.pathname === '/branding') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(logoPage());
return true;
}
if (req.method === 'POST' && u.pathname === '/logo') {
const target = u.searchParams.get('target') || '';
if (!LOGO_TARGETS[target]) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'Ismeretlen logó célpont.' }));
return true;
}
const chunks = [];
let total = 0;
let tooLarge = false;
req.on('data', c => {
total += c.length;
if (total > MAX_LOGO_BYTES) { tooLarge = true; return; }
chunks.push(c);
});
req.on('end', () => {
const buffer = Buffer.concat(chunks);
if (tooLarge) {
writeAudit('logo_updated', { clientAddress, user, target, result: 'request_too_large' });
res.writeHead(413, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: `A fájl túl nagy (maximum ${MAX_LOGO_BYTES} byte).` }));
return;
}
if (!isPng(buffer)) {
writeAudit('logo_updated', { clientAddress, user, target, result: 'invalid_type' });
res.writeHead(415, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'Csak érvényes PNG fájl tölthető fel.' }));
return;
}
try {
const { backupName } = saveLogoAtomically(publicDir, target, buffer, backupDir);
writeAudit('logo_updated', { clientAddress, user, target, result: 'ok', backup: backupName });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, backup: backupName }));
} catch (e) {
writeAudit('logo_updated', { clientAddress, user, target, result: 'error' });
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: e.message }));
}
});
return true;
}
if (req.method === 'POST' && u.pathname === '/partner-logo') {
const filename = u.searchParams.get('name') || '';
if (!slugifyName(filename)) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'Adj meg egy érvényes fájlnevet.' }));
return true;
}
const chunks = [];
let total = 0;
let tooLarge = false;
req.on('data', c => {
total += c.length;
if (total > MAX_LOGO_BYTES) { tooLarge = true; return; }
chunks.push(c);
});
req.on('end', () => {
const buffer = Buffer.concat(chunks);
if (tooLarge) {
writeAudit('partner_logo_upload', { clientAddress, user, result: 'request_too_large' });
res.writeHead(413, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: `A fájl túl nagy (maximum ${MAX_LOGO_BYTES} byte).` }));
return;
}
if (!isPng(buffer)) {
writeAudit('partner_logo_upload', { clientAddress, user, result: 'invalid_type' });
res.writeHead(415, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'Csak érvényes PNG fájl tölthető fel.' }));
return;
}
try {
const publicPath = savePartnerLogo(publicDir, filename, buffer);
writeAudit('partner_logo_upload', { clientAddress, user, result: 'ok', path: publicPath });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, path: publicPath }));
} catch (e) {
writeAudit('partner_logo_upload', { clientAddress, user, result: 'error' });
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: e.message }));
}
});
return true;
}
return false;
}
module.exports = { MAX_LOGO_BYTES, LOGO_TARGETS, isPng, saveLogoAtomically, savePartnerLogo, slugifyName, handleLogoRoutes };
+325
View File
@@ -0,0 +1,325 @@
function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// Page templates for the Content Editor. Kept separate so content-editor.js
// stays focused on routing/handling and below the file-size limits.
const isStaging = () => process.env.CMS_DEPLOY_ENV === 'staging';
// FILE_LABELS is injected to avoid a circular dependency with the main file.
const HTML = (activeFile, jsonData, message, csrfToken, fileLabels, clientJs, contentHash, deployVersion) => `<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${isStaging() ? 'STAGING — ' : ''}mozdIT Content Editor</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; min-height: 100vh; }
.environment-banner { background: #f59e0b; color: #111827; padding: 9px 32px; text-align: center; font-size: 13px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
header { background: linear-gradient(135deg,#1a1f2e,#252d40); border-bottom: 1px solid #2d3748; padding: 14px 32px; display: flex; align-items: center; gap: 12px; }
header h1 { font-size: 17px; font-weight: 700; background: linear-gradient(135deg,#60a5fa,#a78bfa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
header span { color: #64748b; font-size: 13px; }
.tabs { display: flex; gap: 2px; padding: 14px 32px 0; border-bottom: 1px solid #2d3748; background: #13192a; }
.tab { text-decoration: none; color: #94a3b8; padding: 9px 16px; border-radius: 8px 8px 0 0; font-size: 13px; font-weight: 500; transition: all .2s; border: 1px solid transparent; border-bottom: none; margin-bottom: -1px; }
.tab:hover { color: #e2e8f0; background: #1e2535; }
.tab.active { color: #60a5fa; background: #0f1117; border-color: #2d3748; }
.page { max-width: 860px; margin: 28px auto 120px; padding: 0 24px; }
.hint { color: #475569; font-size: 12px; margin-bottom: 20px; }
/* Primitive field */
.field { background: #1a2035; border: 1px solid #2d3748; border-radius: 10px; padding: 14px 16px; transition: border-color .2s; margin-bottom: 10px; }
.field:focus-within { border-color: #60a5fa; }
.field label { display: block; font-size: 11px; font-weight: 600; color: #60a5fa; text-transform: uppercase; letter-spacing:.05em; margin-bottom: 7px; font-family: monospace; }
.field input, .field textarea { width: 100%; background: transparent; border: none; outline: none; color: #e2e8f0; font-size: 14px; line-height: 1.6; resize: vertical; font-family: inherit; }
.field textarea { min-height: 52px; }
/* Array section */
.array-section { margin-bottom: 20px; }
.array-label { font-size: 12px; font-weight: 700; color: #a78bfa; text-transform: uppercase; letter-spacing:.06em; font-family: monospace; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
.array-label::after { content:''; flex: 1; height: 1px; background: #2d3748; }
.array-items { display: flex; flex-direction: column; gap: 8px; }
/* Simple string array item */
.str-item { display: flex; gap: 8px; align-items: flex-start; }
.str-item textarea { flex: 1; background: #1a2035; border: 1px solid #2d3748; border-radius: 8px; padding: 10px 12px; color: #e2e8f0; font-size: 14px; font-family: inherit; outline: none; resize: vertical; min-height: 44px; transition: border-color .2s; }
.str-item textarea:focus { border-color: #60a5fa; }
/* Object array item (card) */
.obj-card { background: #1a2035; border: 1px solid #2d3748; border-radius: 10px; padding: 14px; position: relative; }
.obj-card .card-header { font-size: 11px; color: #64748b; font-family: monospace; margin-bottom: 10px; }
.obj-card .inner-field { margin-bottom: 8px; }
.obj-card .inner-field:last-child { margin-bottom: 0; }
.obj-card .inner-label { font-size: 10px; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing:.05em; font-family: monospace; margin-bottom: 4px; }
.obj-card input, .obj-card textarea { width: 100%; background: #0f1420; border: 1px solid #2d3748; border-radius: 6px; padding: 8px 10px; color: #e2e8f0; font-size: 13px; font-family: inherit; outline: none; resize: vertical; transition: border-color .2s; }
.obj-card input:focus, .obj-card textarea:focus { border-color: #60a5fa; }
/* Buttons */
.btn-del { background: transparent; border: 1px solid #3f1c1c; color: #f87171; border-radius: 7px; padding: 6px 10px; cursor: pointer; font-size: 13px; transition: all .2s; white-space: nowrap; flex-shrink: 0; }
.btn-del:hover { background: #3f1c1c; }
.btn-del-card { position: absolute; top: 10px; right: 10px; background: transparent; border: 1px solid #3f1c1c; color: #f87171; border-radius: 6px; padding: 4px 8px; cursor: pointer; font-size: 12px; transition: all .2s; }
.btn-del-card:hover { background: #3f1c1c; }
.btn-add { background: transparent; border: 1px dashed #334155; color: #64748b; border-radius: 8px; padding: 9px 16px; cursor: pointer; font-size: 13px; width: 100%; text-align: center; transition: all .2s; margin-top: 6px; }
.btn-add:hover { border-color: #a78bfa; color: #a78bfa; background: #1a1535; }
/* Bottom bar */
.bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; background: #0f1117; border-top: 1px solid #2d3748; padding: 14px 32px; display: flex; gap: 14px; align-items: center; z-index: 50; }
.btn-logout { background: #1f2937; color: #e2e8f0; border: 1px solid #374151; border-radius: 8px; padding: 9px 16px; font-size: 14px; cursor: pointer; }
.btn-logout:hover { background: #374151; }
.version-tag { color: #475569; font-size: 12px; font-family: monospace; }
.btn-save { background: linear-gradient(135deg,#3b82f6,#6366f1); color: #fff; border: none; padding: 11px 26px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: opacity .2s, transform .1s; }
.btn-save:hover { opacity: .9; transform: translateY(-1px); }
.btn-save:active { transform: translateY(0); }
.btn-publish { background: linear-gradient(135deg,#10b981,#059669); color: #fff; border: none; padding: 11px 26px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: opacity .2s, transform .1s; }
.btn-publish:hover { opacity: .9; transform: translateY(-1px); }
.btn-publish:active { transform: translateY(0); }
.preview-link { color: #64748b; font-size: 13px; text-decoration: none; }
.preview-link:hover { color: #94a3b8; }
/* WHY: the status slot always occupies the same flex space (visibility, not
display) so showing/hiding messages never shifts the other bar items. */
.save-status { flex: 1 1 0; min-width: 0; margin: 0 8px; font-size: 13px; font-weight: 500; visibility: hidden; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Toast */
.toast { position: fixed; top: 20px; right: 20px; padding: 13px 18px; border-radius: 9px; font-size: 14px; font-weight: 500; z-index: 200; animation: slideIn .3s ease; }
.toast.ok { background: #064e3b; border: 1px solid #10b981; color: #6ee7b7; }
.toast.err { background: #450a0a; border: 1px solid #ef4444; color: #fca5a5; }
@keyframes slideIn { from { opacity:0; transform: translateX(20px); } to { opacity:1; transform: translateX(0); } }
</style>
</head>
<body>
${isStaging() ? '<div class="environment-banner">⚠ STAGING / TESZTKÖRNYEZET — itt végzett publikálás csak a staging oldalt frissíti</div>' : ''}
${message ? `<div class="toast ${message.type === 'ok' ? 'ok' : 'err'}">${escHtml(message.text)}</div>` : ''}
<header>
<h1>mozdIT Content Editor</h1>
<span>— JSON fájlok szerkesztése vizuálisan</span>
</header>
<nav class="tabs">
${Object.entries(fileLabels).map(([k, l]) =>
`<a href="/?file=${k}" class="tab ${activeFile === k ? 'active' : ''}">${l}</a>`
).join('')}
</nav>
<div class="page">
<p class="hint">📝 Szerkeszd a mezőket. Tömbökből elemet törölhetsz (❌) vagy hozzáadhatsz (). Mentés gomb menti a fájlt.</p>
<div id="editor"></div>
</div>
<div class="bottom-bar">
<button class="btn-save" onclick="save()">💾 Mentés</button>
<button class="btn-publish" onclick="publish()" id="publishBtn">🚀 Publikálás & ${isStaging() ? 'Staging deploy' : 'Élesítés'}</button>
<span class="save-status" id="saveStatus"></span>
<a href="${isStaging() ? 'https://stage.mozdit.hu' : 'http://localhost:3000'}" target="_blank" class="preview-link">🔗 Előnézet →</a>
<a href="/guide" target="_blank" class="preview-link">❓ Súgó</a>
<a href="/versions?file=${activeFile}" target="_blank" class="preview-link">🕘 Verziók</a>
<a href="/branding" target="_blank" class="preview-link">🎨 Logó</a>
<span class="version-tag" title="Futó kód verziója (git SHA)">v${deployVersion}</span>
<button class="btn-logout" onclick="logout()">🚪 Kilépés</button>
</div>
<script id="page-data" type="application/json">${jsonData.replace(/<\//g, '<\\/')}</script>
<script>
const DATA = JSON.parse(document.getElementById('page-data').textContent);
const FILE = "${activeFile}";
const CSRF_TOKEN = "${csrfToken}";
let CONTENT_HASH = "${contentHash}";
${clientJs}
</script>
</script>
</body>
</html>`;
// User guide page — renders docs/felhasznaloi-utmutato.md with the shared dark theme.
const GUIDE_PAGE = (contentHtml) => `<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>mozdIT — Felhasználói útmutató</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; line-height: 1.65; padding-bottom: 64px; }
header { background: linear-gradient(135deg,#1a1f2e,#252d40); border-bottom: 1px solid #2d3748; padding: 14px 32px; display: flex; align-items: center; gap: 12px; position: sticky; top: 0; z-index: 10; }
header h1 { font-size: 17px; font-weight: 700; background: linear-gradient(135deg,#60a5fa,#a78bfa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
header a { color: #94a3b8; text-decoration: none; font-size: 14px; margin-left: auto; }
header a:hover { color: #e2e8f0; }
main { max-width: 760px; margin: 0 auto; padding: 32px 24px; }
h1 { font-size: 24px; margin: 16px 0 12px; color: #f1f5f9; }
h2 { font-size: 20px; margin: 28px 0 10px; color: #93c5fd; border-bottom: 1px solid #2d3748; padding-bottom: 6px; }
h3 { font-size: 16px; margin: 20px 0 8px; color: #c4b5fd; }
h4 { font-size: 14px; margin: 16px 0 6px; color: #c4b5fd; }
p { margin: 8px 0; }
ul, ol { margin: 8px 0 8px 22px; }
li { margin: 4px 0; }
a { color: #7dd3fc; }
code { background: #1e293b; border-radius: 4px; padding: 1px 6px; font-size: 0.9em; color: #fbbf24; }
pre { background: #1e293b; border: 1px solid #2d3748; border-radius: 8px; padding: 12px 16px; overflow-x: auto; margin: 12px 0; }
pre code { background: none; padding: 0; color: #e2e8f0; }
hr { border: none; border-top: 1px solid #2d3748; margin: 24px 0; }
</style>
</head>
<body>
<header>
<h1>mozdIT — Felhasználói útmutató</h1>
<a href="/">← Vissza a szerkesztőhöz</a>
</header>
<main>
${contentHtml}
</main>
</body>
</html>`;
// Login page — simple logo page shown after logout (and for unauthenticated browser visits).
const LOGIN_PAGE = () => `<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>mozdIT CMS — Belépés</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
.environment-banner { background: #f59e0b; color: #111827; padding: 9px 32px; text-align: center; font-size: 13px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; position: fixed; top: 0; left: 0; right: 0; }
.card { background: linear-gradient(160deg,#1a1f2e,#252d40); border: 1px solid #2d3748; border-radius: 16px; padding: 40px 36px; width: 100%; max-width: 380px; box-shadow: 0 20px 50px rgba(0,0,0,.45); }
.logo { text-align: center; margin-bottom: 28px; }
.logo img { height: 56px; }
h1 { font-size: 20px; font-weight: 700; text-align: center; margin-bottom: 4px; }
.subtitle { color: #94a3b8; font-size: 14px; text-align: center; margin-bottom: 26px; }
label { display: block; font-size: 13px; color: #94a3b8; margin: 14px 0 6px; }
input { width: 100%; background: #0f1117; border: 1px solid #2d3748; border-radius: 8px; color: #e2e8f0; padding: 11px 14px; font-size: 15px; }
input:focus { outline: none; border-color: #60a5fa; }
button { width: 100%; margin-top: 24px; background: linear-gradient(135deg,#3b82f6,#8b5cf6); color: #fff; border: none; border-radius: 8px; padding: 12px; font-size: 15px; font-weight: 700; cursor: pointer; }
button:hover { filter: brightness(1.1); }
button:disabled { opacity: .6; cursor: wait; }
.error { color: #f87171; font-size: 14px; text-align: center; margin-top: 14px; min-height: 20px; }
</style>
</head>
<body>
${isStaging() ? '<div class="environment-banner">⚠ STAGING / TESZTKÖRNYEZET</div>' : ''}
<div class="card">
<div class="logo"><img src="/logo.png" alt="mozdIT"></div>
<h1>Content Editor</h1>
<p class="subtitle">Belépés a tartalomszerkesztőbe</p>
<form onsubmit="return login(event)">
<label for="user">Felhasználónév</label>
<input id="user" name="user" autocomplete="username" autofocus required>
<label for="pass">Jelszó</label>
<input id="pass" name="pass" type="password" autocomplete="current-password" required>
<button type="submit" id="btn">Belépés</button>
</form>
<p class="error" id="err"></p>
</div>
<script>
async function login(e) {
e.preventDefault();
const btn = document.getElementById('btn');
const err = document.getElementById('err');
btn.disabled = true; err.textContent = '';
try {
const res = await fetch('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user: document.getElementById('user').value, pass: document.getElementById('pass').value })
});
if (res.ok) { location.href = '/'; return; }
const json = await res.json().catch(() => ({}));
err.textContent = json.error || 'Sikertelen belépés — ellenőrizd a felhasználónevet és a jelszót.';
} catch (e2) {
err.textContent = 'Hálózati hiba — próbáld újra.';
}
btn.disabled = false;
}
</script>
</body>
</html>`;
// Version history page: lists automatic backups of the selected file with a
// diff view (?show=) and one-click restore (POST /restore).
const VERSIONS_PAGE = (fileKey, fileLabel, versions, diff, csrfToken) => `<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>mozdIT — Verziók: ${fileLabel}</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; line-height: 1.6; padding-bottom: 64px; }
header { background: linear-gradient(135deg,#1a1f2e,#252d40); border-bottom: 1px solid #2d3748; padding: 14px 32px; display: flex; align-items: center; gap: 12px; position: sticky; top: 0; z-index: 10; }
header h1 { font-size: 17px; font-weight: 700; background: linear-gradient(135deg,#60a5fa,#a78bfa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
header a { color: #94a3b8; text-decoration: none; font-size: 14px; margin-left: auto; }
header a:hover { color: #e2e8f0; }
main { max-width: 860px; margin: 0 auto; padding: 28px 24px; }
.note { color: #94a3b8; font-size: 14px; margin-bottom: 18px; }
.ver { background: #1a2035; border: 1px solid #2d3748; border-radius: 10px; padding: 14px 18px; margin-bottom: 10px; display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.ver .when { font-family: monospace; font-size: 14px; color: #93c5fd; }
.ver .size { color: #64748b; font-size: 13px; }
.ver .actions { margin-left: auto; display: flex; gap: 8px; }
.btn { background: #1f2937; color: #e2e8f0; border: 1px solid #374151; border-radius: 8px; padding: 8px 14px; font-size: 13px; cursor: pointer; text-decoration: none; }
.btn:hover { background: #374151; }
.btn-restore { background: #14532d; border-color: #10b981; color: #6ee7b7; }
.btn-restore:hover { background: #166534; }
h2 { font-size: 16px; margin: 26px 0 10px; color: #93c5fd; }
.diff { background: #0f1420; border: 1px solid #2d3748; border-radius: 10px; padding: 14px; font-family: monospace; font-size: 13px; overflow-x: auto; }
.diff div { padding: 1px 10px; white-space: pre-wrap; word-break: break-all; }
.diff-add { background: #064e3b; color: #6ee7b7; }
.diff-del { background: #450a0a; color: #fca5a5; text-decoration: line-through; }
.diff-skip { color: #475569; }
.diff-ctx { color: #94a3b8; }
.empty { color: #64748b; padding: 24px; text-align: center; }
</style>
</head>
<body>
<header>
<h1>🕘 Verziók — ${fileLabel}</h1>
<a href="/?file=${fileKey}">← Vissza a szerkesztőhöz</a>
</header>
<main>
<p class="note">Minden Mentés automatikus másolatot készít. A ⚖ Összehasonlítás megmutatja az adott mentés és a <strong>jelenlegi</strong> tartalom különbségét (piros = mentésben volt, zöld = most van). A visszaállítás előtt a jelenlegi állapot is mentésre kerül, tehát a visszaállítás is visszavonható.</p>
${versions.length === 0 ? '<div class="empty">Ehhez a fájlhoz még nincs mentés.</div>' : versions.map(v => `
<div class="ver">
<span class="when">${v.when}</span>
<span class="size">${v.size} B</span>
<span class="actions">
<a class="btn" href="/versions?file=${fileKey}&show=${v.name}">⚖ Összehasonlítás</a>
<button class="btn btn-restore" onclick="restore('${v.name}')">↩ Visszaállítás</button>
</span>
</div>`).join('')}
${diff ? `
<h2>Különbség: mentés (${diff.when}) → jelenlegi tartalom</h2>
<div class="diff">${diff.diffHtml}</div>` : ''}
</main>
<script>
const CSRF_TOKEN = "${csrfToken}";
const FILE = "${fileKey}";
async function restore(name) {
if (!confirm('Biztosan visszaállítod ezt a mentést?\\nA jelenlegi tartalom mentésre kerül, így ez később is visszavonható.')) return;
try {
const res = await fetch('/restore?file=' + FILE + '&backup=' + encodeURIComponent(name), {
method: 'POST',
headers: { 'X-CSRF-Token': CSRF_TOKEN }
});
if (res.status === 401) { location.href = '/login'; return; }
const json = await res.json();
if (json.ok) { alert('✅ Visszaállítva.'); location.href = '/?file=' + FILE; }
else alert('❌ Hiba: ' + json.error);
} catch (e) { alert('❌ Hálózati hiba'); }
}
</script>
</body>
</html>`;
module.exports = { HTML, GUIDE_PAGE, LOGIN_PAGE, VERSIONS_PAGE };
+43
View File
@@ -0,0 +1,43 @@
// Publish (git commit + push) command construction and result interpretation
// for the Content Editor. Extracted so it is unit-testable in isolation.
//
// WHY the shell shape:
// - `git diff --cached --quiet && echo MARKER || git commit` — commit only when
// staged changes exist; a skipped commit must NOT produce a failing exit code
// (that was the original bug: "nothing added to commit" surfaced as an error).
// - the MARKER echo is the only reliable signal for "no content changes": plain
// output matching ("Already up to date", "Everything up-to-date") also appears
// after REAL publishes (the pull prints it when the remote did not move), which
// used to misclassify genuine publishes as no-ops.
// - `git pull --rebase || (git rebase --abort; false)` — a failed rebase must be
// aborted, otherwise the repo stays mid-rebase and every later publish fails
// with "cannot pull with rebase".
const NO_CHANGES_MARKER = '__NO_CONTENT_CHANGES__';
function buildPublishCommand(commitMessage) {
return [
'git add .',
// WHY: logo uploads live in proto/public — 2 levels above the content cwd — so
// stage them too (tolerant: optional path in test throwaway repos, stderr muted).
'(git add ../../public 2>/dev/null || true)',
`(git diff --cached --quiet && echo ${NO_CHANGES_MARKER} || git commit -m "${commitMessage}")`,
'(git pull --rebase origin main || (git rebase --abort; false))',
'git push origin main',
].join(' && ');
}
function interpretPublishResult(error, stdout, stderr) {
const hadChanges = !stdout.includes(NO_CHANGES_MARKER);
if (error) {
return { ok: false, hadChanges, result: 'error', error: stderr || stdout || error.message };
}
return {
ok: true,
hadChanges,
result: hadChanges ? 'ok' : 'no_changes',
output: hadChanges ? stdout : 'Nincs új változtatás.',
};
}
module.exports = { NO_CHANGES_MARKER, buildPublishCommand, interpretPublishResult };
+65
View File
@@ -0,0 +1,65 @@
// POST /save handler for the Content Editor — extracted to keep content-editor.js
// under the 400-line hard limit. Returns true when the request was handled.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
function handleSaveRoute({
req, res, u, activeFile, files, maxBodyBytes, validate,
writeAudit, backupAndWrite, backupDir, user, clientAddress, cmsDirname,
}) {
if (req.method !== 'POST' || u.pathname !== '/save') return false;
let body = '';
let bodyTooLarge = false;
let bodySize = 0;
req.on('data', c => {
bodySize += c.length;
if (bodySize > maxBodyBytes) { bodyTooLarge = true; return; }
body += c;
});
req.on('end', () => {
try {
if (bodyTooLarge) {
writeAudit('content_saved', { clientAddress, user, file: activeFile, result: 'request_too_large' });
res.writeHead(413, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: `A kérés túl nagy (maximum ${maxBodyBytes} byte)` }));
return;
}
const data = JSON.parse(body);
// Optimistic locking: the editor echoes the fingerprint of the content it
// loaded. If the file changed since (deploy, another tab, git), a blind
// save would silently overwrite those changes — reject with 409 instead.
const clientHash = req.headers['x-content-hash'];
const currentOnDisk = fs.readFileSync(files[activeFile], 'utf8').trim();
const currentHash = crypto.createHash('sha256').update(currentOnDisk).digest('hex');
if (typeof clientHash !== 'string' || clientHash !== currentHash) {
writeAudit('content_saved', { clientAddress, user, file: activeFile, result: 'conflict' });
res.writeHead(409, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'A tartalom megváltozott, mióta ezt a lapot megnyitottad (pl. deploy vagy másik fül mentett). Frissítsd az oldalt, és végezd el újra a módosításokat.' }));
return;
}
const validation = validate(activeFile, data);
if (!validation.ok) {
writeAudit('content_saved', { clientAddress, user, file: activeFile, result: 'validation_failed' });
res.writeHead(422, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: validation.errors.join('; '), errors: validation.errors }));
return;
}
const backupFile = backupAndWrite(files[activeFile], data, backupDir);
// Return the hash of the written content so the editor tab can refresh its
// fingerprint — otherwise the user's OWN next save would trip the lock.
const newHash = crypto.createHash('sha256').update(JSON.stringify(data, null, 2).trim()).digest('hex');
writeAudit('content_saved', { clientAddress, user, file: activeFile, result: 'ok' });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, backup: path.relative(cmsDirname, backupFile), contentHash: newHash }));
} catch (e) {
writeAudit('content_saved', { clientAddress, user, file: activeFile, result: 'error' });
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: e.message }));
}
});
return true;
}
module.exports = { handleSaveRoute };
+66
View File
@@ -0,0 +1,66 @@
// WHY: Basic Auth has no native logout and its dialog cannot be styled, so a
// successful /login form submit receives a server-side session token in an
// HttpOnly cookie. Basic Auth remains valid in parallel (curl, API use).
const crypto = require('crypto');
const SESSION_COOKIE = 'cms_session';
const SESSION_TTL_MS = 8 * 60 * 60 * 1000;
const sessions = new Map(); // token -> expiresAt (ms)
function timingSafeMatch(candidate, expected) {
if (typeof candidate !== 'string' || typeof expected !== 'string') return false;
const cHash = crypto.createHash('sha256').update(candidate).digest();
const eHash = crypto.createHash('sha256').update(expected).digest();
return crypto.timingSafeEqual(cHash, eHash);
}
function validateLogin(user, pass, expectedUser, expectedPass) {
if (!expectedUser || !expectedPass) return false;
const userOk = timingSafeMatch(user, expectedUser);
const passOk = timingSafeMatch(pass, expectedPass);
return Boolean(userOk && passOk);
}
function createSessionCookie(isSecure) {
const token = crypto.randomBytes(32).toString('hex');
sessions.set(token, Date.now() + SESSION_TTL_MS);
return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}${isSecure ? '; Secure' : ''}`;
}
function clearSessionCookie() {
return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`;
}
function getSessionToken(req) {
const cookies = req.headers.cookie || '';
const match = cookies.match(new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([a-f0-9]+)`));
return match ? match[1] : null;
}
function hasValidSession(req) {
const token = getSessionToken(req);
if (!token) return false;
const expiresAt = sessions.get(token);
if (!expiresAt) return false;
if (Date.now() > expiresAt) {
sessions.delete(token);
return false;
}
return true;
}
function deleteSession(req) {
const token = getSessionToken(req);
if (token) sessions.delete(token);
}
module.exports = {
SESSION_COOKIE,
SESSION_TTL_MS,
timingSafeMatch,
validateLogin,
createSessionCookie,
clearSessionCookie,
hasValidSession,
deleteSession,
};
+112
View File
@@ -0,0 +1,112 @@
// Version history helpers for the CMS: listing automatic backups from
// .content-backups, safe backup-name validation, diff assembly and the
// /versions + /restore route handlers.
const fs = require('fs');
const path = require('path');
const { renderDiffHtml } = require('./cms-diff');
const { backupAndWriteAtomically: backupAndWrite } = require('./cms-core');
// Backup files are named `<fileKey>.<ISO-ish timestamp>.json`
const BACKUP_NAME_RE = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/;
// WHY: the backup name arrives as a query parameter — only allow the exact
// `<fileKey>.<timestamp>.json` shape so path traversal (`../`) is impossible.
function safeBackupName(fileKey, candidate) {
if (typeof candidate !== 'string' || !candidate.startsWith(`${fileKey}.`) || !candidate.endsWith('.json')) return null;
const ts = candidate.slice(fileKey.length + 1, -5);
if (!BACKUP_NAME_RE.test(ts)) return null;
return candidate;
}
function formatBackupTimestamp(fileKey, backupName) {
const ts = backupName.slice(fileKey.length + 1, -5);
const m = ts.match(BACKUP_NAME_RE);
if (!m) return ts;
return `${m[1]} ${m[2]}:${m[3]}:${m[4]}`;
}
function listVersions(backupDir, fileKey) {
try {
return fs.readdirSync(backupDir)
.filter(name => safeBackupName(fileKey, name))
.map(name => {
const full = path.join(backupDir, name);
const stat = fs.statSync(full);
return { name, size: stat.size, when: formatBackupTimestamp(fileKey, name) };
})
.sort((a, b) => b.name.localeCompare(a.name)); // newest first
} catch {
return [];
}
}
function readBackupContent(backupDir, backupName) {
return fs.readFileSync(path.join(backupDir, backupName), 'utf8');
}
// Compare a backup with the current file content; returns both pretty texts and
// the rendered diff HTML (backup = old/left, current = new/right).
function buildVersionDiff(backupDir, currentFilePath, fileKey, backupName) {
const backupText = readBackupContent(backupDir, backupName);
const currentText = fs.readFileSync(currentFilePath, 'utf8');
return {
backupName,
when: formatBackupTimestamp(fileKey, backupName),
backupText: backupText.trim(),
currentText: currentText.trim(),
diffHtml: renderDiffHtml(backupText, currentText),
};
}
// WHY: route handling extracted here so content-editor.js stays under the
// 400-line limit. Returns true when the request was handled.
function handleVersionRoutes({ req, res, u, activeFile, backupDir, currentFile, validate, writeAudit, csrfOk, clientAddress, user, versionsPage }) {
if (req.method === 'GET' && u.pathname === '/versions') {
const versions = listVersions(backupDir, activeFile);
let diff = null;
const showRaw = u.searchParams.get('show');
if (showRaw) {
const safe = safeBackupName(activeFile, showRaw);
if (safe) {
try {
diff = buildVersionDiff(backupDir, currentFile, activeFile, safe);
} catch { /* unreadable backup: render list only */ }
}
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(versionsPage(activeFile, diff));
return true;
}
if (req.method === 'POST' && u.pathname === '/restore') {
const backup = safeBackupName(activeFile, u.searchParams.get('backup') || '');
if (!backup) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'Érvénytelen mentésnév.' }));
return true;
}
try {
const data = JSON.parse(readBackupContent(backupDir, backup));
const validation = validate(activeFile, data);
if (!validation.ok) {
writeAudit('version_restored', { clientAddress, user, file: activeFile, backup, result: 'validation_failed' });
res.writeHead(422, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'A mentés nem felel meg a sémának: ' + validation.errors.join('; ') }));
return true;
}
backupAndWrite(currentFile, data, backupDir);
writeAudit('version_restored', { clientAddress, user, file: activeFile, backup, result: 'ok' });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
} catch (e) {
writeAudit('version_restored', { clientAddress, user, file: activeFile, backup, result: 'error' });
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: e.message }));
}
return true;
}
return false;
}
module.exports = { safeBackupName, listVersions, readBackupContent, buildVersionDiff, formatBackupTimestamp, handleVersionRoutes };
+1 -7
View File
@@ -45,14 +45,8 @@ printf '2/4 Commit feltolása a Gitea-ra…\n'
git push origin "${DEPLOY_BRANCH}" git push origin "${DEPLOY_BRANCH}"
printf '3/4 Staging deploy (%s:%s)…\n' "${STAGE_HOST}" "${STAGE_PATH}" printf '3/4 Staging deploy (%s:%s)…\n' "${STAGE_HOST}" "${STAGE_PATH}"
# WHY nincs itt már "sudo systemctl restart mozdit-content-editor.service": az a
# régi egyedi CMS (content-editor.js) külön systemd szolgáltatása volt — a
# fájlt MITHOME-93 leépítette, a Docker-compose alapú app (deploy.sh) a
# régóta egyedüli kiszolgáló. A szerveren futó mozdit-content-editor.service
# unit-ot (ha még létezik) manuálisan kell leállítani/letiltani — ezt a
# scriptet nem futtatjuk SSH-n keresztül automatikusan.
ssh -o BatchMode=yes -o ConnectTimeout=15 "${STAGE_HOST}" \ ssh -o BatchMode=yes -o ConnectTimeout=15 "${STAGE_HOST}" \
"cd '${STAGE_PATH}' && git pull --ff-only origin '${DEPLOY_BRANCH}' && ./deploy.sh staging" "cd '${STAGE_PATH}' && git pull --ff-only origin '${DEPLOY_BRANCH}' && ./deploy.sh staging && sudo systemctl restart mozdit-content-editor.service"
printf '4/4 Staging smoke teszt (%s)…\n' "${STAGE_URL}" printf '4/4 Staging smoke teszt (%s)…\n' "${STAGE_URL}"
( (

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