From f2b5a151913cf183470c86c9d7e286b1dd902518 Mon Sep 17 00:00:00 2001 From: MOH Date: Tue, 10 Mar 2026 05:24:50 +0100 Subject: [PATCH] fix admin save state architecture --- app/root/portfolio/actions.ts | 3 +- app/root/portfolio/projects/[id]/page.tsx | 3 +- app/root/portfolio/projects/new/page.tsx | 2 +- app/root/smtp/actions.ts | 3 +- app/root/smtp/contact-protection/actions.ts | 5 +- components/root/form-save-button.tsx | 84 ++++++++++++++++--- components/root/portfolio-project-form.tsx | 15 ---- .../root/sidebar-maintenance-control.tsx | 4 +- components/ui/checkbox.tsx | 9 +- components/ui/select.tsx | 4 +- components/ui/use-hidden-input-sync.ts | 26 ++++++ lib/form-data.ts | 3 + 12 files changed, 123 insertions(+), 38 deletions(-) create mode 100644 components/ui/use-hidden-input-sync.ts create mode 100644 lib/form-data.ts diff --git a/app/root/portfolio/actions.ts b/app/root/portfolio/actions.ts index 3bd287f..ca1f49a 100644 --- a/app/root/portfolio/actions.ts +++ b/app/root/portfolio/actions.ts @@ -14,6 +14,7 @@ import { getLocalizedPath } from "@/lib/locale"; import { removeManagedMediaFile } from "@/lib/media-storage"; import { mediaFieldInputSchema } from "@/lib/media-validation"; import { prisma } from "@/lib/prisma"; +import { isCheckedFormValue } from "@/lib/form-data"; import { assetInputSchema, categoryInputSchema, @@ -40,7 +41,7 @@ function withMessage(pathname: string, type: "success" | "error", message: strin } function normalizeCheckboxValue(formData: FormData, key: string) { - return formData.get(key) === "on"; + return isCheckedFormValue(formData.get(key)); } function parseJsonArray(rawValue: FormDataEntryValue | null, key: string) { diff --git a/app/root/portfolio/projects/[id]/page.tsx b/app/root/portfolio/projects/[id]/page.tsx index 9b2901f..de2bfcb 100644 --- a/app/root/portfolio/projects/[id]/page.tsx +++ b/app/root/portfolio/projects/[id]/page.tsx @@ -78,6 +78,8 @@ export default async function RootPortfolioProjectPage({ logoutAction={logoutAction} headerTitle={copy.title} headerDescription={copy.subtitle} + saveFormId="portfolio-project-form" + saveButtonLabel={copy.saveProject} >
{searchParams?.success ? ( @@ -100,7 +102,6 @@ export default async function RootPortfolioProjectPage({ project={project} formId="portfolio-project-form" redirectPath={`/root/portfolio/projects/${project.id}`} - submitLabel={copy.saveProject} /> diff --git a/app/root/portfolio/projects/new/page.tsx b/app/root/portfolio/projects/new/page.tsx index 535442a..980c2e0 100644 --- a/app/root/portfolio/projects/new/page.tsx +++ b/app/root/portfolio/projects/new/page.tsx @@ -58,6 +58,7 @@ export default async function RootNewPortfolioProjectPage({ logoutAction={logoutAction} headerTitle={copy.title} headerDescription={copy.subtitle} + saveFormId="portfolio-project-form" >
{searchParams?.error ? ( @@ -73,7 +74,6 @@ export default async function RootNewPortfolioProjectPage({ mediaOptions={mediaOptions} formId="portfolio-project-form" redirectPath="/root/portfolio/projects/new" - submitLabel="Projekt anlegen" />
diff --git a/app/root/smtp/actions.ts b/app/root/smtp/actions.ts index ac65266..4de36ed 100644 --- a/app/root/smtp/actions.ts +++ b/app/root/smtp/actions.ts @@ -5,6 +5,7 @@ import { redirect } from "next/navigation"; import { isRedirectError } from "next/dist/client/components/redirect"; import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; +import { isCheckedFormValue } from "@/lib/form-data"; import { getMailSettings, updateMailSettings, @@ -53,7 +54,7 @@ function parseMailSettingsFormData( smtp: { host, port: parsePort(portValue || String(existingSettings.smtp.port)), - secure: formData.get("smtpSecure") === "on", + secure: isCheckedFormValue(formData.get("smtpSecure")), username, password: password.trim() ? password : existingSettings.smtp.password, }, diff --git a/app/root/smtp/contact-protection/actions.ts b/app/root/smtp/contact-protection/actions.ts index 2e34ab8..66d5b9c 100644 --- a/app/root/smtp/contact-protection/actions.ts +++ b/app/root/smtp/contact-protection/actions.ts @@ -5,6 +5,7 @@ import { redirect } from "next/navigation"; import { isRedirectError } from "next/dist/client/components/redirect"; import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; +import { isCheckedFormValue } from "@/lib/form-data"; import { getContactProtectionSettings, updateContactProtectionSettings, @@ -39,10 +40,10 @@ function parseContactProtectionFormData( formData: FormData, existingSettings: ContactProtectionSettings, ): ContactProtectionSettings { - const turnstileEnabled = formData.get("turnstileEnabled") === "on"; + const turnstileEnabled = isCheckedFormValue(formData.get("turnstileEnabled")); const turnstileSiteKey = String(formData.get("turnstileSiteKey") ?? "").trim(); const turnstileSecretKey = String(formData.get("turnstileSecretKey") ?? ""); - const rateLimitEnabled = formData.get("contactRateLimitEnabled") === "on"; + const rateLimitEnabled = isCheckedFormValue(formData.get("contactRateLimitEnabled")); if (turnstileEnabled && !turnstileSiteKey) { throw new Error("Turnstile site key is required when Turnstile is enabled."); diff --git a/components/root/form-save-button.tsx b/components/root/form-save-button.tsx index a84e1df..aa70513 100644 --- a/components/root/form-save-button.tsx +++ b/components/root/form-save-button.tsx @@ -39,7 +39,9 @@ export function FormSaveButton({ const [isDirty, setIsDirty] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const baselineRef = useRef>(new Map()); + const dirtyFormsRef = useRef>(new Set()); const activeFormIdRef = useRef(formId ?? null); + const frameRef = useRef(null); const pendingSubmissionRef = useRef<{ formId: string; originUrl: string; @@ -54,6 +56,7 @@ export function FormSaveButton({ setActiveFormId(null); setIsDirty(false); setIsSubmitting(false); + dirtyFormsRef.current.clear(); } }, [formId, formIds, formSelector, formSelectors]); @@ -69,7 +72,12 @@ export function FormSaveButton({ const forms = Array.from(new Map([...formsById, ...formsBySelector].map((form) => [form.id, form])).values()); if (forms.length === 0) { + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } baselineRef.current.clear(); + dirtyFormsRef.current.clear(); activeFormIdRef.current = formId ?? null; setActiveFormId(formId ?? null); setIsDirty(false); @@ -80,33 +88,74 @@ export function FormSaveButton({ const availableIds = forms.map((form) => form.id).filter(Boolean); const fallbackFormId = availableIds[0] ?? null; - const readDirtyState = (nextActiveFormId: string | null) => { - if (!nextActiveFormId) { - setIsDirty(false); + const selectDirtyFormId = () => { + const dirtyIds = availableIds.filter((id) => dirtyFormsRef.current.has(id)); + + if (dirtyIds.length === 0) { + return activeFormIdRef.current && availableIds.includes(activeFormIdRef.current) + ? activeFormIdRef.current + : fallbackFormId; + } + + if (activeFormIdRef.current && dirtyFormsRef.current.has(activeFormIdRef.current)) { + return activeFormIdRef.current; + } + + return dirtyIds[0] ?? fallbackFormId; + }; + + const syncDirtyState = () => { + const nextActiveFormId = selectDirtyFormId(); + + activeFormIdRef.current = nextActiveFormId; + setActiveFormId(nextActiveFormId); + setIsDirty(dirtyFormsRef.current.size > 0); + }; + + const evaluateForm = (form: HTMLFormElement) => { + const baseline = baselineRef.current.get(form.id); + const current = serializeForm(form); + + if (baseline === undefined) { return; } - const nextForm = forms.find((form) => form.id === nextActiveFormId); - - if (!nextForm) { - setIsDirty(false); + if (current !== baseline) { + dirtyFormsRef.current.add(form.id); return; } - setIsDirty(serializeForm(nextForm) !== baselineRef.current.get(nextActiveFormId)); + dirtyFormsRef.current.delete(form.id); + }; + + const scheduleSync = (nextActiveFormId?: string) => { + if (nextActiveFormId) { + activeFormIdRef.current = nextActiveFormId; + } + + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current); + } + + frameRef.current = requestAnimationFrame(() => { + frameRef.current = null; + for (const form of forms) { + evaluateForm(form); + } + syncDirtyState(); + }); }; const syncBaseline = (form: HTMLFormElement) => { baselineRef.current.set(form.id, serializeForm(form)); - readDirtyState(form.id === activeFormIdRef.current ? form.id : activeFormIdRef.current ?? fallbackFormId); + dirtyFormsRef.current.delete(form.id); + syncDirtyState(); setIsSubmitting(false); }; const handleFormActivity = (form: HTMLFormElement) => { - activeFormIdRef.current = form.id; - setActiveFormId(form.id); setIsSubmitting(false); - setIsDirty(serializeForm(form) !== baselineRef.current.get(form.id)); + scheduleSync(form.id); }; const handleSubmit = (form: HTMLFormElement, event: SubmitEvent) => { @@ -162,9 +211,17 @@ export function FormSaveButton({ : fallbackFormId; activeFormIdRef.current = nextActive; setActiveFormId(nextActive); - readDirtyState(nextActive); + for (const form of forms) { + evaluateForm(form); + } + syncDirtyState(); return () => { + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + for (const form of forms) { const handlers = (form as HTMLFormElement & { __saveButtonHandlers?: { @@ -214,6 +271,7 @@ export function FormSaveButton({ if (hasError) { pendingSubmissionRef.current = null; setIsSubmitting(false); + dirtyFormsRef.current.delete(pendingSubmission.formId); return; } diff --git a/components/root/portfolio-project-form.tsx b/components/root/portfolio-project-form.tsx index 41343b3..16b804e 100644 --- a/components/root/portfolio-project-form.tsx +++ b/components/root/portfolio-project-form.tsx @@ -80,7 +80,6 @@ type PortfolioProjectFormProps = { project?: PortfolioProjectView | null; formId: string; redirectPath: string; - submitLabel: string; }; const localeFieldConfig = [ @@ -286,7 +285,6 @@ export function PortfolioProjectForm({ project, formId, redirectPath, - submitLabel, }: PortfolioProjectFormProps) { const [activePanel, setActivePanel] = useState("basic"); const [projectState, setProjectState] = useState( @@ -864,19 +862,6 @@ export function PortfolioProjectForm({
- - - -

- {validation.allDone - ? "Alles bereit zum Speichern." - : "Du kannst jetzt speichern. Falls Pflichtfelder fehlen, bekommst du oben eine Fehlermeldung."} -

- -
-
); } diff --git a/components/root/sidebar-maintenance-control.tsx b/components/root/sidebar-maintenance-control.tsx index f0bbb96..39592e3 100644 --- a/components/root/sidebar-maintenance-control.tsx +++ b/components/root/sidebar-maintenance-control.tsx @@ -29,7 +29,6 @@ export function SidebarMaintenanceControl({ return (