Refactor root admin dashboard UI
This commit is contained in:
@@ -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 (
|
||||
<AppShell
|
||||
sidebar={
|
||||
<AppSidebar
|
||||
title={copy.title}
|
||||
description={copy.subtitle}
|
||||
items={sidebarItems}
|
||||
footer={
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeToggle ariaLabel="Theme wechseln" />
|
||||
</div>
|
||||
<Button asChild variant="outline" className="w-full justify-start">
|
||||
<Link href={getLocalizedPath("de")}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{copy.backToSite}
|
||||
</Link>
|
||||
</Button>
|
||||
<form action={logoutAction}>
|
||||
<Button type="submit" variant="destructive" className="w-full justify-start">
|
||||
<LogOut className="h-4 w-4" />
|
||||
{copy.logout}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
}
|
||||
header={
|
||||
<AppHeader
|
||||
title={copy.title}
|
||||
description={copy.subtitle}
|
||||
actions={
|
||||
<Badge variant={maintenanceEnabled ? "warning" : "success"}>
|
||||
{maintenanceEnabled ? copy.maintenanceOn : copy.maintenanceOff}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
<RootDashboardShell
|
||||
copy={copy}
|
||||
active="maintenance"
|
||||
logoutAction={logoutAction}
|
||||
headerTitle={copy.title}
|
||||
headerDescription={copy.subtitle}
|
||||
headerActions={
|
||||
<Badge variant={maintenanceEnabled ? "warning" : "success"}>
|
||||
{maintenanceEnabled ? copy.maintenanceOn : copy.maintenanceOff}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<AppCard>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">{copy.title}</CardTitle>
|
||||
<CardDescription>{copy.maintenanceText}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={updateMaintenanceMode}>
|
||||
<input
|
||||
type="hidden"
|
||||
name="enabled"
|
||||
value={maintenanceEnabled ? "false" : "true"}
|
||||
/>
|
||||
<Button type="submit">
|
||||
<Power className="h-4 w-4" />
|
||||
{maintenanceEnabled ? copy.disableMaintenance : copy.enableMaintenance}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</AppShell>
|
||||
<MotionFade delay={0.1}>
|
||||
<AppCard>
|
||||
<CardContent className="space-y-4 p-6">
|
||||
<p className="text-sm text-muted-foreground">{copy.maintenanceText}</p>
|
||||
<form action={updateMaintenanceMode}>
|
||||
<input
|
||||
type="hidden"
|
||||
name="enabled"
|
||||
value={maintenanceEnabled ? "false" : "true"}
|
||||
/>
|
||||
<Button type="submit">
|
||||
<Power className="h-4 w-4" />
|
||||
{maintenanceEnabled ? copy.disableMaintenance : copy.enableMaintenance}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</MotionFade>
|
||||
</RootDashboardShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+125
-103
@@ -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
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{searchParams?.success ? (
|
||||
<p className="rounded-nested border border-status-success/30 bg-status-success/10 px-4 py-3 text-sm text-status-success">
|
||||
{searchParams.success}
|
||||
</p>
|
||||
<MotionFade delay={0.1}>
|
||||
<p className="rounded-nested border border-status-success/30 bg-status-success/10 px-4 py-3 text-sm text-status-success">
|
||||
{searchParams.success}
|
||||
</p>
|
||||
</MotionFade>
|
||||
) : null}
|
||||
|
||||
{searchParams?.error ? (
|
||||
<p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{searchParams.error}
|
||||
</p>
|
||||
<MotionFade delay={0.12}>
|
||||
<p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{searchParams.error}
|
||||
</p>
|
||||
</MotionFade>
|
||||
) : null}
|
||||
|
||||
<AppCard>
|
||||
<CardHeader>
|
||||
<CardTitle>New Media Asset</CardTitle>
|
||||
<CardDescription>Upload a file or store an external URL for reuse across the site.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={createMediaAssetAction} className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="label">Label</Label>
|
||||
<Input id="label" name="label" required />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="kind">Kind</Label>
|
||||
<select
|
||||
id="kind"
|
||||
name="kind"
|
||||
defaultValue="IMAGE"
|
||||
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
|
||||
>
|
||||
<option value="IMAGE">IMAGE</option>
|
||||
<option value="DOCUMENT">DOCUMENT</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor="file">Upload File</Label>
|
||||
<Input id="file" name="file" type="file" accept="image/*,.svg,.pdf" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor="externalUrl">External URL</Label>
|
||||
<Input id="externalUrl" name="externalUrl" placeholder="https://example.com/image.jpg" />
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<Button type="submit">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
Save Media
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{mediaAssets.map((asset) => (
|
||||
<AppCard key={asset.id}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{asset.label}</CardTitle>
|
||||
<CardDescription className="flex flex-wrap gap-2">
|
||||
<span>{asset.kind}</span>
|
||||
<span>{asset.source}</span>
|
||||
<span>{asset.usages.length} usages</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{asset.kind === "IMAGE" ? (
|
||||
<div className="overflow-hidden rounded-surface border border-border bg-surface-1">
|
||||
<img src={asset.url} alt={asset.label} className="h-48 w-full object-cover" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-surface border border-border bg-surface-1 px-4 py-6 text-sm text-muted-foreground">
|
||||
{asset.fileName}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<p className="truncate">{asset.url}</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href={asset.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-2 text-foreground">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Open
|
||||
</Link>
|
||||
</div>
|
||||
<MotionFade delay={0.15}>
|
||||
<AppCard>
|
||||
<CardHeader>
|
||||
<CardTitle>{copy.createTitle}</CardTitle>
|
||||
<CardDescription>{copy.createDescription}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={createMediaAssetAction} className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="label">{copy.label}</Label>
|
||||
<Input id="label" name="label" required />
|
||||
</div>
|
||||
|
||||
{asset.usages.length > 0 ? (
|
||||
<div className="space-y-2 rounded-nested border border-border bg-surface-1 px-4 py-3 text-xs text-muted-foreground">
|
||||
{asset.usages.map((usage) => (
|
||||
<p key={usage.id}>
|
||||
{usage.usageType} / {usage.entityType} / {usage.fieldKey}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="kind">{copy.kind}</Label>
|
||||
<select
|
||||
id="kind"
|
||||
name="kind"
|
||||
defaultValue="IMAGE"
|
||||
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
|
||||
>
|
||||
<option value="IMAGE">IMAGE</option>
|
||||
<option value="DOCUMENT">DOCUMENT</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<form action={deleteMediaAssetAction}>
|
||||
<input type="hidden" name="assetId" value={asset.id} />
|
||||
<Button type="submit" variant="destructive" disabled={asset.usages.length > 0}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor="file">{copy.uploadFile}</Label>
|
||||
<Input id="file" name="file" type="file" accept="image/*,.svg,.pdf" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor="externalUrl">{copy.externalUrl}</Label>
|
||||
<Input id="externalUrl" name="externalUrl" placeholder="https://example.com/image.jpg" />
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<Button type="submit">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
{copy.saveMedia}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</MotionFade>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{mediaAssets.map((asset, index) => (
|
||||
<MotionFade key={asset.id} delay={0.18 + index * 0.04}>
|
||||
<AppCard>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{asset.label}</CardTitle>
|
||||
<CardDescription className="flex flex-wrap gap-2">
|
||||
<span>{asset.kind}</span>
|
||||
<span>{asset.source}</span>
|
||||
<span>{asset.usages.length} {copy.usages}</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{asset.kind === "IMAGE" ? (
|
||||
<div className="overflow-hidden rounded-surface border border-border bg-surface-1">
|
||||
<img src={asset.url} alt={asset.label} className="h-48 w-full object-cover" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-surface border border-border bg-surface-1 px-4 py-6 text-sm text-muted-foreground">
|
||||
{asset.fileName}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<p className="truncate">{asset.url}</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href={asset.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-2 text-foreground">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
{copy.open}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{asset.usages.length > 0 ? (
|
||||
<div className="space-y-2 rounded-nested border border-border bg-surface-1 px-4 py-3 text-xs text-muted-foreground">
|
||||
{asset.usages.map((usage) => (
|
||||
<p key={usage.id}>
|
||||
{usage.usageType} / {usage.entityType} / {usage.fieldKey}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form action={deleteMediaAssetAction}>
|
||||
<input type="hidden" name="assetId" value={asset.id} />
|
||||
<Button type="submit" variant="destructive" disabled={asset.usages.length > 0}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{copy.delete}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</MotionFade>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mediaAssets.length === 0 ? (
|
||||
<AppCard>
|
||||
<CardContent className="p-6 text-sm text-muted-foreground">
|
||||
No media assets found yet.
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
<MotionFade delay={0.2}>
|
||||
<AppCard>
|
||||
<CardContent className="p-6 text-sm text-muted-foreground">
|
||||
{copy.noAssets}
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</MotionFade>
|
||||
) : null}
|
||||
</div>
|
||||
</RootDashboardShell>
|
||||
|
||||
+265
-122
@@ -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 (
|
||||
<Container size="narrow" className="py-12">
|
||||
<MotionFade>
|
||||
<AppCard level={3}>
|
||||
<Card className="border-border/70 bg-card/95 shadow-sm">
|
||||
<CardHeader>
|
||||
<p className="inline-flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<LockKeyhole className="h-4 w-4 text-brand-primary" />
|
||||
@@ -159,137 +207,232 @@ export default async function RootPage({ searchParams }: RootPageProps) {
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</Card>
|
||||
</MotionFade>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<AppShell
|
||||
sidebar={
|
||||
<AppSidebar
|
||||
title={copy.title}
|
||||
description={copy.subtitle}
|
||||
items={sidebarItems}
|
||||
footer={
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeToggle ariaLabel="Theme wechseln" />
|
||||
</div>
|
||||
<Button asChild variant="outline" className="w-full justify-start">
|
||||
<Link href={getLocalizedPath("de")}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{copy.backToSite}
|
||||
</Link>
|
||||
</Button>
|
||||
<form action={logoutAction}>
|
||||
<Button type="submit" variant="destructive" className="w-full justify-start">
|
||||
<LogOut className="h-4 w-4" />
|
||||
{copy.logout}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<DashboardLayout
|
||||
title={copy.title}
|
||||
description={copy.subtitle}
|
||||
icon={LayoutDashboard}
|
||||
items={rootNavigation}
|
||||
sidebarFooter={
|
||||
<>
|
||||
<Button
|
||||
asChild
|
||||
variant={maintenanceEnabled ? "default" : "outline"}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<Link href="/root/maintenance">
|
||||
{copy.maintenance}
|
||||
<ShieldAlert className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="w-full justify-between">
|
||||
<Link href="/root/ui-kit">
|
||||
{copy.uiKit}
|
||||
<SwatchBook className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
header={
|
||||
<AppHeader
|
||||
title={copy.title}
|
||||
description={copy.subtitle}
|
||||
actions={
|
||||
maintenanceEnabled ? (
|
||||
<Button asChild variant="destructive">
|
||||
<Link href="/root/maintenance">
|
||||
{copy.maintenanceVisitorsClosed}
|
||||
</Link>
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
headerActions={
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{maintenanceEnabled ? (
|
||||
<Button asChild variant="destructive" className="hidden sm:inline-flex">
|
||||
<Link href="/root/maintenance">{copy.maintenanceVisitorsClosed}</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<ThemeToggle ariaLabel="Theme wechseln" />
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={getLocalizedPath("de")}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{copy.backToSite}
|
||||
</Link>
|
||||
</Button>
|
||||
<form action={logoutAction}>
|
||||
<Button type="submit" variant="ghost" size="sm" className="text-destructive hover:bg-destructive/10 hover:text-destructive">
|
||||
<LogOut className="h-4 w-4" />
|
||||
{copy.logout}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-6">
|
||||
<section className="grid gap-4 lg:grid-cols-4">
|
||||
<div className="space-y-6">
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<MotionFade delay={0.05}>
|
||||
<AppCard level={2}>
|
||||
<CardHeader>
|
||||
<CardDescription>{copy.maintenanceTitle}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-lg font-semibold text-foreground">
|
||||
{maintenanceEnabled ? copy.maintenanceVisitorsClosed : copy.maintenanceOpen}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{copy.maintenanceDescription}
|
||||
</p>
|
||||
<Button asChild variant={maintenanceEnabled ? "destructive" : "outline"}>
|
||||
<Link href="/root/maintenance">
|
||||
{copy.maintenanceAction}
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
<DashboardCard
|
||||
title={copy.maintenanceTitle}
|
||||
value={maintenanceEnabled ? copy.maintenanceStatusOn : copy.maintenanceStatusOff}
|
||||
description={copy.maintenanceDescription}
|
||||
icon={ShieldAlert}
|
||||
/>
|
||||
</MotionFade>
|
||||
|
||||
<MotionFade delay={0.1}>
|
||||
<AppCard level={2}>
|
||||
<CardHeader>
|
||||
<CardDescription>{copy.uiKitTitle}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-lg font-semibold text-foreground">{copy.uiKitTitle}</p>
|
||||
<p className="text-sm text-muted-foreground">{copy.uiKitDescription}</p>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/root/ui-kit">
|
||||
{copy.uiKitAction}
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
<DashboardCard
|
||||
title={copy.totalMedia}
|
||||
value={String(mediaAssets.length)}
|
||||
description={copy.mediaCountDescription}
|
||||
icon={ImageIcon}
|
||||
/>
|
||||
</MotionFade>
|
||||
|
||||
<MotionFade delay={0.15}>
|
||||
<AppCard level={2}>
|
||||
<CardHeader>
|
||||
<CardDescription>{copy.portfolioTitle}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-lg font-semibold text-foreground">{copy.portfolioTitle}</p>
|
||||
<p className="text-sm text-muted-foreground">{copy.portfolioDescription}</p>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/root/portfolio">
|
||||
{copy.portfolioAction}
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
<DashboardCard
|
||||
title={copy.totalProjects}
|
||||
value={String(projects.length)}
|
||||
description={copy.projectCountDescription}
|
||||
icon={FolderKanban}
|
||||
/>
|
||||
</MotionFade>
|
||||
|
||||
<MotionFade delay={0.2}>
|
||||
<AppCard level={2}>
|
||||
<CardHeader>
|
||||
<CardDescription>{copy.mediaTitle}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-lg font-semibold text-foreground">{copy.mediaTitle}</p>
|
||||
<p className="text-sm text-muted-foreground">{copy.mediaDescription}</p>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/root/media">
|
||||
{copy.mediaAction}
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
<DashboardCard
|
||||
title={copy.totalCategories}
|
||||
value={String(categories.length)}
|
||||
description={copy.categoryCountDescription}
|
||||
icon={Shapes}
|
||||
/>
|
||||
</MotionFade>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-[minmax(0,1.5fr)_minmax(320px,1fr)]">
|
||||
<MotionFade delay={0.25}>
|
||||
<Card className="border-border/70 bg-card/95 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>{copy.tableTitle}</CardTitle>
|
||||
<CardDescription>{copy.tableDescription}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{copy.tableSection}</TableHead>
|
||||
<TableHead>{copy.tableSummary}</TableHead>
|
||||
<TableHead>{copy.tableStatus}</TableHead>
|
||||
<TableHead className="text-right">{copy.tableAction}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sectionRows.map((row) => (
|
||||
<TableRow key={row.href}>
|
||||
<TableCell className="font-medium">{row.label}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{row.summary}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={row.badgeVariant}>{row.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={row.href}>
|
||||
{row.action}
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</MotionFade>
|
||||
|
||||
<div className="grid gap-6">
|
||||
<MotionFade delay={0.3}>
|
||||
<Card className="border-border/70 bg-card/95 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>{copy.systemStatusTitle}</CardTitle>
|
||||
<CardDescription>{copy.systemStatusDescription}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-lg border border-border bg-muted/40 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">{copy.authCard}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{authHealthy ? copy.authDescription : copy.authDescriptionWarning}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={authHealthy ? "success" : "warning"}>
|
||||
{authHealthy ? copy.authStatusOk : copy.authStatusWarning}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">{copy.maintenanceLabel}</span>
|
||||
<Badge variant={maintenanceEnabled ? "warning" : "success"}>
|
||||
{maintenanceEnabled ? copy.maintenanceStatusOn : copy.maintenanceStatusOff}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">{copy.contentLabel}</span>
|
||||
<span className="font-medium text-foreground">
|
||||
{`${publishedProjects} ${copy.publishedProjects} / ${projects.length} ${copy.totalProjects}`}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground">{copy.contentDescription}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</MotionFade>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</AppShell>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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={<PortfolioSubnav active="categories" />}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<PortfolioSubnav active="categories" />
|
||||
|
||||
{searchParams?.success ? (
|
||||
<p className="rounded-nested border border-status-success/30 bg-status-success/10 px-4 py-3 text-sm text-status-success">
|
||||
{searchParams.success}
|
||||
</p>
|
||||
<MotionFade delay={0.1}>
|
||||
<p className="rounded-nested border border-status-success/30 bg-status-success/10 px-4 py-3 text-sm text-status-success">
|
||||
{searchParams.success}
|
||||
</p>
|
||||
</MotionFade>
|
||||
) : null}
|
||||
|
||||
{searchParams?.error ? (
|
||||
<p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{searchParams.error}
|
||||
</p>
|
||||
<MotionFade delay={0.12}>
|
||||
<p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{searchParams.error}
|
||||
</p>
|
||||
</MotionFade>
|
||||
) : null}
|
||||
|
||||
<AppCard>
|
||||
<CardHeader>
|
||||
<CardTitle>Neue Kategorie</CardTitle>
|
||||
<CardDescription>Eine Kategorie wird genau einem oder mehreren Projekten zugeordnet.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={upsertCategoryAction} className="grid gap-4 md:grid-cols-2">
|
||||
<MotionFade delay={0.15}>
|
||||
<AppCard>
|
||||
<CardHeader>
|
||||
<CardTitle>Neue Kategorie</CardTitle>
|
||||
<CardDescription>Eine Kategorie wird genau einem oder mehreren Projekten zugeordnet.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={upsertCategoryAction} className="grid gap-4 md:grid-cols-2">
|
||||
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-slug">Slug</Label>
|
||||
<Input id="create-slug" name="slug" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-sortOrder">Sort Order</Label>
|
||||
<Input id="create-sortOrder" name="sortOrder" type="number" min="0" defaultValue="0" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-sortOrder">{copy.sortOrder}</Label>
|
||||
<Input id="create-sortOrder" name="sortOrder" type="number" min="0" defaultValue="0" required />
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<Tabs defaultValue="Ar">
|
||||
@@ -117,7 +129,7 @@ export default async function RootPortfolioCategoriesPage({
|
||||
<Input id={`create-name-${locale.key}`} name={`name${locale.key}`} required />
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor={`create-description-${locale.key}`}>{`Description ${locale.label}`}</Label>
|
||||
<Label htmlFor={`create-description-${locale.key}`}>{`${copy.description} ${locale.label}`}</Label>
|
||||
<Textarea
|
||||
id={`create-description-${locale.key}`}
|
||||
name={`description${locale.key}`}
|
||||
@@ -131,23 +143,25 @@ export default async function RootPortfolioCategoriesPage({
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 rounded-nested border border-border px-4 py-3 text-sm md:col-span-2">
|
||||
<input type="checkbox" name="isActive" defaultChecked />
|
||||
Active
|
||||
</label>
|
||||
<label className="flex items-center gap-3 rounded-nested border border-border px-4 py-3 text-sm md:col-span-2">
|
||||
<input type="checkbox" name="isActive" defaultChecked />
|
||||
{copy.active}
|
||||
</label>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<Button type="submit">Save Category</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
<div className="md:col-span-2">
|
||||
<Button type="submit">{copy.saveCategory}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</MotionFade>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{categories.map((category) => (
|
||||
<AppCard key={category.id}>
|
||||
<CardContent className="p-6">
|
||||
<form action={upsertCategoryAction} className="grid gap-4 md:grid-cols-2">
|
||||
{categories.map((category, index) => (
|
||||
<MotionFade key={category.id} delay={0.18 + index * 0.03}>
|
||||
<AppCard>
|
||||
<CardContent className="p-6">
|
||||
<form action={upsertCategoryAction} className="grid gap-4 md:grid-cols-2">
|
||||
<input type="hidden" name="id" value={category.id} />
|
||||
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
|
||||
|
||||
@@ -157,7 +171,7 @@ export default async function RootPortfolioCategoriesPage({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`sortOrder-${category.id}`}>Sort Order</Label>
|
||||
<Label htmlFor={`sortOrder-${category.id}`}>{copy.sortOrder}</Label>
|
||||
<Input
|
||||
id={`sortOrder-${category.id}`}
|
||||
name="sortOrder"
|
||||
@@ -197,7 +211,7 @@ export default async function RootPortfolioCategoriesPage({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor={`${descriptionKey}-${category.id}`}>{`Description ${locale.label}`}</Label>
|
||||
<Label htmlFor={`${descriptionKey}-${category.id}`}>{`${copy.description} ${locale.label}`}</Label>
|
||||
<Textarea
|
||||
id={`${descriptionKey}-${category.id}`}
|
||||
name={descriptionKey}
|
||||
@@ -215,26 +229,27 @@ export default async function RootPortfolioCategoriesPage({
|
||||
|
||||
<label className="flex items-center gap-3 rounded-nested border border-border px-4 py-3 text-sm">
|
||||
<input type="checkbox" name="isActive" defaultChecked={category.isActive} />
|
||||
Active
|
||||
{copy.active}
|
||||
</label>
|
||||
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<p className="text-sm text-muted-foreground">{category.projectCount} projects</p>
|
||||
<p className="text-sm text-muted-foreground">{category.projectCount} {copy.projects}</p>
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit">Save</Button>
|
||||
<Button type="submit">{copy.save}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
|
||||
<form action={deleteCategoryAction} className="mt-4">
|
||||
<input type="hidden" name="id" value={category.id} />
|
||||
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
|
||||
<Button type="submit" variant="destructive" disabled={category.projectCount > 0}>
|
||||
Delete
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
<form action={deleteCategoryAction} className="mt-4">
|
||||
<input type="hidden" name="id" value={category.id} />
|
||||
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
|
||||
<Button type="submit" variant="destructive" disabled={category.projectCount > 0}>
|
||||
{copy.delete}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</MotionFade>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+46
-41
@@ -61,6 +61,7 @@ export default async function RootPortfolioPage() {
|
||||
logoutAction={logoutAction}
|
||||
headerTitle={copy.title}
|
||||
headerDescription={copy.subtitle}
|
||||
toolbar={<PortfolioSubnav active="overview" />}
|
||||
headerActions={
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button asChild>
|
||||
@@ -79,8 +80,6 @@ export default async function RootPortfolioPage() {
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<PortfolioSubnav active="overview" />
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
{[
|
||||
{
|
||||
@@ -120,47 +119,53 @@ export default async function RootPortfolioPage() {
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 lg:grid-cols-3">
|
||||
<AppCard>
|
||||
<CardHeader>
|
||||
<CardTitle>{copy.totalCategories}</CardTitle>
|
||||
<CardDescription>Sortieren, aktivieren und neue Portfolio Gruppen anlegen.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/root/portfolio/categories">{copy.categoriesAction}</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
<MotionFade delay={0.18}>
|
||||
<AppCard>
|
||||
<CardHeader>
|
||||
<CardTitle>{copy.totalCategories}</CardTitle>
|
||||
<CardDescription>Sortieren, aktivieren und neue Portfolio Gruppen anlegen.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/root/portfolio/categories">{copy.categoriesAction}</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</MotionFade>
|
||||
|
||||
<AppCard>
|
||||
<CardHeader>
|
||||
<CardTitle>{copy.totalProjects}</CardTitle>
|
||||
<CardDescription>Drafts, veroeffentlichte Projekte und flexible Sections pflegen.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex gap-3">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/root/portfolio/projects">{copy.projectsAction}</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/root/portfolio/projects/new">{copy.newProject}</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
<MotionFade delay={0.22}>
|
||||
<AppCard>
|
||||
<CardHeader>
|
||||
<CardTitle>{copy.totalProjects}</CardTitle>
|
||||
<CardDescription>Entwuerfe, veroeffentlichte Projekte und flexible Abschnitte pflegen.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex gap-3">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/root/portfolio/projects">{copy.projectsAction}</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/root/portfolio/projects/new">{copy.newProject}</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</MotionFade>
|
||||
|
||||
<AppCard>
|
||||
<CardHeader>
|
||||
<CardTitle>{copy.media}</CardTitle>
|
||||
<CardDescription>Alle Cover und Projektdateien an einem Ort pruefen.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/root/media">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
{copy.media}
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
<MotionFade delay={0.26}>
|
||||
<AppCard>
|
||||
<CardHeader>
|
||||
<CardTitle>{copy.media}</CardTitle>
|
||||
<CardDescription>Alle Cover und Projektdateien an einem Ort pruefen.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/root/media">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
{copy.media}
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</MotionFade>
|
||||
</section>
|
||||
</div>
|
||||
</RootDashboardShell>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { MotionFade } from "@/components/motion-fade";
|
||||
import { PortfolioProjectForm } from "@/components/root/portfolio-project-form";
|
||||
import { PortfolioSubnav } from "@/components/root/portfolio-subnav";
|
||||
import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
|
||||
@@ -27,6 +28,10 @@ const copy = {
|
||||
portfolio: "Portfolio",
|
||||
logout: "Ausloggen",
|
||||
backToSite: "Zur Website",
|
||||
saveProject: "Projekt speichern",
|
||||
dangerZone: "Gefahrenbereich",
|
||||
dangerText: "Projektdaten werden aus der Datenbank entfernt. Hochgeladene Dateien bleiben auf dem Speicher erhalten.",
|
||||
deleteProject: "Projekt loeschen",
|
||||
};
|
||||
|
||||
type RootPortfolioProjectPageProps = {
|
||||
@@ -72,47 +77,54 @@ export default async function RootPortfolioProjectPage({
|
||||
logoutAction={logoutAction}
|
||||
headerTitle={copy.title}
|
||||
headerDescription={copy.subtitle}
|
||||
toolbar={<PortfolioSubnav active="projects" />}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<PortfolioSubnav active="projects" />
|
||||
|
||||
{searchParams?.success ? (
|
||||
<p className="rounded-nested border border-status-success/30 bg-status-success/10 px-4 py-3 text-sm text-status-success">
|
||||
{searchParams.success}
|
||||
</p>
|
||||
<MotionFade delay={0.1}>
|
||||
<p className="rounded-nested border border-status-success/30 bg-status-success/10 px-4 py-3 text-sm text-status-success">
|
||||
{searchParams.success}
|
||||
</p>
|
||||
</MotionFade>
|
||||
) : null}
|
||||
|
||||
{searchParams?.error ? (
|
||||
<p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{searchParams.error}
|
||||
</p>
|
||||
<MotionFade delay={0.12}>
|
||||
<p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{searchParams.error}
|
||||
</p>
|
||||
</MotionFade>
|
||||
) : null}
|
||||
|
||||
<PortfolioProjectForm
|
||||
action={saveProjectAction}
|
||||
categories={categories}
|
||||
mediaOptions={mediaOptions}
|
||||
project={project}
|
||||
redirectPath={`/root/portfolio/projects/${project.id}`}
|
||||
submitLabel="Save Project"
|
||||
/>
|
||||
<MotionFade delay={0.15}>
|
||||
<PortfolioProjectForm
|
||||
action={saveProjectAction}
|
||||
categories={categories}
|
||||
mediaOptions={mediaOptions}
|
||||
project={project}
|
||||
redirectPath={`/root/portfolio/projects/${project.id}`}
|
||||
submitLabel={copy.saveProject}
|
||||
/>
|
||||
</MotionFade>
|
||||
|
||||
<AppCard>
|
||||
<CardContent className="flex items-center justify-between gap-4 p-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Danger Zone</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Project records are deleted from the database. Uploaded files stay on disk.
|
||||
</p>
|
||||
</div>
|
||||
<form action={deleteProjectAction}>
|
||||
<input type="hidden" name="id" value={project.id} />
|
||||
<Button type="submit" variant="destructive">
|
||||
Delete Project
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
<MotionFade delay={0.2}>
|
||||
<AppCard>
|
||||
<CardContent className="flex items-center justify-between gap-4 p-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">{copy.dangerZone}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{copy.dangerText}
|
||||
</p>
|
||||
</div>
|
||||
<form action={deleteProjectAction}>
|
||||
<input type="hidden" name="id" value={project.id} />
|
||||
<Button type="submit" variant="destructive">
|
||||
{copy.deleteProject}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</MotionFade>
|
||||
</div>
|
||||
</RootDashboardShell>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { MotionFade } from "@/components/motion-fade";
|
||||
import { PortfolioProjectForm } from "@/components/root/portfolio-project-form";
|
||||
import { PortfolioSubnav } from "@/components/root/portfolio-subnav";
|
||||
import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
|
||||
@@ -13,7 +14,7 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
const copy = {
|
||||
title: "Neues Portfolio Projekt",
|
||||
subtitle: "Projekt mit Kategorie, Sections und Assets anlegen.",
|
||||
subtitle: "Projekt mit Kategorie, Abschnitten und Dateien anlegen.",
|
||||
overview: "Uebersicht",
|
||||
maintenance: "Wartungsmodus",
|
||||
uiKit: "UI Kit",
|
||||
@@ -56,23 +57,26 @@ export default async function RootNewPortfolioProjectPage({
|
||||
logoutAction={logoutAction}
|
||||
headerTitle={copy.title}
|
||||
headerDescription={copy.subtitle}
|
||||
toolbar={<PortfolioSubnav active="projects" />}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<PortfolioSubnav active="projects" />
|
||||
|
||||
{searchParams?.error ? (
|
||||
<p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{searchParams.error}
|
||||
</p>
|
||||
<MotionFade delay={0.1}>
|
||||
<p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{searchParams.error}
|
||||
</p>
|
||||
</MotionFade>
|
||||
) : null}
|
||||
|
||||
<PortfolioProjectForm
|
||||
action={saveProjectAction}
|
||||
categories={categories}
|
||||
mediaOptions={mediaOptions}
|
||||
redirectPath="/root/portfolio/projects/new"
|
||||
submitLabel="Create Project"
|
||||
/>
|
||||
<MotionFade delay={0.15}>
|
||||
<PortfolioProjectForm
|
||||
action={saveProjectAction}
|
||||
categories={categories}
|
||||
mediaOptions={mediaOptions}
|
||||
redirectPath="/root/portfolio/projects/new"
|
||||
submitLabel="Projekt anlegen"
|
||||
/>
|
||||
</MotionFade>
|
||||
</div>
|
||||
</RootDashboardShell>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,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";
|
||||
@@ -27,6 +28,17 @@ const copy = {
|
||||
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 = {
|
||||
@@ -71,36 +83,42 @@ export default async function RootPortfolioProjectsPage({
|
||||
logoutAction={logoutAction}
|
||||
headerTitle={copy.title}
|
||||
headerDescription={copy.subtitle}
|
||||
toolbar={<PortfolioSubnav active="projects" />}
|
||||
headerActions={
|
||||
<Button asChild>
|
||||
<Link href="/root/portfolio/projects/new">
|
||||
<Plus className="h-4 w-4" />
|
||||
{copy.newProject}
|
||||
</Link>
|
||||
</Button>
|
||||
<MotionFade delay={0.04}>
|
||||
<Button asChild>
|
||||
<Link href="/root/portfolio/projects/new">
|
||||
<Plus className="h-4 w-4" />
|
||||
{copy.newProject}
|
||||
</Link>
|
||||
</Button>
|
||||
</MotionFade>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<PortfolioSubnav active="projects" />
|
||||
|
||||
{searchParams?.success ? (
|
||||
<p className="rounded-nested border border-status-success/30 bg-status-success/10 px-4 py-3 text-sm text-status-success">
|
||||
{searchParams.success}
|
||||
</p>
|
||||
<MotionFade delay={0.1}>
|
||||
<p className="rounded-nested border border-status-success/30 bg-status-success/10 px-4 py-3 text-sm text-status-success">
|
||||
{searchParams.success}
|
||||
</p>
|
||||
</MotionFade>
|
||||
) : null}
|
||||
|
||||
{searchParams?.error ? (
|
||||
<p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{searchParams.error}
|
||||
</p>
|
||||
<MotionFade delay={0.12}>
|
||||
<p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{searchParams.error}
|
||||
</p>
|
||||
</MotionFade>
|
||||
) : null}
|
||||
|
||||
<AppCard>
|
||||
<CardContent className="p-6">
|
||||
<form className="grid gap-4 md:grid-cols-3">
|
||||
<MotionFade delay={0.15}>
|
||||
<AppCard>
|
||||
<CardContent className="p-6">
|
||||
<form className="grid gap-4 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="category" className="text-sm font-medium text-foreground">
|
||||
Category
|
||||
{copy.category}
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
@@ -108,7 +126,7 @@ export default async function RootPortfolioProjectsPage({
|
||||
defaultValue={searchParams?.category ?? ""}
|
||||
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="">{copy.all}</option>
|
||||
{categories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name.de}
|
||||
@@ -119,7 +137,7 @@ export default async function RootPortfolioProjectsPage({
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="status" className="text-sm font-medium text-foreground">
|
||||
Status
|
||||
{copy.status}
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
@@ -127,25 +145,27 @@ export default async function RootPortfolioProjectsPage({
|
||||
defaultValue={selectedStatus}
|
||||
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="all">{copy.all}</option>
|
||||
<option value="draft">{copy.draft}</option>
|
||||
<option value="published">{copy.published}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<Button type="submit" variant="outline">
|
||||
Filter
|
||||
{copy.filter}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</form>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</MotionFade>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{projects.map((project) => (
|
||||
<AppCard key={project.id} interactive>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||
{projects.map((project, index) => (
|
||||
<MotionFade key={project.id} delay={0.18 + index * 0.03}>
|
||||
<AppCard interactive>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>{getLocalizedValue(project.title, "de")}</CardTitle>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
@@ -154,34 +174,37 @@ export default async function RootPortfolioProjectsPage({
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="rounded-pill border border-border px-3 py-1">
|
||||
{project.isPublished ? "Published" : "Draft"}
|
||||
{project.isPublished ? copy.published : copy.draft}
|
||||
</span>
|
||||
<span className="rounded-pill border border-border px-3 py-1">
|
||||
{project.projectYear}
|
||||
</span>
|
||||
<span className="rounded-pill border border-border px-3 py-1">
|
||||
Sort {project.sortOrder}
|
||||
{copy.sort} {project.sortOrder}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap items-center justify-between gap-4">
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<p>{project.slug}</p>
|
||||
<p>{project.previewUrl ? "Preview link set" : "No preview link"}</p>
|
||||
<p>{project.previewUrl ? copy.previewSet : copy.previewMissing}</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href={`/root/portfolio/projects/${project.id}`}>Edit Project</Link>
|
||||
<Link href={`/root/portfolio/projects/${project.id}`}>{copy.editProject}</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</MotionFade>
|
||||
))}
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<AppCard>
|
||||
<CardContent className="p-6 text-sm text-muted-foreground">
|
||||
No projects match the selected filters.
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
<MotionFade delay={0.18}>
|
||||
<AppCard>
|
||||
<CardContent className="p-6 text-sm text-muted-foreground">
|
||||
{copy.empty}
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</MotionFade>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+12
-41
@@ -1,15 +1,8 @@
|
||||
import { ArrowLeft, LogOut } 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 { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { getLocalizedPath } from "@/lib/locale";
|
||||
import { MotionFade } from "@/components/motion-fade";
|
||||
import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
|
||||
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||
import { getRootNavigation } from "@/lib/root-navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { UiKitShowcase } from "@/components/ui/ui-kit-showcase";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -40,39 +33,17 @@ export default async function RootUiKitPage() {
|
||||
redirect("/root");
|
||||
}
|
||||
|
||||
const sidebarItems = getRootNavigation(copy, "ui-kit");
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
sidebar={
|
||||
<AppSidebar
|
||||
title={copy.title}
|
||||
description={copy.subtitle}
|
||||
items={sidebarItems}
|
||||
footer={
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeToggle ariaLabel="Theme wechseln" />
|
||||
</div>
|
||||
<Button asChild variant="outline" className="w-full justify-start">
|
||||
<Link href={getLocalizedPath("de")}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{copy.backToSite}
|
||||
</Link>
|
||||
</Button>
|
||||
<form action={logoutAction}>
|
||||
<Button type="submit" variant="destructive" className="w-full justify-start">
|
||||
<LogOut className="h-4 w-4" />
|
||||
{copy.logout}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
}
|
||||
header={<AppHeader title={copy.title} description={copy.subtitle} />}
|
||||
<RootDashboardShell
|
||||
copy={copy}
|
||||
active="ui-kit"
|
||||
logoutAction={logoutAction}
|
||||
headerTitle={copy.title}
|
||||
headerDescription={copy.subtitle}
|
||||
>
|
||||
<UiKitShowcase localeKey="de" />
|
||||
</AppShell>
|
||||
<MotionFade delay={0.1}>
|
||||
<UiKitShowcase localeKey="de" />
|
||||
</MotionFade>
|
||||
</RootDashboardShell>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user