This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ?? ""} />
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user