diff --git a/app/root/maintenance/page.tsx b/app/root/maintenance/page.tsx index 123662a..36af63d 100644 --- a/app/root/maintenance/page.tsx +++ b/app/root/maintenance/page.tsx @@ -1,27 +1,23 @@ -import { ArrowLeft, LogOut, Power } from "lucide-react"; -import Link from "next/link"; +import { Power } from "lucide-react"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; -import { AppHeader } from "@/components/layout/app-header"; -import { AppShell } from "@/components/layout/app-shell"; -import { AppSidebar } from "@/components/layout/app-sidebar"; -import { ThemeToggle } from "@/components/theme-toggle"; +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 { getRootNavigation } from "@/lib/root-navigation"; import { AppCard } from "@/components/ui/app-card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { CardContent } from "@/components/ui/card"; export const dynamic = "force-dynamic"; const copy = { title: "Wartungsmodus", - subtitle: "Steuerung fuer den globalen Maintenance Status.", + subtitle: "Steuerung fuer den globalen Wartungsstatus.", overview: "Uebersicht", maintenance: "Wartungsmodus", uiKit: "UI Kit", @@ -75,67 +71,37 @@ export default async function RootMaintenancePage() { redirect("/root/maintenance"); } - const sidebarItems = getRootNavigation(copy, "maintenance"); - return ( - -
- -
- -
- -
- - } - /> - } - header={ - - {maintenanceEnabled ? copy.maintenanceOn : copy.maintenanceOff} - - } - /> + + {maintenanceEnabled ? copy.maintenanceOn : copy.maintenanceOff} + } > - - - {copy.title} - {copy.maintenanceText} - - -
- - -
-
-
-
+ + + +

{copy.maintenanceText}

