Improve admin save UX and portfolio navigation
CI / quality (push) Waiting to run

This commit is contained in:
MOH
2026-03-07 19:20:58 +01:00
parent 3d1976a7a5
commit ee21e8b823
19 changed files with 652 additions and 424 deletions
+67
View File
@@ -0,0 +1,67 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { cn } from "@/lib/utils";
type FlashMessageProps = {
type: "success" | "error";
message: string;
clearDelayMs?: number;
};
export function FlashMessage({
type,
message,
clearDelayMs = 4000,
}: FlashMessageProps) {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const [visible, setVisible] = useState(true);
useEffect(() => {
setVisible(true);
}, [message, pathname, searchParams]);
useEffect(() => {
if (!message) {
return undefined;
}
const timeoutId = window.setTimeout(() => {
setVisible(false);
const nextParams = new URLSearchParams(searchParams.toString());
nextParams.delete("success");
nextParams.delete("error");
const nextQuery = nextParams.toString();
router.replace(nextQuery ? `${pathname}?${nextQuery}` : pathname, {
scroll: false,
});
}, clearDelayMs);
return () => {
window.clearTimeout(timeoutId);
};
}, [clearDelayMs, message, pathname, router, searchParams]);
if (!visible) {
return null;
}
return (
<p
className={cn(
"rounded-nested border px-4 py-3 text-sm",
type === "success"
? "border-status-success/30 bg-status-success/10 text-status-success"
: "border-destructive/30 bg-destructive/10 text-destructive",
)}
>
{message}
</p>
);
}
+203 -20
View File
@@ -1,12 +1,16 @@
"use client";
import { Save } from "lucide-react";
import { useEffect, useState } from "react";
import { LoaderCircle, Save } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
type FormSaveButtonProps = {
formId: string;
formId?: string;
formIds?: string[];
formSelector?: string;
formSelectors?: string[];
label?: string;
};
@@ -21,41 +25,220 @@ function serializeForm(form: HTMLFormElement) {
export function FormSaveButton({
formId,
formIds,
formSelector,
formSelectors,
label = "Speichern",
}: FormSaveButtonProps) {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const [activeFormId, setActiveFormId] = useState<string | null>(formId ?? null);
const [isDirty, setIsDirty] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const baselineRef = useRef<Map<string, string>>(new Map());
const activeFormIdRef = useRef<string | null>(formId ?? null);
const pendingSubmissionRef = useRef<{
formId: string;
originUrl: string;
} | null>(null);
const currentUrl = `${pathname}?${searchParams.toString()}`;
useEffect(() => {
const form = document.getElementById(formId);
if (!(form instanceof HTMLFormElement)) {
if (!formId && !formIds?.length && !formSelector && !formSelectors?.length) {
activeFormIdRef.current = null;
pendingSubmissionRef.current = null;
setActiveFormId(null);
setIsDirty(false);
setIsSubmitting(false);
}
}, [formId, formIds, formSelector, formSelectors]);
useEffect(() => {
const formsById = [formId, ...(formIds ?? [])]
.filter((value): value is string => Boolean(value))
.map((id) => document.getElementById(id))
.filter((form): form is HTMLFormElement => form instanceof HTMLFormElement);
const formsBySelector = [formSelector, ...(formSelectors ?? [])]
.filter((value): value is string => Boolean(value))
.flatMap((selector) => Array.from(document.querySelectorAll(selector)))
.filter((form): form is HTMLFormElement => form instanceof HTMLFormElement);
const forms = Array.from(new Map([...formsById, ...formsBySelector].map((form) => [form.id, form])).values());
if (forms.length === 0) {
baselineRef.current.clear();
activeFormIdRef.current = formId ?? null;
setActiveFormId(formId ?? null);
setIsDirty(false);
setIsSubmitting(false);
return undefined;
}
const initialSnapshot = serializeForm(form);
const availableIds = forms.map((form) => form.id).filter(Boolean);
const fallbackFormId = availableIds[0] ?? null;
const updateDirtyState = () => {
setIsDirty(serializeForm(form) !== initialSnapshot);
const readDirtyState = (nextActiveFormId: string | null) => {
if (!nextActiveFormId) {
setIsDirty(false);
return;
}
const nextForm = forms.find((form) => form.id === nextActiveFormId);
if (!nextForm) {
setIsDirty(false);
return;
}
setIsDirty(serializeForm(nextForm) !== baselineRef.current.get(nextActiveFormId));
};
updateDirtyState();
const syncBaseline = (form: HTMLFormElement) => {
baselineRef.current.set(form.id, serializeForm(form));
readDirtyState(form.id === activeFormIdRef.current ? form.id : activeFormIdRef.current ?? fallbackFormId);
setIsSubmitting(false);
};
form.addEventListener("input", updateDirtyState);
form.addEventListener("change", updateDirtyState);
form.addEventListener("reset", updateDirtyState);
const handleFormActivity = (form: HTMLFormElement) => {
activeFormIdRef.current = form.id;
setActiveFormId(form.id);
setIsSubmitting(false);
setIsDirty(serializeForm(form) !== baselineRef.current.get(form.id));
};
const handleSubmit = (form: HTMLFormElement, event: SubmitEvent) => {
if (pendingSubmissionRef.current) {
event.preventDefault();
event.stopPropagation();
return;
}
activeFormIdRef.current = form.id;
pendingSubmissionRef.current = {
formId: form.id,
originUrl: currentUrl,
};
setActiveFormId(form.id);
setIsSubmitting(true);
setIsDirty(false);
};
for (const form of forms) {
if (!form.id) {
continue;
}
syncBaseline(form);
const onFocusIn = () => handleFormActivity(form);
const onInput = () => handleFormActivity(form);
const onChange = () => handleFormActivity(form);
const onReset = () => syncBaseline(form);
const onSubmit = (event: Event) => handleSubmit(form, event as SubmitEvent);
form.addEventListener("focusin", onFocusIn);
form.addEventListener("input", onInput);
form.addEventListener("change", onChange);
form.addEventListener("reset", onReset);
form.addEventListener("submit", onSubmit);
(form as HTMLFormElement & {
__saveButtonHandlers?: {
onFocusIn: () => void;
onInput: () => void;
onChange: () => void;
onReset: () => void;
onSubmit: (event: Event) => void;
};
}).__saveButtonHandlers = { onFocusIn, onInput, onChange, onReset, onSubmit };
}
const nextActive =
activeFormIdRef.current && availableIds.includes(activeFormIdRef.current)
? activeFormIdRef.current
: fallbackFormId;
activeFormIdRef.current = nextActive;
setActiveFormId(nextActive);
readDirtyState(nextActive);
return () => {
form.removeEventListener("input", updateDirtyState);
form.removeEventListener("change", updateDirtyState);
form.removeEventListener("reset", updateDirtyState);
for (const form of forms) {
const handlers = (form as HTMLFormElement & {
__saveButtonHandlers?: {
onFocusIn: () => void;
onInput: () => void;
onChange: () => void;
onReset: () => void;
onSubmit: (event: Event) => void;
};
}).__saveButtonHandlers;
if (!handlers) {
continue;
}
form.removeEventListener("focusin", handlers.onFocusIn);
form.removeEventListener("input", handlers.onInput);
form.removeEventListener("change", handlers.onChange);
form.removeEventListener("reset", handlers.onReset);
form.removeEventListener("submit", handlers.onSubmit);
delete (
form as HTMLFormElement & {
__saveButtonHandlers?: {
onFocusIn: () => void;
onInput: () => void;
onChange: () => void;
onReset: () => void;
onSubmit: (event: Event) => void;
};
}
).__saveButtonHandlers;
}
};
}, [formId]);
}, [currentUrl, formId, formIds, formSelector, formSelectors, pathname, searchParams]);
useEffect(() => {
const pendingSubmission = pendingSubmissionRef.current;
if (!pendingSubmission) {
return;
}
const hasError = searchParams.has("error");
const hasSuccess = searchParams.has("success") || searchParams.has("__saved");
const navigated = currentUrl !== pendingSubmission.originUrl;
if (hasError) {
pendingSubmissionRef.current = null;
setIsSubmitting(false);
return;
}
if (!hasSuccess && !navigated) {
return;
}
pendingSubmissionRef.current = null;
router.refresh();
if (!searchParams.has("__saved")) {
return;
}
const nextParams = new URLSearchParams(searchParams.toString());
nextParams.delete("__saved");
const nextQuery = nextParams.toString();
router.replace(nextQuery ? `${pathname}?${nextQuery}` : pathname, {
scroll: false,
});
}, [currentUrl, pathname, router, searchParams]);
return (
<Button type="submit" form={formId} disabled={!isDirty}>
<Save className="h-4 w-4" />
{label}
<Button type="submit" form={activeFormId ?? undefined} disabled={!activeFormId || !isDirty || isSubmitting}>
{isSubmitting ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{isSubmitting ? "Speichert..." : label}
</Button>
);
}
+3 -1
View File
@@ -48,6 +48,7 @@ type PortfolioProjectFormProps = {
categories: PortfolioCategoryView[];
mediaOptions: MediaOption[];
project?: PortfolioProjectView | null;
formId: string;
redirectPath: string;
submitLabel: string;
};
@@ -120,6 +121,7 @@ export function PortfolioProjectForm({
categories,
mediaOptions,
project,
formId,
redirectPath,
submitLabel,
}: PortfolioProjectFormProps) {
@@ -190,7 +192,7 @@ export function PortfolioProjectForm({
);
return (
<form action={action} className="space-y-6">
<form id={formId} action={action} className="space-y-6">
<input type="hidden" name="id" value={project?.id ?? ""} />
<input type="hidden" name="redirectPath" value={redirectPath} />
<input type="hidden" name="currentCoverImagePath" value={project?.coverImagePath ?? ""} />
+1 -6
View File
@@ -5,7 +5,7 @@ import { CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
type PortfolioSubnavProps = {
active: "overview" | "categories" | "projects";
active: "overview" | "categories";
};
const items = [
@@ -19,11 +19,6 @@ const items = [
label: "Kategorien",
href: "/root/portfolio/categories",
},
{
key: "projects",
label: "Projekte",
href: "/root/portfolio/projects",
},
] as const;
export function PortfolioSubnav({ active }: PortfolioSubnavProps) {
+27 -12
View File
@@ -15,12 +15,16 @@ import Link from "next/link";
import { DashboardLayout } from "@/components/dashboard/dashboard-layout";
import { MotionFade } from "@/components/motion-fade";
import { FormSaveButton } from "@/components/root/form-save-button";
import { SidebarMaintenanceControl } from "@/components/root/sidebar-maintenance-control";
import { ThemeToggle } from "@/components/theme-toggle";
import { Button } from "@/components/ui/button";
import { getSiteSettingsMediaBindings } from "@/lib/app-config";
import { getMaintenanceMode, getSiteSettingsMediaBindings } from "@/lib/app-config";
import { getLocalizedPath } from "@/lib/locale";
import { getRootNavigation } from "@/lib/root-navigation";
import { updateMaintenanceModeAction } from "@/app/root/maintenance/actions";
type RootDashboardCopy = {
title: string;
subtitle: string;
@@ -41,6 +45,9 @@ type RootDashboardShellProps = {
logoutAction: () => Promise<void>;
headerTitle: string;
headerDescription: string;
saveFormId?: string;
saveFormSelector?: string;
saveButtonLabel?: string;
headerActions?: ReactNode;
sidebarTopContent?: ReactNode;
toolbar?: ReactNode;
@@ -54,12 +61,18 @@ export async function RootDashboardShell({
logoutAction,
headerTitle,
headerDescription,
saveFormId,
saveFormSelector,
saveButtonLabel,
headerActions,
sidebarTopContent,
toolbar,
children,
}: RootDashboardShellProps) {
const mediaBindings = await getSiteSettingsMediaBindings();
const [mediaBindings, maintenanceEnabled] = await Promise.all([
getSiteSettingsMediaBindings(),
getMaintenanceMode(),
]);
const sidebarItems = getRootNavigation(copy, active, portfolioChild).filter(
(item) => item.href !== "/root/maintenance" && item.href !== "/root/ui-kit",
);
@@ -81,6 +94,11 @@ export async function RootDashboardShell({
: FolderKanban;
const sharedActions = (
<>
<FormSaveButton
formIds={saveFormId ? ["sidebar-maintenance-form", saveFormId] : ["sidebar-maintenance-form"]}
formSelectors={saveFormSelector ? [saveFormSelector] : undefined}
label={saveButtonLabel ?? "Speichern"}
/>
<ThemeToggle ariaLabel="Theme wechseln" />
</>
);
@@ -105,16 +123,13 @@ export async function RootDashboardShell({
}
sidebarFooter={
<>
<Button
asChild
variant={active === "maintenance" ? "default" : "outline"}
className="w-full justify-between"
>
<Link href="/root/maintenance">
{copy.maintenance}
<ShieldAlert className="h-4 w-4" />
</Link>
</Button>
<SidebarMaintenanceControl
action={updateMaintenanceModeAction}
initialEnabled={maintenanceEnabled}
label={copy.maintenance}
onLabel="Besucher gesperrt"
offLabel="Website offen"
/>
<Button
asChild
variant={active === "ui-kit" ? "default" : "outline"}
@@ -0,0 +1,75 @@
"use client";
import { ShieldAlert } from "lucide-react";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
type SidebarMaintenanceControlProps = {
action: (formData: FormData) => Promise<void>;
initialEnabled: boolean;
label: string;
onLabel: string;
offLabel: string;
};
export function SidebarMaintenanceControl({
action,
initialEnabled,
label,
onLabel,
offLabel,
}: SidebarMaintenanceControlProps) {
const pathname = usePathname();
const [enabled, setEnabled] = useState(initialEnabled);
return (
<form id="sidebar-maintenance-form" action={action} className="space-y-2">
<input type="hidden" name="redirectPath" value={pathname} />
<input type="hidden" name="enabled" value={enabled ? "true" : "false"} />
<label
htmlFor="sidebar-maintenance-enabled"
className={cn(
"flex cursor-pointer items-center justify-between gap-3 rounded-nested border px-3 py-2 transition-colors",
enabled
? "border-status-warning/40 bg-status-warning-soft/80"
: "border-border bg-surface-1 hover:bg-surface-2",
)}
>
<span className="flex min-w-0 items-center gap-3">
<span
className={cn(
"flex h-9 w-9 items-center justify-center rounded-full border",
enabled
? "border-status-warning/40 bg-status-warning-soft text-status-warning"
: "border-border bg-background text-muted-foreground",
)}
>
<ShieldAlert className="h-4 w-4" />
</span>
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">{label}</span>
<span className="block text-xs text-muted-foreground">
{enabled ? onLabel : offLabel}
</span>
</span>
</span>
<Badge variant={enabled ? "warning" : "success"}>
{enabled ? "ON" : "OFF"}
</Badge>
</label>
<input
id="sidebar-maintenance-enabled"
type="checkbox"
checked={enabled}
onChange={(event) => setEnabled(event.target.checked)}
className="sr-only"
/>
</form>
);
}