From ee21e8b823c24c8716065ab8dec36e3430dc84d5 Mon Sep 17 00:00:00 2001 From: MOH Date: Sat, 7 Mar 2026 19:20:58 +0100 Subject: [PATCH] Improve admin save UX and portfolio navigation --- app/root/maintenance/actions.ts | 39 +++ app/root/maintenance/page.tsx | 66 +----- app/root/media/page.tsx | 9 +- app/root/page.tsx | 7 - app/root/portfolio/actions.ts | 6 +- app/root/portfolio/categories/page.tsx | 24 +- app/root/portfolio/page.tsx | 207 +++++++++++----- app/root/portfolio/projects/[id]/page.tsx | 15 +- app/root/portfolio/projects/new/page.tsx | 9 +- app/root/portfolio/projects/page.tsx | 204 +--------------- app/root/site-settings/page.tsx | 12 +- components/dashboard/sidebar.tsx | 10 +- components/root/flash-message.tsx | 67 ++++++ components/root/form-save-button.tsx | 223 ++++++++++++++++-- components/root/portfolio-project-form.tsx | 4 +- components/root/portfolio-subnav.tsx | 7 +- components/root/root-dashboard-shell.tsx | 39 ++- .../root/sidebar-maintenance-control.tsx | 75 ++++++ lib/root-navigation.ts | 53 ++--- 19 files changed, 652 insertions(+), 424 deletions(-) create mode 100644 app/root/maintenance/actions.ts create mode 100644 components/root/flash-message.tsx create mode 100644 components/root/sidebar-maintenance-control.tsx diff --git a/app/root/maintenance/actions.ts b/app/root/maintenance/actions.ts new file mode 100644 index 0000000..6ab8b3c --- /dev/null +++ b/app/root/maintenance/actions.ts @@ -0,0 +1,39 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; + +import { routing } from "@/i18n/routing"; +import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; +import { getLocalizedPath } from "@/lib/locale"; +import { setMaintenanceMode } from "@/lib/app-config"; + +function ensureAdmin() { + if (!isAdminAuthenticated()) { + clearAdminSessionCookie(); + redirect("/root"); + } +} + +export async function updateMaintenanceModeAction(formData: FormData) { + ensureAdmin(); + + const nextValue = formData.get("enabled") === "true"; + const redirectPath = String(formData.get("redirectPath") ?? "/root"); + const redirectUrl = new URL(redirectPath, "http://localhost"); + redirectUrl.searchParams.set("__saved", "maintenance"); + + await setMaintenanceMode(nextValue); + revalidatePath("/", "layout"); + revalidatePath("/coming-soon"); + revalidatePath("/root"); + revalidatePath("/root/maintenance"); + revalidatePath(redirectPath); + + for (const appLocale of routing.locales) { + revalidatePath(getLocalizedPath(appLocale), "layout"); + revalidatePath(getLocalizedPath(appLocale, "/coming-soon")); + } + + redirect(`${redirectUrl.pathname}${redirectUrl.search}`); +} diff --git a/app/root/maintenance/page.tsx b/app/root/maintenance/page.tsx index 8948f49..c327350 100644 --- a/app/root/maintenance/page.tsx +++ b/app/root/maintenance/page.tsx @@ -1,18 +1,12 @@ -import { Power } from "lucide-react"; -import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; -import { FormSaveButton } from "@/components/root/form-save-button"; import { MotionFade } from "@/components/motion-fade"; import { RootDashboardShell } from "@/components/root/root-dashboard-shell"; -import { routing } from "@/i18n/routing"; -import { getLocalizedPath } from "@/lib/locale"; import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; -import { getMaintenanceMode, setMaintenanceMode } from "@/lib/app-config"; +import { getMaintenanceMode } from "@/lib/app-config"; import { AppCard } from "@/components/ui/app-card"; import { Badge } from "@/components/ui/badge"; import { CardContent } from "@/components/ui/card"; -import { Label } from "@/components/ui/label"; export const dynamic = "force-dynamic"; @@ -28,8 +22,7 @@ const copy = { maintenanceText: "Wenn aktiv, werden alle Seiten auf die Coming Soon Seite weitergeleitet.", maintenanceOn: "Aktiv", maintenanceOff: "Inaktiv", - selectLabel: "Status", - selectHint: "Aenderung wird erst nach Speichern uebernommen.", + selectHint: "Aenderung erfolgt jetzt direkt ueber den Schalter in der Sidebar und wird mit dem Save Button oben gespeichert.", logout: "Ausloggen", backToSite: "Zur Website", }; @@ -42,7 +35,6 @@ export default async function RootMaintenancePage() { } const maintenanceEnabled = await getMaintenanceMode(); - async function logoutAction() { "use server"; @@ -50,29 +42,6 @@ export default async function RootMaintenancePage() { redirect("/root"); } - async function updateMaintenanceMode(formData: FormData) { - "use server"; - - if (!isAdminAuthenticated()) { - redirect("/root"); - } - - const nextValue = formData.get("enabled") === "true"; - - await setMaintenanceMode(nextValue); - revalidatePath("/", "layout"); - revalidatePath("/coming-soon"); - revalidatePath("/root"); - revalidatePath("/root/maintenance"); - - for (const appLocale of routing.locales) { - revalidatePath(getLocalizedPath(appLocale), "layout"); - revalidatePath(getLocalizedPath(appLocale, "/coming-soon")); - } - - redirect("/root/maintenance"); - } - return ( - - {maintenanceEnabled ? copy.maintenanceOn : copy.maintenanceOff} - - - - } >

{copy.maintenanceText}

-
-
- - -

{copy.selectHint}

-
-
- +
+ {maintenanceEnabled ? copy.maintenanceOn : copy.maintenanceOff} -
- + +

{copy.selectHint}

+
diff --git a/app/root/media/page.tsx b/app/root/media/page.tsx index f4332a4..f172694 100644 --- a/app/root/media/page.tsx +++ b/app/root/media/page.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { redirect } from "next/navigation"; import { MotionFade } from "@/components/motion-fade"; +import { FlashMessage } from "@/components/root/flash-message"; import { RootDashboardShell } from "@/components/root/root-dashboard-shell"; import { AppCard } from "@/components/ui/app-card"; import { Button } from "@/components/ui/button"; @@ -74,17 +75,13 @@ export default async function RootMediaPage({ searchParams }: RootMediaPageProps
{searchParams?.success ? ( -

- {searchParams.success} -

+
) : null} {searchParams?.error ? ( -

- {searchParams.error} -

+
) : null} diff --git a/app/root/page.tsx b/app/root/page.tsx index 0e28d1b..fdf16ed 100644 --- a/app/root/page.tsx +++ b/app/root/page.tsx @@ -277,13 +277,6 @@ export default async function RootPage({ searchParams }: RootPageProps) { logoutAction={logoutAction} headerTitle={copy.title} headerDescription={copy.subtitle} - sidebarTopContent={ - maintenanceEnabled ? ( -

- {copy.maintenanceVisitorsShort} -

- ) : null - } >
diff --git a/app/root/portfolio/actions.ts b/app/root/portfolio/actions.ts index 5a1f433..3bd287f 100644 --- a/app/root/portfolio/actions.ts +++ b/app/root/portfolio/actions.ts @@ -574,7 +574,7 @@ export async function deleteProjectAction(formData: FormData) { }); if (!project) { - redirect(withMessage("/root/portfolio/projects", "error", "Project not found.")); + redirect(withMessage("/root/portfolio", "error", "Project not found.")); } await prisma.portfolioProject.delete({ @@ -591,12 +591,12 @@ export async function deleteProjectAction(formData: FormData) { revalidatePath(getLocalizedPath(locale, `/portfolio/${project.slug}`)); } - redirect(withMessage("/root/portfolio/projects", "success", "Project deleted.")); + redirect(withMessage("/root/portfolio", "success", "Project deleted.")); } catch (error) { if (isRedirectError(error)) { throw error; } - redirect(withMessage("/root/portfolio/projects", "error", "Unable to delete project.")); + redirect(withMessage("/root/portfolio", "error", "Unable to delete project.")); } } diff --git a/app/root/portfolio/categories/page.tsx b/app/root/portfolio/categories/page.tsx index 979686f..60f3c6d 100644 --- a/app/root/portfolio/categories/page.tsx +++ b/app/root/portfolio/categories/page.tsx @@ -1,6 +1,7 @@ import { redirect } from "next/navigation"; import { MotionFade } from "@/components/motion-fade"; +import { FlashMessage } from "@/components/root/flash-message"; import { PortfolioSubnav } from "@/components/root/portfolio-subnav"; import { RootDashboardShell } from "@/components/root/root-dashboard-shell"; import { AppCard } from "@/components/ui/app-card"; @@ -74,22 +75,19 @@ export default async function RootPortfolioCategoriesPage({ logoutAction={logoutAction} headerTitle={copy.title} headerDescription={copy.subtitle} + saveFormSelector="[data-topbar-save-form='category']" toolbar={} >
{searchParams?.success ? ( -

- {searchParams.success} -

+
) : null} {searchParams?.error ? ( -

- {searchParams.error} -

+
) : null} @@ -100,7 +98,12 @@ export default async function RootPortfolioCategoriesPage({ Eine Kategorie wird genau einem oder mehreren Projekten zugeordnet. -
+
@@ -162,7 +165,12 @@ export default async function RootPortfolioCategoriesPage({ - + diff --git a/app/root/portfolio/page.tsx b/app/root/portfolio/page.tsx index b6af770..3ccffd8 100644 --- a/app/root/portfolio/page.tsx +++ b/app/root/portfolio/page.tsx @@ -1,18 +1,20 @@ -import { Boxes, FolderKanban, ImageIcon, Layers3, Plus, Tags } from "lucide-react"; +import { Boxes, ExternalLink, FolderKanban, Layers3, Plus, Tags } from "lucide-react"; import Link from "next/link"; import { redirect } from "next/navigation"; -import { PortfolioSubnav } from "@/components/root/portfolio-subnav"; import { RootDashboardShell } from "@/components/root/root-dashboard-shell"; import { MotionFade } from "@/components/motion-fade"; +import { FlashMessage } from "@/components/root/flash-message"; import { AppCard } from "@/components/ui/app-card"; import { Button } from "@/components/ui/button"; -import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; import { getAdminPortfolioCategories, getAdminPortfolioProjects, + getLocalizedValue, } from "@/lib/portfolio"; +import { getLocalizedPath } from "@/lib/locale"; export const dynamic = "force-dynamic"; @@ -29,14 +31,32 @@ const copy = { totalCategories: "Kategorien", totalProjects: "Projekte", publishedProjects: "Veroeffentlicht", - categoriesAction: "Kategorien verwalten", - projectsAction: "Projekte verwalten", newProject: "Neues Projekt", newCategory: "Neue Kategorie", - media: "Media", + category: "Kategorie", + all: "Alle", + status: "Status", + draft: "Entwurf", + published: "Veroeffentlicht", + filter: "Filtern", + sort: "Sortierung", + previewSet: "Preview Link gesetzt", + previewMissing: "Kein Preview Link", + editProject: "Projekt bearbeiten", + openProject: "Projekt ansehen", + empty: "Keine Projekte fuer die aktuellen Filter gefunden.", }; -export default async function RootPortfolioPage() { +type RootPortfolioPageProps = { + searchParams?: { + category?: string; + status?: "all" | "draft" | "published"; + success?: string; + error?: string; + }; +}; + +export default async function RootPortfolioPage({ searchParams }: RootPortfolioPageProps) { if (!isAdminAuthenticated()) { redirect("/root"); } @@ -48,9 +68,15 @@ export default async function RootPortfolioPage() { redirect("/root"); } + const selectedStatus = searchParams?.status === "draft" || searchParams?.status === "published" + ? searchParams.status + : "all"; const [categories, projects] = await Promise.all([ getAdminPortfolioCategories(), - getAdminPortfolioProjects(), + getAdminPortfolioProjects({ + categoryId: searchParams?.category || undefined, + status: selectedStatus, + }), ]); const publishedProjects = projects.filter((project) => project.isPublished).length; @@ -62,7 +88,6 @@ export default async function RootPortfolioPage() { logoutAction={logoutAction} headerTitle={copy.title} headerDescription={copy.subtitle} - toolbar={} headerActions={
- - - + + + + +
+ + +
- - - - {copy.totalProjects} - Entwuerfe, veroeffentlichte Projekte und flexible Abschnitte pflegen. - - - - - - - +
+ + +
- - - - {copy.media} - Alle Cover und Projektdateien an einem Ort pruefen. - - - - - - -
+
+ +
+ + + + + +
+ {projects.map((project, index) => ( + + + +
+ {getLocalizedValue(project.title, "de")} +

+ {project.category.name.de} +

+
+
+ + {project.isPublished ? copy.published : copy.draft} + + + {project.projectYear} + + + {copy.sort} {project.sortOrder} + +
+
+ +
+

{project.slug}

+

{project.previewUrl ? copy.previewSet : copy.previewMissing}

+
+
+ + +
+
+
+
+ ))} + + {projects.length === 0 ? ( + + + + {copy.empty} + + + + ) : null} +
); diff --git a/app/root/portfolio/projects/[id]/page.tsx b/app/root/portfolio/projects/[id]/page.tsx index 799c876..4d60fdb 100644 --- a/app/root/portfolio/projects/[id]/page.tsx +++ b/app/root/portfolio/projects/[id]/page.tsx @@ -1,8 +1,8 @@ import { redirect } from "next/navigation"; import { MotionFade } from "@/components/motion-fade"; +import { FlashMessage } from "@/components/root/flash-message"; import { PortfolioProjectForm } from "@/components/root/portfolio-project-form"; -import { PortfolioSubnav } from "@/components/root/portfolio-subnav"; import { RootDashboardShell } from "@/components/root/root-dashboard-shell"; import { AppCard } from "@/components/ui/app-card"; import { Button } from "@/components/ui/button"; @@ -67,7 +67,7 @@ export default async function RootPortfolioProjectPage({ ]); if (!project) { - redirect("/root/portfolio/projects?error=Project+not+found."); + redirect("/root/portfolio?error=Project+not+found."); } return ( @@ -78,22 +78,18 @@ export default async function RootPortfolioProjectPage({ logoutAction={logoutAction} headerTitle={copy.title} headerDescription={copy.subtitle} - toolbar={} + saveFormId="portfolio-project-form" >
{searchParams?.success ? ( -

- {searchParams.success} -

+
) : null} {searchParams?.error ? ( -

- {searchParams.error} -

+
) : null} @@ -103,6 +99,7 @@ export default async function RootPortfolioProjectPage({ categories={categories} mediaOptions={mediaOptions} 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 5c071a4..f89dc6f 100644 --- a/app/root/portfolio/projects/new/page.tsx +++ b/app/root/portfolio/projects/new/page.tsx @@ -1,8 +1,8 @@ import { redirect } from "next/navigation"; import { MotionFade } from "@/components/motion-fade"; +import { FlashMessage } from "@/components/root/flash-message"; import { PortfolioProjectForm } from "@/components/root/portfolio-project-form"; -import { PortfolioSubnav } from "@/components/root/portfolio-subnav"; import { RootDashboardShell } from "@/components/root/root-dashboard-shell"; import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; import { getMediaOptions } from "@/lib/media"; @@ -58,14 +58,12 @@ export default async function RootNewPortfolioProjectPage({ logoutAction={logoutAction} headerTitle={copy.title} headerDescription={copy.subtitle} - toolbar={} + saveFormId="portfolio-project-form" >
{searchParams?.error ? ( -

- {searchParams.error} -

+
) : null} @@ -74,6 +72,7 @@ export default async function RootNewPortfolioProjectPage({ action={saveProjectAction} categories={categories} mediaOptions={mediaOptions} + formId="portfolio-project-form" redirectPath="/root/portfolio/projects/new" submitLabel="Projekt anlegen" /> diff --git a/app/root/portfolio/projects/page.tsx b/app/root/portfolio/projects/page.tsx index 42e02ea..4fa0ad7 100644 --- a/app/root/portfolio/projects/page.tsx +++ b/app/root/portfolio/projects/page.tsx @@ -1,47 +1,7 @@ -import { Plus } from "lucide-react"; -import Link from "next/link"; import { redirect } from "next/navigation"; -import { MotionFade } from "@/components/motion-fade"; -import { PortfolioSubnav } from "@/components/root/portfolio-subnav"; -import { RootDashboardShell } from "@/components/root/root-dashboard-shell"; -import { AppCard } from "@/components/ui/app-card"; -import { Button } from "@/components/ui/button"; -import { CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; -import { - getAdminPortfolioCategories, - getAdminPortfolioProjects, - getLocalizedValue, -} from "@/lib/portfolio"; - export const dynamic = "force-dynamic"; -const copy = { - title: "Portfolio Projekte", - subtitle: "Alle Projekte mit Status, Kategorie und Reihenfolge.", - overview: "Uebersicht", - maintenance: "Wartungsmodus", - uiKit: "UI Kit", - media: "Media", - siteSettings: "SEO", - portfolio: "Portfolio", - logout: "Ausloggen", - backToSite: "Zur Website", - newProject: "Neues Projekt", - category: "Kategorie", - all: "Alle", - status: "Status", - draft: "Entwurf", - published: "Veroeffentlicht", - filter: "Filtern", - sort: "Sortierung", - previewSet: "Preview Link gesetzt", - previewMissing: "Kein Preview Link", - editProject: "Projekt bearbeiten", - empty: "Keine Projekte fuer die aktuellen Filter gefunden.", -}; - type RootPortfolioProjectsPageProps = { searchParams?: { category?: string; @@ -54,161 +14,23 @@ type RootPortfolioProjectsPageProps = { export default async function RootPortfolioProjectsPage({ searchParams, }: RootPortfolioProjectsPageProps) { - if (!isAdminAuthenticated()) { - redirect("/root"); + const params = new URLSearchParams(); + + if (searchParams?.category) { + params.set("category", searchParams.category); } - async function logoutAction() { - "use server"; - - clearAdminSessionCookie(); - redirect("/root"); + if (searchParams?.status) { + params.set("status", searchParams.status); } - const selectedStatus = searchParams?.status === "draft" || searchParams?.status === "published" - ? searchParams.status - : "all"; - const [categories, projects] = await Promise.all([ - getAdminPortfolioCategories(), - getAdminPortfolioProjects({ - categoryId: searchParams?.category || undefined, - status: selectedStatus, - }), - ]); + if (searchParams?.success) { + params.set("success", searchParams.success); + } - return ( - } - headerActions={ - - - - } - > -
- {searchParams?.success ? ( - -

- {searchParams.success} -

-
- ) : null} + if (searchParams?.error) { + params.set("error", searchParams.error); + } - {searchParams?.error ? ( - -

- {searchParams.error} -

-
- ) : null} - - - - -
-
- - -
- -
- - -
- -
- -
-
-
-
-
- -
- {projects.map((project, index) => ( - - - -
- {getLocalizedValue(project.title, "de")} -

- {project.category.name.de} -

-
-
- - {project.isPublished ? copy.published : copy.draft} - - - {project.projectYear} - - - {copy.sort} {project.sortOrder} - -
-
- -
-

{project.slug}

-

{project.previewUrl ? copy.previewSet : copy.previewMissing}

-
- -
-
-
- ))} - - {projects.length === 0 ? ( - - - - {copy.empty} - - - - ) : null} -
-
-
- ); + redirect(params.toString() ? `/root/portfolio?${params.toString()}` : "/root/portfolio"); } diff --git a/app/root/site-settings/page.tsx b/app/root/site-settings/page.tsx index 2e8758c..991a965 100644 --- a/app/root/site-settings/page.tsx +++ b/app/root/site-settings/page.tsx @@ -2,7 +2,7 @@ import { MediaKind } from "@prisma/client"; import { redirect } from "next/navigation"; import { MotionFade } from "@/components/motion-fade"; -import { FormSaveButton } from "@/components/root/form-save-button"; +import { FlashMessage } from "@/components/root/flash-message"; import { SiteSettingsForm } from "@/components/root/site-settings-form"; import { RootDashboardShell } from "@/components/root/root-dashboard-shell"; import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; @@ -63,22 +63,18 @@ export default async function RootSiteSettingsPage({ logoutAction={logoutAction} headerTitle={copy.title} headerDescription={copy.subtitle} - headerActions={} + saveFormId="site-settings-form" >
{searchParams?.success ? ( -

- {searchParams.success} -

+
) : null} {searchParams?.error ? ( -

- {searchParams.error} -

+
) : null} diff --git a/components/dashboard/sidebar.tsx b/components/dashboard/sidebar.tsx index fe3347d..b2d299f 100644 --- a/components/dashboard/sidebar.tsx +++ b/components/dashboard/sidebar.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from "react"; import Link from "next/link"; -import type { LucideIcon } from "lucide-react"; +import { ChevronDown, ChevronRight, type LucideIcon } from "lucide-react"; import { Separator } from "@/components/ui/separator"; import { cn } from "@/lib/utils"; @@ -10,6 +10,7 @@ type DashboardSidebarItem = { href: string; icon: LucideIcon; active?: boolean; + expanded?: boolean; children?: DashboardSidebarItem[]; }; @@ -23,6 +24,8 @@ type DashboardSidebarProps = { export function DashboardSidebar({ items, iconSrc, top, footer }: DashboardSidebarProps) { function renderItem(item: DashboardSidebarItem, nested = false) { const Icon = item.icon; + const hasChildren = Boolean(item.children?.length); + const ChevronIcon = item.expanded ? ChevronDown : ChevronRight; return (
@@ -37,9 +40,10 @@ export function DashboardSidebar({ items, iconSrc, top, footer }: DashboardSideb )} > - {item.label} + {item.label} + {hasChildren ? : null} - {item.children?.length ? item.children.map((child) => renderItem(child, true)) : null} + {item.expanded && item.children?.length ? item.children.map((child) => renderItem(child, true)) : null}
); } diff --git a/components/root/flash-message.tsx b/components/root/flash-message.tsx new file mode 100644 index 0000000..93e1571 --- /dev/null +++ b/components/root/flash-message.tsx @@ -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 ( +

+ {message} +

+ ); +} diff --git a/components/root/form-save-button.tsx b/components/root/form-save-button.tsx index ca514ce..a9b6683 100644 --- a/components/root/form-save-button.tsx +++ b/components/root/form-save-button.tsx @@ -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(formId ?? null); const [isDirty, setIsDirty] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const baselineRef = useRef>(new Map()); + const activeFormIdRef = useRef(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 ( - ); } diff --git a/components/root/portfolio-project-form.tsx b/components/root/portfolio-project-form.tsx index b956ae8..ac5e59e 100644 --- a/components/root/portfolio-project-form.tsx +++ b/components/root/portfolio-project-form.tsx @@ -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 ( -
+ diff --git a/components/root/portfolio-subnav.tsx b/components/root/portfolio-subnav.tsx index 4eac096..d0a0f6b 100644 --- a/components/root/portfolio-subnav.tsx +++ b/components/root/portfolio-subnav.tsx @@ -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) { diff --git a/components/root/root-dashboard-shell.tsx b/components/root/root-dashboard-shell.tsx index bffcf31..02c24d5 100644 --- a/components/root/root-dashboard-shell.tsx +++ b/components/root/root-dashboard-shell.tsx @@ -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; 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 = ( <> + ); @@ -105,16 +123,13 @@ export async function RootDashboardShell({ } sidebarFooter={ <> - +