diff --git a/CLAUDE.md b/CLAUDE.md index 68be9ad..e84ec4e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,10 +12,12 @@ Beheerportaal voor de Rikx-methodiek. Beheert de sociaal ondernemers (accounts i - `/experts` (CRM → Experts) — experts uitnodigen, rondes koppelen, opnieuw uitnodigen (ook voor een vergeten wachtwoord), verwijderen, en "Inloggen als": een admin bekijkt `/expert` als die expert (alleen lezen; sessie met `impersonatorId`, terug via "Terug naar beheer"). Uitnodiging per e-mail via SendGrid of SMTP; zonder beide toont de pagina de link om zelf door te geven - `/uitnodiging/[token]` — openbaar: expert stelt wachtwoord in (token 7 dagen geldig, eenmalig; alleen de SHA-256-hash staat in de database) - `/expert` — de enige pagina voor experts: projecten in Waardebepaling uit hun rondes, project plan (alleen lezen) en hun eigen beoordeling (3 scores 1–10, advies Bonus/Malus, toelichting). Experts zien elkaars beoordeling niet +- `/website` (Publieke Website → Pagina's) — CMS, overgenomen van rikx-org (`/portaal/paginas`) maar daar los van (eigen tabellen in deze database): pagina's aanmaken/verwijderen, hoofdmenu (naam + volgorde met pijltjes), SEO (meta-titel/-omschrijving, canonical, og:image, noindex) en per pagina content-blokken (`hero`, `richtext` met weergave standaard/oranje balk/kaart, `testimonial`, `benefits`, `cta`) toevoegen, bewerken (Quill), herordenen en verwijderen. NL/EN/DE: NL is de basis, EN/DE per veld met terugval op NL; een taal is "ingevuld" zodra ≥1 blok vertaald is. Er zijn hier geen vaste pagina's en (nog) geen publieke weergave, sitemap of bezoekteller. Logica in `lib/cms.ts`, acties in `app/website/actions.ts`; rich text wordt vóór opslag gesaniteerd (`lib/sanitize.ts`) +- `/media/[filename]` — openbare route voor via het CMS geüploade afbeeldingen (png/jpg/webp/svg, max 10 MB) uit `MEDIA_DIR`; met CSP-sandbox zodat een SVG geen script kan uitvoeren op het beheerdomein - Rollen: `users.role` = `admin` of `expert`. Elke beheerpagina en -actie begint met `requireAdmin()`, de expertpagina met `requireExpert()` (`lib/auth.ts`); de rol komt uit de database, niet uit de cookie - Gedeelde kop met navigatie: `app/AppHeader.tsx` (oranje Rikx-balk, menu per module; items zonder pagina tonen "binnenkort") - Huisstijlkleuren als Tailwind-tokens in `app/globals.css`: `rikx-orange`, `rikx-purple` (actief menu-item), `rikx-maroon` (primaire knop). Statuskleuren in `lib/projectStatus.ts` -- Conventie: server actions in `actions.ts` per map, geen API-routes (behalve `app/api/auto-login`) +- Conventie: server actions in `actions.ts` per map, geen API-routes (behalve `app/api/auto-login` en de mediaroute) ## Stack - Next.js 16 + TypeScript + Tailwind CSS @@ -42,7 +44,7 @@ npm run create-admin maarten@rikx.nl wachtwoord "Maarten" - Locatie lokaal: `./data/app.db` - Locatie productie: `/data/app.db` (absoluut pad — Next.js standalone doet process.chdir bij start) - Schema: `lib/db.ts` — migraties draaien automatisch bij eerste verbinding -- Tabellen: `users`, `projects` (`name`, `status`; statussen in `lib/projectStatus.ts`, standaard `aanvraag`; plus de velden uit de rekenmethode als kolommen; overige project plan-velden als JSON in `project_plan`), `expert_beoordelingen` (per project en expert; Impactscore M = gemiddelde over de experts; de Rikx calculatie-tab toont ze alleen-lezen), `expert_rondes`, `expert_uitnodigingen`. `project_expert_scores` (oude handmatige scores) wordt niet meer gebruikt maar blijft staan, `rikx_rondes` (Rikx waarde en prijs per ronde; de calculatie haalt ze uit de ronde van het project, prijs standaard waarde / 10) +- Tabellen: `users`, `projects` (`name`, `status`; statussen in `lib/projectStatus.ts`, standaard `aanvraag`; plus de velden uit de rekenmethode als kolommen; overige project plan-velden als JSON in `project_plan`), `expert_beoordelingen` (per project en expert; Impactscore M = gemiddelde over de experts; de Rikx calculatie-tab toont ze alleen-lezen), `expert_rondes`, `expert_uitnodigingen`. `project_expert_scores` (oude handmatige scores) wordt niet meer gebruikt maar blijft staan, `pages`, `content_blocks`, `page_translations`, `block_translations` (CMS, zie `/website`), `rikx_rondes` (Rikx waarde en prijs per ronde; de calculatie haalt ze uit de ronde van het project, prijs standaard waarde / 10) - Rekenmethode (Rikx Waardeberekening Arbeid): `_doc/Rikx rekenmethode uitleg.xlsx`, tabblad Uitleg; achtergrond in `_doc/Waarderingssystematiek Rikx juni 2023.pdf`. Afwijkingen van de sheet: N-score = ((werk×5)+skills)/6 (sheet mist haakjes), Bonus/Malus `-` = 0,9 en `--` = 0,8 (sheet heeft typefouten) - De oude offerte-tabellen (`clients`, `projects` met `client_id`, `quotes`, `quote_items`) worden bij de migratie verwijderd als ze leeg zijn; met gegevens worden ze hernoemd naar `legacy_*` - Tweede database (alleen-platform): `RIKX_PLATFORM_DB_PATH` (standaard `/rikx-platform-data/app.db`) met `accounts` en `users` van het ZRM-platform @@ -59,6 +61,7 @@ npm run create-admin maarten@rikx.nl wachtwoord "Maarten" |-----------|-----------|--------| | `SESSION_SECRET` | — | HMAC-secret voor sessie-cookies — per omgeving uniek genereren | | `DB_PATH` | `./data/app.db` | **In productie: `/data/app.db`** (absoluut pad) | +| `MEDIA_DIR` | `/media` | Geüploade CMS-afbeeldingen (productie: `/data/media`, op hetzelfde volume) | | `RIKX_PLATFORM_DB_PATH` | `/rikx-platform-data/app.db` | Database van het ZRM-platform (sociaal ondernemers) | | `NODE_ENV` | `development` | In productie: `production` | | `SENDGRID_API_KEY` | — | E-mail via SendGrid (uitnodigingen voor experts); zelfde key als het Rikx ZRM-platform. Nooit committen. Leeg én geen SMTP = geen e-mail, link wordt in beeld getoond | diff --git a/app/AppHeader.tsx b/app/AppHeader.tsx index ee1e91d..1c59f7e 100644 --- a/app/AppHeader.tsx +++ b/app/AppHeader.tsx @@ -26,7 +26,11 @@ const NAV_ITEMS: NavItem[] = [ section: "crm", items: [{ label: "Sociaal ondernemers", href: "/tenants" }, { label: "Investeerders" }, { label: "Experts", href: "/experts" }], }, - { label: "Publieke Website", section: "website" }, + { + label: "Publieke Website", + section: "website", + items: [{ label: "Pagina's", href: "/website" }], + }, ]; function Caret() { diff --git a/app/media/[filename]/route.ts b/app/media/[filename]/route.ts new file mode 100644 index 0000000..264673c --- /dev/null +++ b/app/media/[filename]/route.ts @@ -0,0 +1,42 @@ +import { readFile } from "fs/promises"; +import path from "path"; +import { getMediaDir } from "../../../lib/cms"; + +const CONTENT_TYPES: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".svg": "image/svg+xml", +}; + +// Openbaar (geen sessie): afbeeldingen voor de publieke website, via het CMS +// geüpload naar het /data-volume. De CSP-sandbox voorkomt dat een SVG met +// script iets kan op dit domein, waar ook het beheer draait. +export async function GET(_request: Request, { params }: { params: Promise<{ filename: string }> }) { + const { filename } = await params; + + // Alleen een kale bestandsnaam (geen path traversal) + if (filename.includes("/") || filename.includes("\\") || filename.includes("..")) { + return new Response("Ongeldige bestandsnaam", { status: 400 }); + } + + const contentType = CONTENT_TYPES[path.extname(filename).toLowerCase()]; + if (!contentType) return new Response("Niet ondersteund bestandstype", { status: 400 }); + + let buffer: Buffer; + try { + buffer = await readFile(path.join(getMediaDir(), filename)); + } catch { + return new Response("Niet gevonden", { status: 404 }); + } + + return new Response(new Uint8Array(buffer), { + headers: { + "Content-Type": contentType, + "Cache-Control": "public, max-age=31536000, immutable", + "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox", + "X-Content-Type-Options": "nosniff", + }, + }); +} diff --git a/app/website/BlockEditModal.tsx b/app/website/BlockEditModal.tsx new file mode 100644 index 0000000..578c8d4 --- /dev/null +++ b/app/website/BlockEditModal.tsx @@ -0,0 +1,270 @@ +"use client"; + +import "quill/dist/quill.snow.css"; +import { useEffect, useRef, useState } from "react"; +import type Quill from "quill"; +import type { BenefitsContent, ContentBlock, CtaContent, HeroContent, PageSlug, RichtextContent, TestimonialContent } from "../../lib/cms"; +import { DEFAULT_LANG, LANG_LABELS, type Lang } from "../../lib/i18n"; +import { RICHTEXT_VARIANTS, RICHTEXT_VARIANT_LABELS, type RichtextVariant } from "../../lib/richtextVariants"; +import { updateBlockAction } from "./actions"; +import { ImageField } from "./ImageField"; +import { Modal } from "./Modal"; +import { errorClass, hintClass, inputClass, labelClass, primaryButton, secondaryButton, textareaClass } from "./ui"; + +function useRichText(initialHtml: string) { + const editorRef = useRef(null); + const quillRef = useRef(null); + + useEffect(() => { + let cancelled = false; + import("quill").then(({ default: Quill }) => { + if (cancelled || !editorRef.current || quillRef.current) return; + const quill = new Quill(editorRef.current, { + theme: "snow", + modules: { + toolbar: [["bold", "italic", "underline"], [{ list: "ordered" }, { list: "bullet" }], ["link"], ["clean"]], + }, + }); + if (initialHtml) quill.clipboard.dangerouslyPasteHTML(initialHtml); + quillRef.current = quill; + }); + return () => { + cancelled = true; + quillRef.current = null; + }; + }, []); + + const getHtml = () => quillRef.current?.root.innerHTML ?? initialHtml; + const setHtml = (html: string) => { + if (quillRef.current) quillRef.current.setContents(quillRef.current.clipboard.convert({ html })); + }; + return { editorRef, getHtml, setHtml }; +} + +function RichTextField({ label, editorRef }: { label: string; editorRef: React.RefObject }) { + return ( +
+ {label} +
+
+
+
+ ); +} + +function TextInput({ label, value, onChange, required }: { label: string; value: string; onChange: (v: string) => void; required?: boolean }) { + return ( + + ); +} + +type AnyContent = HeroContent & RichtextContent & TestimonialContent & BenefitsContent & CtaContent; + +export function BlockEditModal({ + block, + slug, + lang, + translation, + mediaImages, + onClose, + onSaved, +}: { + block: ContentBlock; + slug: PageSlug; + lang: Lang; + // EN/DE: de bestaande vertaling van dit blok (of undefined) + translation?: Record; + mediaImages: string[]; + onClose: () => void; + onSaved: () => void; +}) { + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const isNl = lang === DEFAULT_LANG; + const nlContent = JSON.parse(block.content) as AnyContent; + // EN/DE opent leeg (niet voorgevuld met NL), anders telt een ongewijzigde kopie als "vertaald" + const initial = (isNl ? nlContent : (translation ?? {})) as AnyContent; + + const [image, setImage] = useState(initial.image ?? ""); + const [buttonLabel, setButtonLabel] = useState(initial.buttonLabel ?? ""); + const [buttonHref, setButtonHref] = useState(initial.buttonHref ?? ""); + const [attribution, setAttribution] = useState(initial.attribution ?? ""); + const [plainHeading, setPlainHeading] = useState(initial.heading ?? ""); + const [itemsText, setItemsText] = useState((initial.items ?? []).join("\n")); + const [variant, setVariant] = useState(nlContent.variant ?? "standaard"); + const variantEditable = isNl && block.type === "richtext"; + + const heading = useRichText(block.type === "hero" || block.type === "richtext" ? (initial.heading ?? "") : ""); + const text = useRichText(block.type === "hero" ? (initial.text ?? "") : ""); + const body = useRichText(block.type === "richtext" || block.type === "cta" ? (initial.body ?? "") : ""); + const quote = useRichText(block.type === "testimonial" ? (initial.quote ?? "") : ""); + const imageEmptyLabel = isNl ? "Geen afbeelding" : "Leeg = NL-afbeelding gebruiken"; + + // Nederlandse tekstvelden als startpunt om te vertalen (afbeeldingen/links blijven leeg = NL) + function copyFromNl() { + if (nlContent.buttonLabel !== undefined) setButtonLabel(nlContent.buttonLabel ?? ""); + if (nlContent.attribution !== undefined) setAttribution(nlContent.attribution ?? ""); + if (block.type === "benefits") { + setPlainHeading(nlContent.heading ?? ""); + setItemsText((nlContent.items ?? []).join("\n")); + } + if (block.type === "cta") { + setPlainHeading(nlContent.heading ?? ""); + body.setHtml(nlContent.body ?? ""); + } + if (block.type === "hero") { + heading.setHtml(nlContent.heading ?? ""); + text.setHtml(nlContent.text ?? ""); + } + if (block.type === "richtext") { + heading.setHtml(nlContent.heading ?? ""); + body.setHtml(nlContent.body ?? ""); + } + if (block.type === "testimonial") quote.setHtml(nlContent.quote ?? ""); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSaving(true); + setError(null); + try { + let content: Record; + switch (block.type) { + case "hero": + content = { heading: heading.getHtml(), text: text.getHtml(), image, buttonLabel, buttonHref }; + break; + case "richtext": + content = { heading: heading.getHtml(), body: body.getHtml(), buttonLabel, buttonHref }; + if (variantEditable) content.variant = variant; + break; + case "testimonial": + content = { quote: quote.getHtml(), attribution, image }; + break; + case "benefits": + content = { heading: plainHeading, items: itemsText.split("\n").map((s) => s.trim()).filter(Boolean) }; + break; + case "cta": + content = { heading: plainHeading, body: body.getHtml(), buttonLabel, buttonHref }; + break; + } + const result = await updateBlockAction(block.id, slug, content, lang); + if (result.error) setError(result.error); + else { + onSaved(); + onClose(); + } + } finally { + setSaving(false); + } + } + + const buttonFields = (required: boolean) => ( + <> + + + + ); + + return ( + +
+ {!isNl && ( +
+

+ Je vertaalt naar {LANG_LABELS[lang]}. Leeg gelaten velden vallen terug op het Nederlands. +

+
+ +
+
+ Nederlandse tekst (referentie) +
+ {[nlContent.heading, nlContent.text, nlContent.body, nlContent.quote, nlContent.attribution] + .concat(nlContent.items ?? []) + .filter((v): v is string => typeof v === "string" && v.trim() !== "") + .map((v, i) => ( +

{v.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim()}

+ ))} +
+
+
+ )} + + {block.type === "hero" && ( + <> + + + + {buttonFields(false)} + + )} + + {block.type === "richtext" && ( + <> + {variantEditable && ( + + )} + + + {buttonFields(false)} + + )} + + {block.type === "testimonial" && ( + <> + + + + + )} + + {block.type === "benefits" && ( + <> + +