+
+ + +
+
+
+
+ ); } diff --git a/app/root/media/actions.ts b/app/root/media/actions.ts index a30b2a0..be80265 100644 --- a/app/root/media/actions.ts +++ b/app/root/media/actions.ts @@ -47,13 +47,13 @@ export async function createMediaAssetAction(formData: FormData) { }); revalidateMediaPages(); - redirect(withMessage("/root/media", "success", "Media asset created.")); + redirect(withMessage("/root/media", "success", "Datei gespeichert.")); } catch (error) { if (isRedirectError(error)) { throw error; } - const message = error instanceof Error ? error.message : "Unable to create media asset."; + const message = error instanceof Error ? error.message : "Datei konnte nicht gespeichert werden."; redirect(withMessage("/root/media", "error", message)); } } @@ -67,13 +67,13 @@ export async function deleteMediaAssetAction(formData: FormData) { const asset = await getMediaAssetById(assetId); if (!asset) { - redirect(withMessage("/root/media", "error", "Media asset not found.")); + redirect(withMessage("/root/media", "error", "Datei nicht gefunden.")); } const usageCount = await countMediaUsageReferences(asset.id); if (usageCount > 0) { - redirect(withMessage("/root/media", "error", "Media asset is still in use.")); + redirect(withMessage("/root/media", "error", "Datei wird noch verwendet.")); } await prisma.mediaAsset.delete({ @@ -90,13 +90,13 @@ export async function deleteMediaAssetAction(formData: FormData) { } revalidateMediaPages(); - redirect(withMessage("/root/media", "success", "Media asset deleted.")); + redirect(withMessage("/root/media", "success", "Datei geloescht.")); } catch (error) { if (isRedirectError(error)) { throw error; } - const message = error instanceof Error ? error.message : "Unable to delete media asset."; + const message = error instanceof Error ? error.message : "Datei konnte nicht geloescht werden."; redirect(withMessage("/root/media", "error", message)); } } diff --git a/app/root/media/page.tsx b/app/root/media/page.tsx index 2c318a1..331a17e 100644 --- a/app/root/media/page.tsx +++ b/app/root/media/page.tsx @@ -4,6 +4,7 @@ import { ExternalLink, ImageIcon, Trash2 } from "lucide-react"; import Link from "next/link"; import { redirect } from "next/navigation"; +import { MotionFade } from "@/components/motion-fade"; import { RootDashboardShell } from "@/components/root/root-dashboard-shell"; import { AppCard } from "@/components/ui/app-card"; import { Button } from "@/components/ui/button"; @@ -27,6 +28,17 @@ const copy = { portfolio: "Portfolio", logout: "Ausloggen", backToSite: "Zur Website", + createTitle: "Neue Datei", + createDescription: "Datei hochladen oder externe URL zentral fuer spaetere Wiederverwendung speichern.", + label: "Bezeichnung", + kind: "Typ", + uploadFile: "Datei hochladen", + externalUrl: "Externe URL", + saveMedia: "Datei speichern", + open: "Oeffnen", + delete: "Loeschen", + usages: "Verwendungen", + noAssets: "Noch keine Dateien vorhanden.", }; type RootMediaPageProps = { @@ -60,122 +72,132 @@ export default async function RootMediaPage({ searchParams }: RootMediaPageProps >
{searchParams?.success ? ( -

- {searchParams.success} -

+ +

+ {searchParams.success} +

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

- {searchParams.error} -

+ +

+ {searchParams.error} +

+
) : null} - - - New Media Asset - Upload a file or store an external URL for reuse across the site. - - -
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- -
-
-
-
- -
- {mediaAssets.map((asset) => ( - - - {asset.label} - - {asset.kind} - {asset.source} - {asset.usages.length} usages - - - - {asset.kind === "IMAGE" ? ( -
- {asset.label} -
- ) : ( -
- {asset.fileName} -
- )} - -
-

{asset.url}

-
- - - Open - -
+ + + + {copy.createTitle} + {copy.createDescription} + + +
+
+ +
- {asset.usages.length > 0 ? ( -
- {asset.usages.map((usage) => ( -

- {usage.usageType} / {usage.entityType} / {usage.fieldKey} -

- ))} -
- ) : null} +
+ + +
- - - -
-
-
+
+ +
+
+ + +
+ {mediaAssets.map((asset, index) => ( + + + + {asset.label} + + {asset.kind} + {asset.source} + {asset.usages.length} {copy.usages} + + + + {asset.kind === "IMAGE" ? ( +
+ {asset.label} +
+ ) : ( +
+ {asset.fileName} +
+ )} + +
+

{asset.url}

+
+ + + {copy.open} + +
+
+ + {asset.usages.length > 0 ? ( +
+ {asset.usages.map((usage) => ( +

+ {usage.usageType} / {usage.entityType} / {usage.fieldKey} +

+ ))} +
+ ) : null} + +
+ + +
+
+
+
))}
{mediaAssets.length === 0 ? ( - - - No media assets found yet. - - + + + + {copy.noAssets} + + + ) : null}
diff --git a/app/root/page.tsx b/app/root/page.tsx index 8a46aa4..af0559b 100644 --- a/app/root/page.tsx +++ b/app/root/page.tsx @@ -1,19 +1,37 @@ -import { ArrowLeft, ExternalLink, ImageIcon, LockKeyhole, LogOut } from "lucide-react"; +import { + ExternalLink, + FolderKanban, + ImageIcon, + LayoutDashboard, + LockKeyhole, + LogOut, + ShieldAlert, + Shapes, + SwatchBook, + ArrowLeft, +} from "lucide-react"; import Link from "next/link"; import { redirect } from "next/navigation"; -import { AppHeader } from "@/components/layout/app-header"; -import { AppShell } from "@/components/layout/app-shell"; -import { AppSidebar } from "@/components/layout/app-sidebar"; import { Container } from "@/components/layout/container"; import { MotionFade } from "@/components/motion-fade"; +import { DashboardCard } from "@/components/dashboard/dashboard-card"; +import { DashboardLayout } from "@/components/dashboard/dashboard-layout"; import { ThemeToggle } from "@/components/theme-toggle"; -import { getLocalizedPath } from "@/lib/locale"; -import { AppCard } from "@/components/ui/app-card"; +import { Badge, type BadgeProps } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { CardContent, CardDescription, CardHeader } from "@/components/ui/card"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; import { clearAdminSessionCookie, getAdminLockState, @@ -25,6 +43,12 @@ import { setAdminSessionCookie, } from "@/lib/admin-auth"; import { getMaintenanceMode } from "@/lib/app-config"; +import { getLocalizedPath } from "@/lib/locale"; +import { getAdminMediaAssets } from "@/lib/media"; +import { + getAdminPortfolioCategories, + getAdminPortfolioProjects, +} from "@/lib/portfolio"; import { getRootNavigation } from "@/lib/root-navigation"; type RootPageProps = { @@ -52,7 +76,7 @@ const copy = { media: "Media", portfolio: "Portfolio", portfolioTitle: "Portfolio", - portfolioDescription: "Kategorien, Projekte, Sections und Assets verwalten.", + portfolioDescription: "Kategorien, Projekte, Abschnitte und Dateien verwalten.", portfolioAction: "Zum Portfolio", mediaTitle: "Media Library", mediaDescription: "Uploads, externe URLs und Verwendungsorte zentral verwalten.", @@ -68,6 +92,31 @@ const copy = { logout: "Ausloggen", backToSite: "Zur Website", uiKit: "UI Kit", + tableTitle: "Bereiche", + tableDescription: "Direkter Zugriff auf alle Verwaltungsbereiche mit aktuellem Status.", + tableSection: "Bereich", + tableSummary: "Zusammenfassung", + tableStatus: "Status", + tableAction: "Aktion", + maintenanceStatusOn: "Aktiv", + maintenanceStatusOff: "Inaktiv", + authStatusOk: "Bereit", + authStatusWarning: "Pruefen", + authCard: "Root Zugang", + authDescription: "Admin Passwort und Session Signatur sind gesetzt.", + authDescriptionWarning: "Eine oder mehrere Root Variablen fehlen und sollten geprueft werden.", + mediaCountDescription: "Verfuegbare Dateien fuer Portfolio und Inhalte.", + projectCountDescription: "Gespeicherte Portfolio Projekte im System.", + categoryCountDescription: "Portfolio Kategorien mit eigener Sortierung.", + systemStatusTitle: "Systemstatus", + systemStatusDescription: "Technischer Zustand des Root Bereichs und der wichtigsten Inhaltsbereiche.", + maintenanceLabel: "Wartung", + contentLabel: "Inhalte", + contentDescription: "Portfolio, Media und Kategorien sind direkt aus diesem Bereich erreichbar.", + publishedProjects: "Veroeffentlicht", + totalProjects: "Projekte", + totalCategories: "Kategorien", + totalMedia: "Dateien", }; export default async function RootPage({ searchParams }: RootPageProps) { @@ -77,7 +126,6 @@ export default async function RootPage({ searchParams }: RootPageProps) { ); const authenticated = isAdminAuthenticated(); const lockState = getAdminLockState(); - const maintenanceEnabled = authenticated ? await getMaintenanceMode() : false; async function loginAction(formData: FormData) { "use server"; @@ -114,7 +162,7 @@ export default async function RootPage({ searchParams }: RootPageProps) { return ( - +

@@ -159,137 +207,232 @@ export default async function RootPage({ searchParams }: RootPageProps) { - + ); } - const sidebarItems = getRootNavigation(copy, "overview"); + const [maintenanceEnabled, categories, projects, mediaAssets] = await Promise.all([ + getMaintenanceMode(), + getAdminPortfolioCategories(), + getAdminPortfolioProjects(), + getAdminMediaAssets(), + ]); + const publishedProjects = projects.filter((project) => project.isPublished).length; + const rootNavigation = getRootNavigation(copy, "overview"); + const authHealthy = authConfigured && basicConfigured; + const sectionRows: Array<{ + label: string; + summary: string; + status: string; + href: string; + badgeVariant: BadgeProps["variant"]; + action: string; + }> = [ + { + label: copy.maintenanceTitle, + summary: copy.maintenanceDescription, + status: maintenanceEnabled ? copy.maintenanceStatusOn : copy.maintenanceStatusOff, + href: "/root/maintenance", + badgeVariant: maintenanceEnabled ? "warning" : "success", + action: copy.maintenanceAction, + }, + { + label: copy.mediaTitle, + summary: `${mediaAssets.length} ${copy.totalMedia}`, + status: copy.authStatusOk, + href: "/root/media", + badgeVariant: "outline" as const, + action: copy.mediaAction, + }, + { + label: copy.portfolioTitle, + summary: `${projects.length} ${copy.totalProjects} / ${categories.length} ${copy.totalCategories}`, + status: `${publishedProjects} ${copy.publishedProjects}`, + href: "/root/portfolio", + badgeVariant: "outline" as const, + action: copy.portfolioAction, + }, + { + label: copy.uiKitTitle, + summary: copy.uiKitDescription, + status: copy.authStatusOk, + href: "/root/ui-kit", + badgeVariant: "outline" as const, + action: copy.uiKitAction, + }, + ]; return ( - -

- -
- -
- -
-
- } - /> + + + + } - header={ - - - {copy.maintenanceVisitorsClosed} - - - ) : null - } - /> + headerActions={ +
+ {maintenanceEnabled ? ( + + ) : null} + + +
+ +
+
} > -
-
+
+
- - - {copy.maintenanceTitle} - - -

- {maintenanceEnabled ? copy.maintenanceVisitorsClosed : copy.maintenanceOpen} -

-

- {copy.maintenanceDescription} -

- -
-
+
- - - - {copy.uiKitTitle} - - -

{copy.uiKitTitle}

-

{copy.uiKitDescription}

- -
-
+
- - - - {copy.portfolioTitle} - - -

{copy.portfolioTitle}

-

{copy.portfolioDescription}

- -
-
+
- - - - {copy.mediaTitle} - - -

{copy.mediaTitle}

-

{copy.mediaDescription}

- -
-
+
+ +
+ + + + {copy.tableTitle} + {copy.tableDescription} + + + + + + {copy.tableSection} + {copy.tableSummary} + {copy.tableStatus} + {copy.tableAction} + + + + {sectionRows.map((row) => ( + + {row.label} + {row.summary} + + {row.status} + + + + + + ))} + +
+
+
+
+ +
+ + + + {copy.systemStatusTitle} + {copy.systemStatusDescription} + + +
+
+
+

{copy.authCard}

+

+ {authHealthy ? copy.authDescription : copy.authDescriptionWarning} +

+
+ + {authHealthy ? copy.authStatusOk : copy.authStatusWarning} + +
+
+ +
+
+ {copy.maintenanceLabel} + + {maintenanceEnabled ? copy.maintenanceStatusOn : copy.maintenanceStatusOff} + +
+
+ {copy.contentLabel} + + {`${publishedProjects} ${copy.publishedProjects} / ${projects.length} ${copy.totalProjects}`} + +
+

{copy.contentDescription}

+
+
+
+
+
+
- + ); } diff --git a/app/root/portfolio/actions.ts b/app/root/portfolio/actions.ts index 52501c6..5a1f433 100644 --- a/app/root/portfolio/actions.ts +++ b/app/root/portfolio/actions.ts @@ -52,12 +52,12 @@ function parseJsonArray(rawValue: FormDataEntryValue | null, key: string) { const parsed = JSON.parse(rawValue); if (!Array.isArray(parsed)) { - throw new Error(`${key} must be an array.`); + throw new Error(`${key} muss ein Array sein.`); } return parsed; } catch { - throw new Error(`Invalid ${key} payload.`); + throw new Error(`Ungueltige ${key} Nutzdaten.`); } } @@ -70,17 +70,17 @@ function parseJsonObject(rawValue: FormDataEntryValue | null, key: string) { const parsed = JSON.parse(rawValue); if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") { - throw new Error(`${key} must be an object.`); + throw new Error(`${key} muss ein Objekt sein.`); } return parsed; } catch { - throw new Error(`Invalid ${key} payload.`); + throw new Error(`Ungueltige ${key} Nutzdaten.`); } } function parseZodError(error: ZodError) { - return error.issues[0]?.message ?? "Validation failed."; + return error.issues[0]?.message ?? "Validierung fehlgeschlagen."; } async function revalidatePortfolioPages() { @@ -135,7 +135,7 @@ export async function upsertCategoryAction(formData: FormData) { } await revalidatePortfolioPages(); - redirect(withMessage(redirectPath, "success", "Category saved.")); + redirect(withMessage(redirectPath, "success", "Kategorie gespeichert.")); } catch (error) { if (isRedirectError(error)) { throw error; @@ -145,8 +145,8 @@ export async function upsertCategoryAction(formData: FormData) { error instanceof ZodError ? parseZodError(error) : error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002" - ? "Category slug must be unique." - : "Unable to save category."; + ? "Kategorie Slug muss eindeutig sein." + : "Kategorie konnte nicht gespeichert werden."; redirect(withMessage(redirectPath, "error", message)); } @@ -166,7 +166,7 @@ export async function deleteCategoryAction(formData: FormData) { }); if (projectCount > 0) { - redirect(withMessage(redirectPath, "error", "Cannot delete a category with projects.")); + redirect(withMessage(redirectPath, "error", "Kategorie mit Projekten kann nicht geloescht werden.")); } await prisma.category.delete({ @@ -176,13 +176,13 @@ export async function deleteCategoryAction(formData: FormData) { }); await revalidatePortfolioPages(); - redirect(withMessage(redirectPath, "success", "Category deleted.")); + redirect(withMessage(redirectPath, "success", "Kategorie geloescht.")); } catch (error) { if (isRedirectError(error)) { throw error; } - redirect(withMessage(redirectPath, "error", "Unable to delete category.")); + redirect(withMessage(redirectPath, "error", "Kategorie konnte nicht geloescht werden.")); } } @@ -337,7 +337,7 @@ export async function saveProjectAction(formData: FormData) { }); if (!assetSelection.url) { - throw new Error("Each asset row needs either an existing file or a new upload."); + throw new Error("Jede Datei Zeile braucht eine vorhandene Datei oder einen neuen Upload."); } if (assetSelection.createdAssetId) { @@ -521,7 +521,7 @@ export async function saveProjectAction(formData: FormData) { } redirect( - withMessage(`/root/portfolio/projects/${projectResult.project.id}`, "success", "Project saved."), + withMessage(`/root/portfolio/projects/${projectResult.project.id}`, "success", "Projekt gespeichert."), ); } catch (error) { if (isRedirectError(error)) { @@ -532,10 +532,10 @@ export async function saveProjectAction(formData: FormData) { error instanceof ZodError ? parseZodError(error) : error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002" - ? "Project slug must be unique." + ? "Projekt Slug muss eindeutig sein." : error instanceof Error ? error.message - : "Unable to save project."; + : "Projekt konnte nicht gespeichert werden."; await removeManagedPaths(uploadedPaths); if (createdMediaAssetIds.length > 0) { diff --git a/app/root/portfolio/categories/page.tsx b/app/root/portfolio/categories/page.tsx index 2e0786c..0e04ca2 100644 --- a/app/root/portfolio/categories/page.tsx +++ b/app/root/portfolio/categories/page.tsx @@ -1,5 +1,6 @@ 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"; @@ -17,9 +18,9 @@ import { deleteCategoryAction, upsertCategoryAction } from "../actions"; export const dynamic = "force-dynamic"; const locales = [ - { key: "Ar", label: "Arabic", hint: "الواجهة العربية" }, - { key: "En", label: "English", hint: "English website" }, - { key: "De", label: "German", hint: "Deutsche Website" }, + { key: "Ar", label: "Arabisch", hint: "الواجهة العربية" }, + { key: "En", label: "Englisch", hint: "English website" }, + { key: "De", label: "Deutsch", hint: "Deutsche Website" }, ] as const; const copy = { @@ -32,6 +33,13 @@ const copy = { portfolio: "Portfolio", logout: "Ausloggen", backToSite: "Zur Website", + sortOrder: "Sortierung", + active: "Aktiv", + saveCategory: "Kategorie speichern", + save: "Speichern", + delete: "Loeschen", + projects: "Projekte", + description: "Beschreibung", }; type RootPortfolioCategoriesPageProps = { @@ -65,38 +73,42 @@ export default async function RootPortfolioCategoriesPage({ logoutAction={logoutAction} headerTitle={copy.title} headerDescription={copy.subtitle} + toolbar={} >
- - {searchParams?.success ? ( -

- {searchParams.success} -

+ +

+ {searchParams.success} +

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

- {searchParams.error} -

+ +

+ {searchParams.error} +

+
) : null} - - - Neue Kategorie - Eine Kategorie wird genau einem oder mehreren Projekten zugeordnet. - - -
+ + + + Neue Kategorie + Eine Kategorie wird genau einem oder mehreren Projekten zugeordnet. + + +
-
- - -
+
+ + +
@@ -117,7 +129,7 @@ export default async function RootPortfolioCategoriesPage({
- +