Compare commits
55
Commits
58947328d7
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d699bf4787 | ||
|
|
758ee2cfed | ||
|
|
3573d2accb | ||
|
|
1bf32dd076 | ||
|
|
e2b0c62628 | ||
|
|
a7e0530068 | ||
|
|
bf6c8f7abd | ||
|
|
d2f960207e | ||
|
|
d60c38f65a | ||
|
|
314b129259 | ||
|
|
c07ec2298c | ||
|
|
d207e5653e | ||
|
|
02a73b3702 | ||
|
|
74e40329a0 | ||
|
|
3f72d714df | ||
|
|
265b656df9 | ||
|
|
00d2b82877 | ||
|
|
729268a3fe | ||
|
|
94647d5293 | ||
|
|
d8cccc1a65 | ||
|
|
1b3ae07811 | ||
|
|
19622d9703 | ||
|
|
12b2711168 | ||
|
|
21a0d73639 | ||
|
|
d445f0f31a | ||
|
|
b5590175b7 | ||
|
|
d6f3dda9e5 | ||
|
|
594865ea9a | ||
|
|
8407b45367 | ||
|
|
71ab1e08ac | ||
|
|
fb11eff18b | ||
|
|
30f271651a | ||
|
|
6589289878 | ||
|
|
1b587ac373 | ||
|
|
776fa66bc8 | ||
|
|
928439bc7b | ||
|
|
1c9e231a16 | ||
|
|
329269aeb1 | ||
|
|
17c6d63ae3 | ||
|
|
570f4b45e5 | ||
|
|
91a9d97a59 | ||
|
|
72a5a10476 | ||
|
|
c4e3d91640 | ||
|
|
a3d7e9ce75 | ||
|
|
db2f38743f | ||
|
|
4c54c639e5 | ||
|
|
018a1f2767 | ||
|
|
3132fcb8eb | ||
|
|
3183098926 | ||
|
|
a661739844 | ||
|
|
2a4f33172b | ||
|
|
849acf4c34 | ||
|
|
1600d99c5d | ||
|
|
30364fcab7 | ||
|
|
6cb99f603a |
+8
-4
@@ -80,10 +80,15 @@ 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/ # Next.js App Router (pages és API routes)
|
│ │ ├── app/
|
||||||
|
│ │ │ ├── (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)
|
||||||
│ │ ├── content/ # JSON tartalom-kezelő rendszer
|
│ │ ├── globals/ # Payload Globals (Home, About, Services, Contact, Common)
|
||||||
│ │ ├── lib/ # Utility könyvtárak (MongoDB, Logger, Site Config)
|
│ │ ├── collections/ # Payload Collections (LegalPages, Partners, Media, ContactSubmissions, Users)
|
||||||
|
│ │ ├── 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
|
||||||
@@ -279,7 +284,6 @@ 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 |
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ 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.
|
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).
|
||||||
|
|
||||||
- **Frontend**: Next.js 15 App Router (SSR/SSG), React 19, Tailwind CSS 4
|
- **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
|
||||||
- **Backend**: Next.js API Routes (`/api/*`)
|
- **CMS**: Payload CMS — Globals (Home/About/Services/Contact/Common) + Collections (LegalPages/Partners/Media/ContactSubmissions/Users), hu/en lokalizáció, draft/publish + verziózás
|
||||||
- **Adatbázis**: MongoDB (Mongoose ODM) — site config és contact form logok
|
- **Backend**: Next.js API Routes (`/api/*`), Payload REST/GraphQL API + Local API
|
||||||
|
- **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`)
|
||||||
|
|
||||||
@@ -24,13 +25,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** | JSON alapú tartalom | `proto/src/content/` |
|
| **Content** | Payload CMS Globals/Collections (admin: `/admin`), Local API adapter | `proto/src/globals/`, `proto/src/collections/`, `proto/src/lib/payload-content.ts` |
|
||||||
| **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 JSON-ban**: SOHA ne égess be szöveget közvetlenül komponensbe — használd a `src/content/` rendszert.
|
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.)
|
||||||
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.
|
||||||
@@ -55,14 +56,21 @@ A projekt egy **Next.js 15 alapú marketing weboldal** a mozdIT Bt. számára, A
|
|||||||
## Content Management
|
## Content Management
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Helyes: content rendszer használata
|
// Helyes: Payload Local API adapter használata (Server Component-ben)
|
||||||
import { content, getPageContent } from '@/content'
|
import { getAboutContent } from '@/lib/payload-content'
|
||||||
const pageData = content.pages.about
|
const about = await getAboutContent(locale) // locale: 'hu' | 'en'
|
||||||
|
|
||||||
// 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**
|
||||||
|
|||||||
@@ -76,9 +76,8 @@ 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 CMS **❓ Súgó** menüpontja a `docs/felhasznaloi-utmutato.md` fájlt rendereli (`/guide`).
|
- 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.
|
||||||
- **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.
|
- **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.
|
||||||
- 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
|
||||||
|
|
||||||
@@ -143,8 +142,9 @@ 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: JSON content management vs CMS — könnyebb deploy, de nem non-tech szerkeszthető
|
// TRADEOFF: Payload Local API vs statikus JSON import — élő, adminból
|
||||||
const content = await import('@/content/pages/home.json');
|
// szerkeszthető tartalom, cserébe minden kérésnél DB-olvasás kell (MITHOME-97)
|
||||||
|
const content = await getHomeContent(locale);
|
||||||
```
|
```
|
||||||
|
|
||||||
## Pre-modification kockázatbecslés
|
## Pre-modification kockázatbecslés
|
||||||
|
|||||||
+26
-17
@@ -44,6 +44,7 @@ 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
|
||||||
```
|
```
|
||||||
@@ -77,22 +78,30 @@ 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ó, a CMS
|
Ez lefedi: proto unit + `tsc --noEmit` + `eslint`, content séma-validáció és a
|
||||||
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 CMS (content-editor.js) tesztjei külön is futtathatók (valódi szervert indítanak):
|
> A régi egyedi CMS (content-editor.js) saját teszt-szkriptjeit (auth, CSRF,
|
||||||
|
> serializer, optimista zárolás, verziók panel, logó feltöltés, login/logout,
|
||||||
```bash
|
> guide, publish) a Payload CMS-re állás után (MITHOME-93) eltávolítottuk —
|
||||||
node scripts/test-content-editor-security.js # auth, CSRF, XFF
|
> a Payload admin felület a saját upstream tesztelésével fedett, ezt itt nem
|
||||||
node scripts/test-content-editor-serializer.js # collect/reindex regresszió
|
> duplikáljuk.
|
||||||
node scripts/test-content-editor-save.js # atomikus mentés + validáció
|
>
|
||||||
node scripts/test-content-editor-conflict.js # optimista zárolás (409)
|
> **MITHOME-96**: Payload collection/global config tesztek —
|
||||||
node scripts/test-content-editor-versions.js # verziók panel (diff, restore)
|
> `src/payload-config.test.ts` (a `npm test`/pre-deploy suite része, nincs
|
||||||
node scripts/test-content-editor-logo.js # logó feltöltés
|
> hozzá élő DB, csak a Collection/Global config objektumokat vizsgálja —
|
||||||
node scripts/test-content-editor-login.js # login flow (session, Safari)
|
> köztük két, MITHOME-121-ben talált éles hibára regresszió-őrt: a Media
|
||||||
node scripts/test-content-editor-logout.js # logout + rate-limit
|
> publikus olvashatósága, a Partners.logo opcionalitása). A Local API elleni,
|
||||||
node scripts/test-content-editor-guide.js # /guide + markdown renderer
|
> élő MongoDB-t igénylő tesztek külön scriptben vannak
|
||||||
node scripts/test-content-editor-bottombar.js # alsó sáv layout guard
|
> (`scripts/test-payload-local-api.ts`, `npm run test:payload`) — WHY nem
|
||||||
node scripts/test-cms-publish.js # publish parancs + integráció
|
> Jest: a `payload` csomag ESM-only dist-et ad ki, amit a next/jest
|
||||||
```
|
> 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).
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
@@ -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; a Content Editor is staging feliratot kap.
|
- **Staging megkülönböztetés**: a `NEXT_PUBLIC_DEPLOY_ENV=staging` build arg amber banner-t jelenít meg a weboldalon.
|
||||||
|
|
||||||
## Előfeltételek
|
## Előfeltételek
|
||||||
|
|
||||||
@@ -44,10 +44,11 @@ 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.
|
||||||
|
|
||||||
## CMS-ből történő publikálás
|
## Tartalom publikálása (Payload admin)
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Production deploy (élesítés)
|
## Production deploy (élesítés)
|
||||||
|
|
||||||
|
|||||||
@@ -55,8 +55,12 @@ 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)
|
||||||
- Módosítsd a megfelelő `proto/src/content/pages/*.json` fájlt
|
- Új mező esetén bővítsd a megfelelő `proto/src/globals/*.ts` vagy
|
||||||
- Frissítsd a `types.ts`-t ha új content struktúra kell
|
`proto/src/collections/*.ts` Payload config fájlt, majd generáld újra a
|
||||||
|
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"`
|
||||||
|
|||||||
@@ -74,7 +74,8 @@ 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: proto/src/content/pages/home.json → content.pages.home.*
|
Át kell tenni Payload mezőbe (proto/src/globals/*.ts vagy collections/*.ts),
|
||||||
|
é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á.
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
@@ -0,0 +1,685 @@
|
|||||||
|
<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‑09‑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>
|
||||||
@@ -7,6 +7,21 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,21 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -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, MongoDB, Winston
|
- **Stack**: Next.js 15, React 19, TypeScript, Tailwind CSS 4, Payload CMS (self-hosted, `/admin`), 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** → `proto/src/content/pages/*.json`
|
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.)
|
||||||
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,19 +47,24 @@ 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 Router: oldalak és API route-ok
|
│ ├── app/
|
||||||
|
│ │ ├── (frontend)/ # Publikus oldalak: [locale]/[slug] catch-all
|
||||||
|
│ │ └── (payload)/ # Payload admin (/admin) + REST/GraphQL API
|
||||||
│ ├── components/ # React komponensek (PascalCase.tsx)
|
│ ├── components/ # React komponensek (PascalCase.tsx)
|
||||||
│ ├── content/ # JSON tartalom-kezelő rendszer
|
│ ├── globals/ # Payload Globals (Home, About, Services, Contact, Common)
|
||||||
│ │ ├── common.json # Közös szövegek
|
│ ├── collections/ # Payload Collections (LegalPages, Partners, Media, ContactSubmissions, Users)
|
||||||
│ │ └── pages/ # Oldal-specifikus JSONok
|
│ ├── payload.config.ts # Payload CMS konfiguráció
|
||||||
│ ├── lib/ # MongoDB, Logger, Site Config
|
│ ├── content/ # RÉGI JSON rendszer — csak migrációs seed + teszt-fixture (MITHOME-91/93/96)
|
||||||
|
│ │ ├── 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, /cms-feature, /review, /deploy
|
│ ├── workflows/ # /new-feature, /fix-bug, /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
|
||||||
@@ -76,6 +81,8 @@ 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -97,7 +104,6 @@ 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 |
|
||||||
@@ -37,6 +37,8 @@ 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
|
||||||
|
|||||||
@@ -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
|
||||||
- **Tartalom**: JSON-alapú, séma-validált content rendszer (`proto/src/content/`)
|
- **CMS**: Payload CMS (self-hosted, `/admin`), MongoDB adapter, draft/publish + verziózás, hu/en lokalizáció
|
||||||
- **CMS**: saját, dependency-mentes `content-editor.js` (böngészős szerkesztő)
|
- **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)
|
||||||
- **Backend**: Next.js API routes, MongoDB
|
- **Backend**: Next.js API routes, MongoDB
|
||||||
- **Tesztelés**: Jest, React Testing Library, Playwright (smoke), valódi szervert indító CMS-tesztek
|
- **Tesztelés**: Jest, React Testing Library, Playwright (smoke)
|
||||||
- **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,32 +39,33 @@ 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 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`.
|
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`.
|
||||||
|
|
||||||
## Tartalomkezelés
|
## Tartalomkezelés
|
||||||
|
|
||||||
A weboldal szövegei és a CMS a `proto/src/content/` JSON-fájljaiból jönnek:
|
A weboldal szövegei Payload CMS Globals/Collections mezőkben élnek, az ügyfél a `/admin` felületen szerkeszti őket:
|
||||||
|
|
||||||
```
|
```
|
||||||
proto/src/content/
|
proto/src/globals/ # Home, About, Services, Contact, Common
|
||||||
├── schema.js # közös séma-validátor (Next + CMS)
|
proto/src/collections/ # LegalPages, Partners, Media, ContactSubmissions, Users
|
||||||
├── types.ts # TypeScript típusok
|
proto/src/payload.config.ts
|
||||||
├── 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:
|
Használat Server Component-ből (a Payload Local API-n keresztül):
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { content, getPageContent } from '@/content'
|
import { getAboutContent } from '@/lib/payload-content'
|
||||||
const about = content.pages.about
|
const about = await getAboutContent(locale) // locale: 'hu' | 'en'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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/`
|
||||||
- **CMS felhasználói útmutató**: `docs/felhasznaloi-utmutato.md` (a CMS-ben a ❓ Súgó is ezt rendereli)
|
- **nginx vhost-ok**: `docs/nginx-vhosts.md`
|
||||||
- **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`
|
||||||
|
|||||||
@@ -97,6 +97,54 @@ 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 | 📋 |
|
||||||
@@ -160,7 +208,33 @@ 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-110–116) 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-100–108) 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-86–97) 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.
|
||||||
|
|||||||
@@ -1,362 +0,0 @@
|
|||||||
#!/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,
|
|
||||||
};
|
|
||||||
@@ -42,8 +42,12 @@ 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 az APP_PORT-ot, ezért a healthcheckhez expliciten kiolvassuk.
|
# a shell nem látja belőle ezeket az értékeket, ezért a healthcheckhez és az
|
||||||
APP_PORT_VALUE="$(grep -E '^APP_PORT=' "$ENV_FILE" | tail -n 1 | cut -d= -f2- | tr -d '[:space:]' | tr -d '"' | tr -d "'")"
|
# admin-seedeléshez expliciten kiolvassuk.
|
||||||
|
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..."
|
||||||
@@ -53,15 +57,61 @@ 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"
|
||||||
echo "✅ Deploy sikeres: [$ENV]"
|
HEALTHY=1
|
||||||
exit 0
|
break
|
||||||
fi
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "❌ Healthcheck sikertelen: $HEALTH_URL"
|
if [ "$HEALTHY" -ne 1 ]; then
|
||||||
docker compose -f "$COMPOSE_FILE" logs app --tail 50
|
echo "❌ Healthcheck sikertelen: $HEALTH_URL"
|
||||||
exit 1
|
docker compose -f "$COMPOSE_FILE" logs app --tail 50
|
||||||
|
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]"
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# Next.js Application
|
# Next.js Application
|
||||||
app:
|
app:
|
||||||
@@ -18,6 +16,9 @@ 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
|
||||||
|
|||||||
@@ -17,10 +17,17 @@ 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:
|
||||||
@@ -52,6 +59,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
mongodb_data_prod:
|
mongodb_data_prod:
|
||||||
driver: local
|
driver: local
|
||||||
|
media_data_prod:
|
||||||
|
driver: local
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
mozdit-network:
|
mozdit-network:
|
||||||
|
|||||||
@@ -18,11 +18,20 @@ 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:
|
||||||
@@ -54,6 +63,8 @@ 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
@@ -1,122 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# 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.
|
||||||
@@ -39,3 +39,9 @@ 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
|
||||||
|
|||||||
+9
-11
@@ -1,16 +1,14 @@
|
|||||||
import { dirname } from "path";
|
// NOTE (Next.js 16 / eslint-config-next 16): the FlatCompat("next/core-web-vitals")
|
||||||
import { fileURLToPath } from "url";
|
// bridge to the legacy .eslintrc-style config caused a circular-JSON crash in
|
||||||
import { FlatCompat } from "@eslint/eslintrc";
|
// ESLint 9 (the "react" plugin object closes a cycle when re-validated through
|
||||||
|
// FlatCompat). eslint-config-next now ships native flat-config arrays, so
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
// import those directly instead of going through the compat layer.
|
||||||
const __dirname = dirname(__filename);
|
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTypescript from "eslint-config-next/typescript";
|
||||||
const compat = new FlatCompat({
|
|
||||||
baseDirectory: __dirname,
|
|
||||||
});
|
|
||||||
|
|
||||||
const eslintConfig = [
|
const eslintConfig = [
|
||||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
...nextCoreWebVitals,
|
||||||
|
...nextTypescript,
|
||||||
{
|
{
|
||||||
ignores: [
|
ignores: [
|
||||||
"node_modules/**",
|
"node_modules/**",
|
||||||
|
|||||||
+17
-7
@@ -1,14 +1,20 @@
|
|||||||
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',
|
||||||
|
|
||||||
// Skip linting during build for faster Docker builds
|
// NOTE (Next.js 16): `next dev` auto-generates AGENTS.md/CLAUDE.md stub
|
||||||
eslint: {
|
// files describing the framework to AI agents. This project already has
|
||||||
ignoreDuringBuilds: true,
|
// its own agent instruction system (root CLAUDE.md -> .agent/) — a second,
|
||||||
},
|
// 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: {
|
||||||
ignoreBuildErrors: true,
|
ignoreBuildErrors: true,
|
||||||
@@ -71,4 +77,8 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
// MITHOME-86: bundles the admin UI's route handlers correctly under
|
||||||
|
// 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);
|
||||||
|
|||||||
Generated
+4216
-466
File diff suppressed because it is too large
Load Diff
+13
-4
@@ -27,14 +27,22 @@
|
|||||||
"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": "^15.5.23",
|
"next": "^16.3.4",
|
||||||
|
"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",
|
||||||
@@ -49,10 +57,11 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@types/winston": "^2.4",
|
"@types/winston": "^2.4",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.5.2",
|
"eslint-config-next": "^16.3.4",
|
||||||
"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",
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* 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()
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
})
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
})
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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'])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* 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', () => {
|
||||||
@@ -27,24 +31,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="/rolunk"')
|
expect(html).toContain('href="/hu/rolunk"')
|
||||||
expect(html).toContain('href="/szolgaltatasok"')
|
expect(html).toContain('href="/hu/szolgaltatasok"')
|
||||||
expect(html).toContain('href="/kapcsolat"')
|
expect(html).toContain('href="/hu/kapcsolat"')
|
||||||
|
|
||||||
// Test about page
|
// Test about page
|
||||||
response = await fetch(`${APP_URL}/rolunk`)
|
response = await fetch(`${APP_URL}/hu/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}/szolgaltatasok`)
|
response = await fetch(`${APP_URL}/hu/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}/kapcsolat`)
|
response = await fetch(`${APP_URL}/hu/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')
|
||||||
@@ -210,9 +214,9 @@ describe('Docker E2E Tests', () => {
|
|||||||
|
|
||||||
const pages = [
|
const pages = [
|
||||||
{ url: '', title: 'mozdIT Bt.' },
|
{ url: '', title: 'mozdIT Bt.' },
|
||||||
{ url: '/rolunk', title: 'Rólunk' },
|
{ url: '/hu/rolunk', title: 'Rólunk' },
|
||||||
{ url: '/szolgaltatasok', title: 'Szolgáltatásaink' },
|
{ url: '/hu/szolgaltatasok', title: 'Szolgáltatásaink' },
|
||||||
{ url: '/kapcsolat', title: 'Kapcsolatfelvétel' }
|
{ url: '/hu/kapcsolat', title: 'Kapcsolat' }
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const page of pages) {
|
for (const page of pages) {
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* 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'
|
||||||
@@ -72,14 +80,18 @@ describe('Docker Environment Integration Tests', () => {
|
|||||||
|
|
||||||
const mozditDb = mongoClient.db('mozdit')
|
const mozditDb = mongoClient.db('mozdit')
|
||||||
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)
|
||||||
expect(collectionNames).toContain('site_config')
|
// Payload Globals (Home/About/Services/Contact/Common) live in a
|
||||||
expect(collectionNames).toContain('contact_submissions')
|
// single `globals` collection, one document per global.
|
||||||
|
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 site config data exists', async () => {
|
it('should verify the Home global exists and is published', async () => {
|
||||||
if (process.env.NODE_ENV === 'test' && !process.env.INTEGRATION_TESTS) {
|
if (process.env.NODE_ENV === 'test' && !process.env.INTEGRATION_TESTS) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -88,17 +100,14 @@ describe('Docker Environment Integration Tests', () => {
|
|||||||
mongoClient = new MongoClient(DOCKER_SERVICES.mongodb)
|
mongoClient = new MongoClient(DOCKER_SERVICES.mongodb)
|
||||||
await mongoClient.connect()
|
await mongoClient.connect()
|
||||||
}
|
}
|
||||||
|
|
||||||
const mozditDb = mongoClient.db('mozdit')
|
|
||||||
const siteConfig = await mozditDb.collection('site_config').findOne()
|
|
||||||
|
|
||||||
if (!siteConfig) throw new Error('site_config document not found')
|
const mozditDb = mongoClient.db('mozdit')
|
||||||
expect(siteConfig).toBeTruthy()
|
const home = await mozditDb.collection('globals').findOne({ globalType: 'home' })
|
||||||
expect(siteConfig).toHaveProperty('type', 'site_config')
|
|
||||||
expect(siteConfig).toHaveProperty('environment', 'development')
|
if (!home) throw new Error('Home global document not found')
|
||||||
expect(siteConfig).toHaveProperty('data')
|
expect(home).toHaveProperty('_status', 'published')
|
||||||
expect(siteConfig.data).toHaveProperty('general')
|
expect(home).toHaveProperty('hero')
|
||||||
expect(siteConfig.data.general).toHaveProperty('name', 'mozdIT Bt.')
|
expect(home.hero).toHaveProperty('title')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should access Mongo Express UI', async () => {
|
it('should access Mongo Express UI', async () => {
|
||||||
@@ -179,7 +188,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,
|
||||||
@@ -256,7 +265,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', 'Spam gyanús tartalom észlelve')
|
expect(result).toHaveProperty('error', 'Az üzenet spam gyanús tartalmat tartalmaz.')
|
||||||
} 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')
|
||||||
@@ -270,13 +279,14 @@ 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 email szolgáltatás')
|
||||||
expect(html).toContain('Webmail Ugrás')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should load about page', async () => {
|
it('should load about page', async () => {
|
||||||
@@ -284,9 +294,9 @@ describe('Docker Environment Integration Tests', () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${DOCKER_SERVICES.app}/rolunk`)
|
const response = await fetch(`${DOCKER_SERVICES.app}/hu/rolunk`)
|
||||||
expect(response.status).toBe(200)
|
expect(response.status).toBe(200)
|
||||||
|
|
||||||
const html = await response.text()
|
const html = await response.text()
|
||||||
expect(html).toContain('Rólunk')
|
expect(html).toContain('Rólunk')
|
||||||
expect(html).toContain('mozdIT Bt.')
|
expect(html).toContain('mozdIT Bt.')
|
||||||
@@ -297,14 +307,14 @@ describe('Docker Environment Integration Tests', () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${DOCKER_SERVICES.app}/szolgaltatasok`)
|
const response = await fetch(`${DOCKER_SERVICES.app}/hu/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('Web Hosting')
|
expect(html).toContain('Webtárhely')
|
||||||
expect(html).toContain('Email Szolgáltatás')
|
expect(html).toContain('E-mail 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 () => {
|
||||||
@@ -312,9 +322,9 @@ describe('Docker Environment Integration Tests', () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${DOCKER_SERVICES.app}/kapcsolat`)
|
const response = await fetch(`${DOCKER_SERVICES.app}/hu/kapcsolat`)
|
||||||
expect(response.status).toBe(200)
|
expect(response.status).toBe(200)
|
||||||
|
|
||||||
const html = await response.text()
|
const html = await response.text()
|
||||||
// The page title is "Kapcsolat" not "Kapcsolatfelvétel"
|
// The page title is "Kapcsolat" not "Kapcsolatfelvétel"
|
||||||
expect(html).toContain('Kapcsolat')
|
expect(html).toContain('Kapcsolat')
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
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} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
Executable
+44
@@ -0,0 +1,44 @@
|
|||||||
|
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} />
|
||||||
|
}
|
||||||
@@ -2,11 +2,8 @@ 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 Header from "../components/Header";
|
import { ThemeProvider } from "../../components/ThemeProvider";
|
||||||
import Footer from "../components/Footer";
|
import { siteConfig, getSiteDescription } from "../../config/site";
|
||||||
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",
|
||||||
@@ -18,47 +15,18 @@ 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} | ${siteConfig.general.description}`,
|
title: siteConfig.general.name,
|
||||||
description: siteConfig.general.description,
|
description: getSiteDescription("hu"),
|
||||||
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.
|
||||||
@@ -71,7 +39,6 @@ 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>
|
||||||
@@ -95,16 +62,7 @@ export default function RootLayout({
|
|||||||
style={{ background: 'var(--color-background)', color: 'var(--color-foreground)' }}
|
style={{ background: 'var(--color-background)', color: 'var(--color-foreground)' }}
|
||||||
>
|
>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
{isStaging && (
|
{children}
|
||||||
<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>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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 you’re looking for could not be found.
|
||||||
|
</p>
|
||||||
|
<Link href={localePath(DEFAULT_LOCALE)} className="btn btn-primary">
|
||||||
|
mozdIT Bt.
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import config from '@payload-config'
|
||||||
|
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
|
||||||
|
|
||||||
|
export const GET = GRAPHQL_PLAYGROUND_GET(config)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import config from '@payload-config'
|
||||||
|
import { GRAPHQL_POST } from '@payloadcms/next/routes'
|
||||||
|
|
||||||
|
export const POST = GRAPHQL_POST(config)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getCollection } from '@/lib/mongodb'
|
import { getPayload } from 'payload'
|
||||||
|
import config from '@payload-config'
|
||||||
|
|
||||||
interface ContactFormData {
|
interface ContactFormData {
|
||||||
name: string
|
name: string
|
||||||
@@ -9,16 +10,6 @@ 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
|
||||||
@@ -127,20 +118,21 @@ export async function POST(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const submissions = await getCollection<ContactSubmission>('contact_submissions')
|
const payload = await getPayload({ config })
|
||||||
const submission: ContactSubmission = {
|
const submission = await payload.create({
|
||||||
...sanitizedData,
|
collection: 'contact-submissions',
|
||||||
gdprConsent: true,
|
data: {
|
||||||
status: 'new',
|
...sanitizedData,
|
||||||
createdAt: new Date(),
|
gdprConsent: true,
|
||||||
}
|
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.toISOString(),
|
timestamp: submission.createdAt,
|
||||||
submissionId: result.insertedId.toHexString()
|
submissionId: String(submission.id)
|
||||||
},
|
},
|
||||||
{ status: 200 }
|
{ status: 200 }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
* 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 mockInsertOne = jest.fn()
|
const mockCreate = jest.fn()
|
||||||
|
|
||||||
// Mock the logger to avoid complex setup
|
// Mock the logger to avoid complex setup
|
||||||
jest.mock('@/lib/logger', () => ({
|
jest.mock('@/lib/logger', () => ({
|
||||||
@@ -15,16 +16,28 @@ jest.mock('@/lib/logger', () => ({
|
|||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
|
|
||||||
jest.mock('@/lib/mongodb', () => ({
|
// WHY mock the resolved relative path instead of the '@payload-config' alias:
|
||||||
getCollection: jest.fn(async () => ({ insertOne: mockInsertOne })),
|
// next/jest's SWC transform rewrites the tsconfig path alias to a real
|
||||||
|
// 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(() => {
|
||||||
mockInsertOne.mockReset()
|
mockCreate.mockReset()
|
||||||
|
;(getPayload as jest.Mock).mockClear()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('MongoDB persistence', () => {
|
describe('Payload persistence', () => {
|
||||||
const validData = {
|
const validData = {
|
||||||
name: 'Test User',
|
name: 'Test User',
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
@@ -34,7 +47,7 @@ describe('/api/contact Unit Tests', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it('persists a valid submission before reporting success', async () => {
|
it('persists a valid submission before reporting success', async () => {
|
||||||
mockInsertOne.mockResolvedValue({ insertedId: { toHexString: () => 'submission-123' } })
|
mockCreate.mockResolvedValue({ id: 'submission-123', createdAt: '2026-09-12T00:00:00.000Z' })
|
||||||
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,
|
||||||
@@ -45,15 +58,17 @@ 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(mockInsertOne).toHaveBeenCalledWith(expect.objectContaining({
|
expect(mockCreate).toHaveBeenCalledWith({
|
||||||
...validData,
|
collection: 'contact-submissions',
|
||||||
status: 'new',
|
data: expect.objectContaining({
|
||||||
createdAt: expect.any(Date),
|
...validData,
|
||||||
}))
|
status: 'new',
|
||||||
|
}),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns a server error when persistence fails', async () => {
|
it('returns a server error when persistence fails', async () => {
|
||||||
mockInsertOne.mockRejectedValue(new Error('MongoDB unavailable'))
|
mockCreate.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,
|
||||||
@@ -62,7 +77,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(mockInsertOne).toHaveBeenCalledTimes(1)
|
expect(mockCreate).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects an over-long message before persisting', async () => {
|
it('rejects an over-long message before persisting', async () => {
|
||||||
@@ -75,7 +90,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(mockInsertOne).not.toHaveBeenCalled()
|
expect(mockCreate).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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ó".',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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).',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -1,27 +1,44 @@
|
|||||||
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 } from '@/content'
|
import { common, content } 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 />)
|
render(<Footer {...footerProps} />)
|
||||||
|
|
||||||
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 />)
|
render(<Footer {...footerProps} />)
|
||||||
|
|
||||||
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 (common.json footer.address) — assert the source value
|
// Address is CMS-editable (Payload Common global 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 />)
|
render(<Footer {...footerProps} />)
|
||||||
|
|
||||||
expect(screen.getByText('Kezdőlap')).toBeInTheDocument()
|
expect(screen.getByText('Kezdőlap')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Rólunk')).toBeInTheDocument()
|
expect(screen.getByText('Rólunk')).toBeInTheDocument()
|
||||||
@@ -30,7 +47,7 @@ describe('Footer', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should render service sections', () => {
|
it('should render service sections', () => {
|
||||||
render(<Footer />)
|
render(<Footer {...footerProps} />)
|
||||||
|
|
||||||
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()
|
||||||
@@ -39,21 +56,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 />)
|
render(<Footer {...footerProps} />)
|
||||||
|
|
||||||
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 />)
|
render(<Footer {...footerProps} />)
|
||||||
|
|
||||||
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 />)
|
const { container } = render(<Footer {...footerProps} />)
|
||||||
|
|
||||||
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()
|
||||||
@@ -63,7 +80,7 @@ describe('Footer', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should render with proper semantic structure', () => {
|
it('should render with proper semantic structure', () => {
|
||||||
const { container } = render(<Footer />)
|
const { container } = render(<Footer {...footerProps} />)
|
||||||
|
|
||||||
// Should have a footer element
|
// Should have a footer element
|
||||||
const footer = container.firstChild as HTMLElement
|
const footer = container.firstChild as HTMLElement
|
||||||
|
|||||||
@@ -1,14 +1,35 @@
|
|||||||
'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'
|
||||||
|
|
||||||
export default function Footer() {
|
type FooterServiceItem = { id: string; title: string; icon: string }
|
||||||
|
|
||||||
|
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"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--color-background-secondary)',
|
background: 'var(--color-background-secondary)',
|
||||||
borderColor: 'var(--color-border)'
|
borderColor: 'var(--color-border)'
|
||||||
}}
|
}}
|
||||||
@@ -17,65 +38,65 @@ export default function Footer() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 lg:gap-12">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 lg:gap-12">
|
||||||
{/* Company Info */}
|
{/* Company Info */}
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href={homeHref}
|
||||||
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)' }}
|
||||||
>
|
>
|
||||||
{siteConfig.general.name}
|
{siteConfig.general.name}
|
||||||
<span
|
<span
|
||||||
className="inline-block w-2 h-2 rounded-full animate-pulse-slow"
|
className="inline-block w-2 h-2 rounded-full animate-pulse-slow"
|
||||||
style={{ background: 'var(--color-success-500)' }}
|
style={{ background: 'var(--color-success-500)' }}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<p
|
<p
|
||||||
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)' }}
|
||||||
>
|
>
|
||||||
{siteConfig.general.description}
|
{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:${siteConfig.contact.email}`}
|
href={`mailto:${contactEmail}`}
|
||||||
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)' }}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center transition-all duration-200 group-hover:scale-110"
|
className="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center transition-all duration-200 group-hover:scale-110"
|
||||||
style={{ background: 'var(--color-primary-100)' }}
|
style={{ background: 'var(--color-primary-100)' }}
|
||||||
>
|
>
|
||||||
✉️
|
✉️
|
||||||
</span>
|
</span>
|
||||||
<span>{siteConfig.contact.email}</span>
|
<span>{contactEmail}</span>
|
||||||
</a>
|
</a>
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
style={{ color: 'var(--color-foreground-secondary)' }}
|
style={{ color: 'var(--color-foreground-secondary)' }}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center"
|
className="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center"
|
||||||
style={{ background: 'var(--color-primary-100)' }}
|
style={{ background: 'var(--color-primary-100)' }}
|
||||||
>
|
>
|
||||||
🏢
|
🏢
|
||||||
</span>
|
</span>
|
||||||
<span>{content.common.footer.address}</span>
|
<span>{footerAddress}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation Links */}
|
{/* Navigation Links */}
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3
|
||||||
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)' }}
|
||||||
>
|
>
|
||||||
Navigáció
|
{t.navigation}
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{siteConfig.navigation.footer.map((item) => (
|
{nav.map((item) => (
|
||||||
<li key={item.href}>
|
<li key={item.href}>
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className="group flex items-center gap-2 text-sm transition-all duration-200"
|
className="group flex items-center gap-2 text-sm transition-all duration-200"
|
||||||
style={{ color: 'var(--color-foreground-muted)' }}
|
style={{ color: 'var(--color-foreground-muted)' }}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
@@ -85,7 +106,7 @@ export default function Footer() {
|
|||||||
e.currentTarget.style.color = 'var(--color-foreground-muted)'
|
e.currentTarget.style.color = 'var(--color-foreground-muted)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="w-1.5 h-1.5 rounded-full transition-all duration-200 group-hover:scale-150"
|
className="w-1.5 h-1.5 rounded-full transition-all duration-200 group-hover:scale-150"
|
||||||
style={{ background: 'var(--color-primary-500)' }}
|
style={{ background: 'var(--color-primary-500)' }}
|
||||||
/>
|
/>
|
||||||
@@ -98,16 +119,16 @@ export default function Footer() {
|
|||||||
|
|
||||||
{/* Services */}
|
{/* Services */}
|
||||||
<div>
|
<div>
|
||||||
<h3
|
<h3
|
||||||
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)' }}
|
||||||
>
|
>
|
||||||
Szolgáltatások
|
{t.services}
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{content.pages.home.services.items.map((service) => (
|
{homeServices.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"
|
||||||
style={{ color: 'var(--color-foreground-muted)' }}
|
style={{ color: 'var(--color-foreground-muted)' }}
|
||||||
>
|
>
|
||||||
@@ -115,19 +136,19 @@ export default function Footer() {
|
|||||||
{service.title}
|
{service.title}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
<li
|
<li
|
||||||
className="flex items-center gap-2 text-sm"
|
className="flex items-center gap-2 text-sm"
|
||||||
style={{ color: 'var(--color-foreground-muted)' }}
|
style={{ color: 'var(--color-foreground-muted)' }}
|
||||||
>
|
>
|
||||||
<span className="text-base">🛠️</span>
|
<span className="text-base">🛠️</span>
|
||||||
Műszaki támogatás
|
{t.support}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom section */}
|
{/* Bottom section */}
|
||||||
<div
|
<div
|
||||||
className="border-t pt-8 mt-8"
|
className="border-t pt-8 mt-8"
|
||||||
style={{ borderColor: 'var(--color-border)' }}
|
style={{ borderColor: 'var(--color-border)' }}
|
||||||
>
|
>
|
||||||
@@ -136,11 +157,11 @@ export default function Footer() {
|
|||||||
className="text-sm"
|
className="text-sm"
|
||||||
style={{ color: 'var(--color-foreground-muted)' }}
|
style={{ color: 'var(--color-foreground-muted)' }}
|
||||||
>
|
>
|
||||||
{/* Copyright text is CMS-editable (common.json); {year} resolves to the current year */}
|
{/* Copyright text is CMS-editable (Payload Common global); {year} resolves to the current year */}
|
||||||
{content.common.footer.copyright.replace('{year}', String(new Date().getFullYear()))}
|
{footerCopyright.replace('{year}', String(new Date().getFullYear()))}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
{siteConfig.footer.links.map((link) => (
|
{legalLinks.map((link) => (
|
||||||
<a
|
<a
|
||||||
key={link.href}
|
key={link.href}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
@@ -162,9 +183,9 @@ export default function Footer() {
|
|||||||
|
|
||||||
{/* Tech badge */}
|
{/* Tech badge */}
|
||||||
<div className="mt-8 flex justify-center">
|
<div className="mt-8 flex justify-center">
|
||||||
<div
|
<div
|
||||||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-xs"
|
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-xs"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--color-background-tertiary)',
|
background: 'var(--color-background-tertiary)',
|
||||||
color: 'var(--color-foreground-muted)'
|
color: 'var(--color-foreground-muted)'
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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', () => {
|
||||||
@@ -11,14 +12,23 @@ 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 />)
|
render(<Header {...headerProps} />)
|
||||||
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 />)
|
render(<Header {...headerProps} />)
|
||||||
|
|
||||||
// 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')
|
||||||
@@ -32,7 +42,7 @@ describe('Header', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should render contact button with correct styling', () => {
|
it('should render contact button with correct styling', () => {
|
||||||
render(<Header />)
|
render(<Header {...headerProps} />)
|
||||||
|
|
||||||
const contactButtons = screen.getAllByText('Kapcsolat')
|
const contactButtons = screen.getAllByText('Kapcsolat')
|
||||||
expect(contactButtons.length).toBeGreaterThan(0)
|
expect(contactButtons.length).toBeGreaterThan(0)
|
||||||
@@ -49,7 +59,7 @@ describe('Header', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should render hamburger menu button on mobile', () => {
|
it('should render hamburger menu button on mobile', () => {
|
||||||
render(<Header />)
|
render(<Header {...headerProps} />)
|
||||||
|
|
||||||
// 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
|
||||||
@@ -58,14 +68,14 @@ describe('Header', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should have proper accessibility attributes', () => {
|
it('should have proper accessibility attributes', () => {
|
||||||
render(<Header />)
|
render(<Header {...headerProps} />)
|
||||||
|
|
||||||
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 />)
|
const { container } = render(<Header {...headerProps} />)
|
||||||
|
|
||||||
// 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
|
||||||
@@ -81,7 +91,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 />)
|
render(<Header {...headerProps} />)
|
||||||
|
|
||||||
const hamburgerButton = screen.getByRole('button', { name: new RegExp(common.a11y.openMenu, 'i') })
|
const hamburgerButton = screen.getByRole('button', { name: new RegExp(common.a11y.openMenu, 'i') })
|
||||||
|
|
||||||
@@ -99,7 +109,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 />)
|
render(<Header {...headerProps} />)
|
||||||
|
|
||||||
const hamburgerButton = screen.getByRole('button', { name: new RegExp(common.a11y.openMenu, 'i') })
|
const hamburgerButton = screen.getByRole('button', { name: new RegExp(common.a11y.openMenu, 'i') })
|
||||||
|
|
||||||
@@ -120,31 +130,31 @@ describe('Header', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should have correct navigation links with proper hrefs', () => {
|
it('should have correct navigation links with proper hrefs', () => {
|
||||||
render(<Header />)
|
render(<Header {...headerProps} />)
|
||||||
|
|
||||||
// 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', '/')
|
expect(homeLinks[0].closest('a')).toHaveAttribute('href', '/hu')
|
||||||
|
|
||||||
// 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', '/rolunk')
|
expect(aboutLinks[0].closest('a')).toHaveAttribute('href', '/hu/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', '/szolgaltatasok')
|
expect(servicesLinks[0].closest('a')).toHaveAttribute('href', '/hu/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', '/kapcsolat')
|
expect(contactLinks[0].closest('a')).toHaveAttribute('href', '/hu/kapcsolat')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should have proper responsive classes', () => {
|
it('should have proper responsive classes', () => {
|
||||||
const { container } = render(<Header />)
|
const { container } = render(<Header {...headerProps} />)
|
||||||
|
|
||||||
// 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')
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
'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'
|
||||||
|
|
||||||
export default function Header() {
|
type HeaderProps = {
|
||||||
|
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)
|
||||||
|
|
||||||
@@ -49,7 +55,7 @@ export default function Header() {
|
|||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href={homeHref}
|
||||||
className="group flex items-center gap-2 transition-all duration-200"
|
className="group flex items-center gap-2 transition-all duration-200"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
@@ -64,7 +70,7 @@ export default function Header() {
|
|||||||
|
|
||||||
{/* Desktop Navigation */}
|
{/* Desktop Navigation */}
|
||||||
<div className="hidden md:flex items-center space-x-1">
|
<div className="hidden md:flex items-center space-x-1">
|
||||||
{siteConfig.navigation.main.map((item) => (
|
{nav.map((item) => (
|
||||||
<a
|
<a
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
@@ -115,7 +121,7 @@ export default function Header() {
|
|||||||
}}
|
}}
|
||||||
aria-expanded={isMenuOpen}
|
aria-expanded={isMenuOpen}
|
||||||
>
|
>
|
||||||
<span className="sr-only">{isMenuOpen ? common.a11y.closeMenu : common.a11y.openMenu}</span>
|
<span className="sr-only">{isMenuOpen ? a11y.closeMenu : 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
|
||||||
@@ -151,7 +157,7 @@ export default function Header() {
|
|||||||
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)' }}
|
||||||
>
|
>
|
||||||
{siteConfig.navigation.main.map((item, index) => (
|
{nav.map((item, index) => (
|
||||||
<a
|
<a
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ 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) {
|
||||||
@@ -59,6 +66,8 @@ 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])
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
'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
|
||||||
@@ -1,20 +1,13 @@
|
|||||||
import { siteConfig } from '@/config/site'
|
import { localePath, type Locale } from '@/lib/i18n'
|
||||||
import { content } from '@/content'
|
import type { getAboutContent } from '@/lib/payload-content'
|
||||||
import type { Metadata } from 'next'
|
|
||||||
|
|
||||||
const { about: pageContent } = content.pages
|
type AboutViewProps = {
|
||||||
|
content: Awaited<ReturnType<typeof getAboutContent>>
|
||||||
export const metadata: Metadata = {
|
locale: Locale
|
||||||
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 AboutPage() {
|
export default function AboutView({ content: pageContent, locale }: AboutViewProps) {
|
||||||
|
const contactHref = localePath(locale, 'contact')
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0">
|
<div className="space-y-0">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
@@ -92,7 +85,7 @@ export default function AboutPage() {
|
|||||||
</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"
|
||||||
@@ -182,7 +175,7 @@ export default function AboutPage() {
|
|||||||
{pageContent.cta.subtitle}
|
{pageContent.cta.subtitle}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="/kapcsolat"
|
href={contactHref}
|
||||||
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,12 +1,25 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { siteConfig } from '@/config/site'
|
|
||||||
import { content } from '@/content'
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { siteConfig } from '@/config/site'
|
||||||
|
import { localePath, type Locale } from '@/lib/i18n'
|
||||||
|
import type { getContactContent } from '@/lib/payload-content'
|
||||||
|
|
||||||
const { contact: pageContent } = content.pages
|
type ContactViewProps = {
|
||||||
|
content: Awaited<ReturnType<typeof getContactContent>>
|
||||||
|
contactEmail: string
|
||||||
|
footerAddress: string
|
||||||
|
webmailHref: string
|
||||||
|
locale: Locale
|
||||||
|
}
|
||||||
|
|
||||||
export default function ContactPage() {
|
export default function ContactView({ content: pageContent, contactEmail, footerAddress, webmailHref, locale }: ContactViewProps) {
|
||||||
|
// 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: '',
|
||||||
@@ -135,7 +148,7 @@ export default function ContactPage() {
|
|||||||
{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}', siteConfig.contact.email)}
|
❌ {pageContent.form.errorMessage.replace('{email}', contactEmail)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -224,7 +237,7 @@ export default function ContactPage() {
|
|||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className="text-sm text-gray-700"
|
className="text-sm text-gray-700"
|
||||||
dangerouslySetInnerHTML={{ __html: pageContent.form.fields.gdpr.label + ' *' }}
|
dangerouslySetInnerHTML={{ __html: gdprLabel + ' *' }}
|
||||||
/>
|
/>
|
||||||
</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>}
|
||||||
@@ -255,11 +268,11 @@ export default function ContactPage() {
|
|||||||
</div>
|
</div>
|
||||||
<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:${siteConfig.contact.email}`}
|
href={`mailto:${contactEmail}`}
|
||||||
className="text-blue-600 hover:text-blue-700"
|
className="text-blue-600 hover:text-blue-700"
|
||||||
>
|
>
|
||||||
{siteConfig.contact.email}
|
{contactEmail}
|
||||||
</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}
|
||||||
@@ -274,7 +287,7 @@ export default function ContactPage() {
|
|||||||
<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">{content.common.footer.address}</p>
|
<p className="text-sm text-gray-600">{footerAddress}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -284,8 +297,12 @@ export default function ContactPage() {
|
|||||||
</div>
|
</div>
|
||||||
<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
|
||||||
href={content.pages.home.hero.cta.primary.href}
|
// WHY webmailHref: a migráció előtti kód itt is a
|
||||||
|
// "/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"
|
||||||
@@ -307,7 +324,7 @@ export default function ContactPage() {
|
|||||||
</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>
|
||||||
@@ -1,9 +1,25 @@
|
|||||||
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'
|
||||||
|
|
||||||
const { home: pageContent } = content.pages
|
type HomeViewProps = {
|
||||||
|
content: Awaited<ReturnType<typeof getHomeContent>>
|
||||||
|
partners: PartnerView[]
|
||||||
|
locale: Locale
|
||||||
|
}
|
||||||
|
|
||||||
export default function Home() {
|
// WHY hardcoded itt: a Partners collection (MITHOME-89) csak name/url/logo-t
|
||||||
|
// 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 */}
|
||||||
@@ -94,7 +110,7 @@ export default function Home() {
|
|||||||
</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"
|
||||||
@@ -190,8 +206,8 @@ export default function Home() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
href="/kapcsolat"
|
href={contactHref}
|
||||||
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)' }}
|
||||||
>
|
>
|
||||||
@@ -212,17 +228,23 @@ export default function Home() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Partners Section */}
|
{/* Partners Section */}
|
||||||
{pageContent.partners.items.length > 0 && (
|
{partners.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)' }}>
|
||||||
{pageContent.partners.title}
|
{partnersCopy.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)' }}>
|
||||||
{pageContent.partners.subtitle}
|
{partnersCopy.subtitle}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap justify-center items-center gap-10">
|
{/* WHY grid + w-fit + mx-auto: a felhasználó kérésére valódi
|
||||||
{pageContent.partners.items.map((partner) => (
|
mátrix (max 3 oszlop), nem folyó flex-wrap. `w-fit` zsugorítja
|
||||||
|
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}
|
||||||
@@ -233,8 +255,8 @@ export default function Home() {
|
|||||||
>
|
>
|
||||||
<span className="relative h-12 w-40">
|
<span className="relative h-12 w-40">
|
||||||
<Image
|
<Image
|
||||||
src={partner.logo}
|
src={partner.logo.url}
|
||||||
alt={partner.name}
|
alt={partner.logo.alt}
|
||||||
fill
|
fill
|
||||||
sizes="160px"
|
sizes="160px"
|
||||||
className="object-contain"
|
className="object-contain"
|
||||||
@@ -285,7 +307,7 @@ export default function Home() {
|
|||||||
{pageContent.cta.subtitle}
|
{pageContent.cta.subtitle}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="/kapcsolat"
|
href={contactHref}
|
||||||
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,40 +1,46 @@
|
|||||||
import { content } from '@/content'
|
import type { Locale } from '@/lib/i18n'
|
||||||
|
import type { getLegalPage } from '@/lib/payload-content'
|
||||||
|
|
||||||
export const metadata = {
|
type LegalPageViewProps = {
|
||||||
title: `${content.pages.adatvedelem.title} | ${content.common.labels.features || 'mozdIT Bt.'}`,
|
page: NonNullable<Awaited<ReturnType<typeof getLegalPage>>>
|
||||||
description: 'Adatvédelmi tájékoztató - ismerje meg, hogyan kezeljük személyes adatait.',
|
locale: Locale
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PrivacyPolicy() {
|
const LAST_UPDATED_LABEL: Record<Locale, string> = {
|
||||||
const pageContent = content.pages.adatvedelem
|
hu: 'Utolsó frissítés',
|
||||||
|
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">
|
||||||
<h1
|
<h1
|
||||||
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)' }}
|
||||||
>
|
>
|
||||||
{pageContent.title}
|
{page.title}
|
||||||
</h1>
|
</h1>
|
||||||
<p
|
<p
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
style={{ color: 'var(--color-foreground-muted)' }}
|
style={{ color: 'var(--color-foreground-muted)' }}
|
||||||
>
|
>
|
||||||
Utolsó frissítés: {pageContent.lastUpdated}
|
{LAST_UPDATED_LABEL[locale]}: {page.lastUpdated}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-12">
|
<div className="space-y-12">
|
||||||
{pageContent.sections.map((section) => (
|
{page.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"
|
||||||
style={{ color: 'var(--color-foreground)' }}
|
style={{ color: 'var(--color-foreground)' }}
|
||||||
>
|
>
|
||||||
{section.title}
|
{section.title}
|
||||||
</h2>
|
</h2>
|
||||||
<div
|
<div
|
||||||
className="prose max-w-none"
|
className="prose max-w-none"
|
||||||
style={{ color: 'var(--color-foreground-muted)' }}
|
style={{ color: 'var(--color-foreground-muted)' }}
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
+21
-21
@@ -1,20 +1,16 @@
|
|||||||
import { siteConfig } from '@/config/site'
|
import { localePath, type Locale } from '@/lib/i18n'
|
||||||
import { content } from '@/content'
|
import type { getHomeContent, getServicesContent } from '@/lib/payload-content'
|
||||||
import type { Metadata } from 'next'
|
|
||||||
|
|
||||||
const { services: pageContent } = content.pages
|
type ServicesViewProps = {
|
||||||
|
content: Awaited<ReturnType<typeof getServicesContent>>
|
||||||
export const metadata: Metadata = {
|
homeServices: Awaited<ReturnType<typeof getHomeContent>>['services']['items']
|
||||||
title: `${pageContent.meta.title} | ${siteConfig.general.name}`,
|
featuresLabel: string
|
||||||
description: pageContent.meta.description,
|
webmailHref: string
|
||||||
openGraph: {
|
locale: Locale
|
||||||
title: `${pageContent.meta.title} | ${siteConfig.general.name}`,
|
|
||||||
description: pageContent.meta.ogDescription,
|
|
||||||
url: `${siteConfig.general.url}/szolgaltatasok`,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ServicesPage() {
|
export default function ServicesView({ content: pageContent, homeServices, featuresLabel, webmailHref, locale }: ServicesViewProps) {
|
||||||
|
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 */}
|
||||||
@@ -32,7 +28,7 @@ export default function ServicesPage() {
|
|||||||
{/* 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">
|
||||||
{content.pages.home.services.items.map((service) => (
|
{homeServices.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>
|
||||||
@@ -42,7 +38,7 @@ export default function ServicesPage() {
|
|||||||
<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">{content.common.labels.features}</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-3">{featuresLabel}</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">
|
||||||
@@ -53,8 +49,8 @@ export default function ServicesPage() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="/kapcsolat"
|
href={contactHref}
|
||||||
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}
|
||||||
@@ -115,7 +111,7 @@ export default function ServicesPage() {
|
|||||||
{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>
|
||||||
@@ -136,13 +132,17 @@ export default function ServicesPage() {
|
|||||||
</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="/kapcsolat"
|
href={contactHref}
|
||||||
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
|
||||||
href={content.pages.home.hero.cta.primary.href}
|
// WHY webmailHref és nem "/kapcsolat" ismét: a migráció előtti
|
||||||
|
// 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"
|
||||||
+63
-37
@@ -1,47 +1,73 @@
|
|||||||
import { SiteConfig } from '@/types/site'
|
import type { NavigationItem } from '@/types/site'
|
||||||
|
import { localePath, type Locale } from '@/lib/i18n'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Site Configuration - Centralized configuration for all public content
|
* Site Configuration - statikus, nyelvfüggetlen alapadatok.
|
||||||
* All content is easily modifiable without code changes
|
*
|
||||||
* This structure supports easy expansion for CMS integration later
|
* MITHOME-91/114: a navigáció és a lábláb jogi linkjei nyelvfüggővé váltak
|
||||||
|
* (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: SiteConfig = {
|
export const 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 content/common.json (footer.address) — CMS-editable.
|
// address lives in the Payload Common global (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') },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@@ -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=\"/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.",
|
"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.",
|
||||||
"error": "Az adatkezelési tájékoztató elfogadása kötelező"
|
"error": "Az adatkezelési tájékoztató elfogadása kötelező"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
// Shared runtime schema for the content JSON files.
|
// Shared runtime schema for the content JSON files (proto/src/content/*.json).
|
||||||
// Kept dependency-free so it can run in both Next.js and content-editor.js.
|
// Kept dependency-free — used by src/content/index.ts (test fixtures for
|
||||||
|
// 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 });
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
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 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
@@ -93,16 +93,6 @@ 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')
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { MongoClient, Db, Collection, Document } from 'mongodb'
|
import { MongoClient, Db } 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,
|
||||||
@@ -50,11 +56,6 @@ 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 {
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
})
|
||||||
+22
-5
@@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2017",
|
"target": "ES2017",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@@ -11,7 +15,7 @@
|
|||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "react-jsx",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
@@ -19,9 +23,22 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
],
|
||||||
|
"@payload-config": [
|
||||||
|
"./src/payload.config.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": [
|
||||||
"exclude": ["node_modules"]
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
// 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,
|
|
||||||
};
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
// 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, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) || ' '}</div>`).join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { diffLines, trimContext, renderDiffHtml, escapeHtml };
|
|
||||||
@@ -1,345 +0,0 @@
|
|||||||
// 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Boot
|
|
||||||
render(DATA, document.getElementById('editor'));
|
|
||||||
|
|
||||||
// Auto-dismiss toast
|
|
||||||
const toast = document.querySelector('.toast');
|
|
||||||
if (toast) setTimeout(() => toast.remove(), 3500);
|
|
||||||
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
// 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,345 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
// 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 };
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
// 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 };
|
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
function escHtml(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
|
||||||
// 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 };
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
// 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 };
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
// 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 };
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
// 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,
|
|
||||||
};
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
// 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 };
|
|
||||||
@@ -45,8 +45,14 @@ 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 && sudo systemctl restart mozdit-content-editor.service"
|
"cd '${STAGE_PATH}' && git pull --ff-only origin '${DEPLOY_BRANCH}' && ./deploy.sh staging"
|
||||||
|
|
||||||
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
Reference in New Issue
Block a user