Voeg CMS voor de Publieke Website toe (overgenomen van rikx-org)

Pagina-editor, hoofdmenu, SEO en media onder Publieke Website → Pagina's
(/website), los van het CMS in rikx-org: eigen tabellen in de beheerdatabase.
Blokken (hero, tekstblok, testimonial, voordelenlijst, call-to-action) met
Quill, NL/EN/DE met terugval op NL, en afbeeldingsupload naar MEDIA_DIR met
een openbare /media-route (CSP-sandbox).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 15:27:12 +02:00
parent f9e4a1aa5d
commit 9be692974e
19 changed files with 2505 additions and 87 deletions
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { useId, useRef, useState } from "react";
import { uploadMediaAction } from "./actions";
import { errorClass, inputClass, labelClass, secondaryButton } from "./ui";
// Kies een eerder geüploade afbeelding of upload een nieuwe (max 10 MB)
export function ImageField({
label,
value,
onChange,
mediaImages,
emptyLabel = "Geen afbeelding",
}: {
label: string;
value: string;
onChange: (path: string) => void;
mediaImages: string[];
emptyLabel?: string;
}) {
const [uploaded, setUploaded] = useState<string[]>([]);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const fieldId = useId();
async function handleUpload() {
const file = fileInputRef.current?.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append("file", file);
setUploading(true);
setError(null);
try {
const result = await uploadMediaAction(formData);
if (result.error) setError(result.error);
else if (result.path) {
setUploaded((u) => [result.path!, ...u]);
onChange(result.path);
if (fileInputRef.current) fileInputRef.current.value = "";
}
} finally {
setUploading(false);
}
}
// Ook een gekozen afbeelding die niet (meer) in de lijst staat blijft zichtbaar als optie
const options = [...new Set([...(value ? [value] : []), ...uploaded, ...mediaImages])];
return (
<div className="flex flex-col gap-2">
<label className={labelClass} htmlFor={fieldId}>
{label}
</label>
<select id={fieldId} value={value} onChange={(e) => onChange(e.target.value)} className={inputClass}>
<option value="">{emptyLabel}</option>
{options.map((img) => (
<option key={img} value={img}>
{img.split("/").pop()}
</option>
))}
</select>
{value && (
<img src={value} alt="" className="max-w-[200px] max-h-[120px] object-cover rounded-lg border border-zinc-200 dark:border-zinc-700" />
)}
<div className="flex flex-wrap items-center gap-3">
<input ref={fileInputRef} type="file" accept="image/png,image/jpeg,image/webp,image/svg+xml" className="text-sm text-zinc-600 dark:text-zinc-400" />
<button type="button" className={secondaryButton} onClick={handleUpload} disabled={uploading}>
{uploading ? "Bezig..." : "Uploaden"}
</button>
</div>
{error && <p className={errorClass}>{error}</p>}
</div>
);
}