Refactor root admin dashboard UI

This commit is contained in:
MOH
2026-03-07 17:36:45 +01:00
parent ead75769ef
commit 8606f6e315
25 changed files with 2345 additions and 685 deletions
+34 -68
View File
@@ -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>
);
}
+6 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}
+15 -15
View File
@@ -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) {
+67 -52
View File
@@ -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
View File
@@ -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>
+44 -32
View File
@@ -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>
);
+17 -13
View File
@@ -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>
);
+66 -43
View File
@@ -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
View File
@@ -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>
);
}
+34
View File
@@ -0,0 +1,34 @@
import type { LucideIcon } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
type DashboardCardProps = {
title: string;
value: string;
description: string;
icon: LucideIcon;
};
export function DashboardCard({
title,
value,
description,
icon: Icon,
}: DashboardCardProps) {
return (
<Card className="border-border/70 bg-card/95 shadow-sm">
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
<div className="space-y-1">
<CardDescription>{title}</CardDescription>
<CardTitle className="text-3xl font-semibold tracking-tight">{value}</CardTitle>
</div>
<div className="rounded-md border border-border bg-muted/60 p-2 text-muted-foreground">
<Icon className="h-4 w-4" />
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{description}</p>
</CardContent>
</Card>
);
}
+81
View File
@@ -0,0 +1,81 @@
import type { ReactNode } from "react";
import { Menu, type LucideIcon } from "lucide-react";
import { DashboardSidebar } from "@/components/dashboard/sidebar";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import type { RootNavItem } from "@/lib/root-navigation";
type DashboardLayoutProps = {
title: string;
description: string;
icon?: LucideIcon;
items: RootNavItem[];
sidebarFooter?: ReactNode;
headerActions?: ReactNode;
children: ReactNode;
};
export function DashboardLayout({
title,
description,
icon: Icon,
items,
sidebarFooter,
headerActions,
children,
}: DashboardLayoutProps) {
return (
<div className="min-h-screen bg-muted/30">
<div className="grid min-h-screen lg:grid-cols-[280px_minmax(0,1fr)]">
<aside className="hidden border-r border-border/70 bg-sidebar/60 lg:block">
<DashboardSidebar items={items} footer={sidebarFooter} />
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-30 flex h-16 items-center gap-3 border-b border-border/70 bg-background/95 px-4 backdrop-blur lg:px-6">
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" size="icon" className="lg:hidden">
<Menu className="h-4 w-4" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="p-0">
<SheetHeader className="sr-only">
<SheetTitle>{title}</SheetTitle>
<SheetDescription>{description}</SheetDescription>
</SheetHeader>
<DashboardSidebar items={items} footer={sidebarFooter} />
</SheetContent>
</Sheet>
<div className="flex min-w-0 flex-1 items-center gap-3">
{Icon ? (
<div className="hidden rounded-lg border border-border/70 bg-muted/50 p-2 text-muted-foreground sm:flex">
<Icon className="h-4 w-4" />
</div>
) : null}
<div className="min-w-0">
<p className="truncate text-lg font-semibold text-foreground">{title}</p>
<p className="truncate text-sm text-muted-foreground">{description}</p>
</div>
</div>
{headerActions ? (
<div className="flex items-center gap-2">{headerActions}</div>
) : null}
</header>
<main className="flex-1 p-4 lg:p-6">{children}</main>
</div>
</div>
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
import type { ReactNode } from "react";
import Link from "next/link";
import type { LucideIcon } from "lucide-react";
import { ShieldCheck } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
type DashboardSidebarItem = {
label: string;
href: string;
icon: LucideIcon;
active?: boolean;
children?: DashboardSidebarItem[];
};
type DashboardSidebarProps = {
items: DashboardSidebarItem[];
footer?: ReactNode;
};
export function DashboardSidebar({ items, footer }: DashboardSidebarProps) {
function renderItem(item: DashboardSidebarItem, nested = false) {
const Icon = item.icon;
return (
<div key={item.href} className="space-y-1">
<Link
href={item.href}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
item.active
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
nested ? "ml-4" : undefined,
)}
>
<Icon className="h-4 w-4" />
<span>{item.label}</span>
</Link>
{item.children?.length ? item.children.map((child) => renderItem(child, true)) : null}
</div>
);
}
return (
<div className="flex h-full flex-col bg-sidebar/40">
<div className="flex items-center gap-3 px-4 py-5">
<div className="rounded-xl bg-primary/10 p-2 text-primary">
<ShieldCheck className="h-5 w-5" />
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-foreground">Mohs Admin</p>
<p className="truncate text-xs text-muted-foreground">Website owner workspace</p>
</div>
</div>
<div className="px-4">
<div className="rounded-xl border border-border/70 bg-background/80 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-foreground">Private panel</p>
<p className="mt-1 text-xs text-muted-foreground">Single owner access</p>
</div>
<Badge variant="outline">Private</Badge>
</div>
</div>
</div>
<Separator className="my-4" />
<div className="flex-1 space-y-6 overflow-y-auto px-4 pb-4">
<nav className="space-y-1">{items.map((item) => renderItem(item))}</nav>
</div>
{footer ? (
<>
<Separator />
<div className="space-y-2 p-4">{footer}</div>
</>
) : null}
</div>
);
}
+68 -51
View File
@@ -27,59 +27,76 @@ export function AppSidebar({
items,
footer,
}: AppSidebarProps) {
const portfolioItem = items.find((item) => item.href === "/root/portfolio");
const systemOrder = ["/root", "/root/media", "/root/maintenance", "/root/ui-kit"];
const systemItems = items
.filter((item) => item.href !== "/root/portfolio")
.sort((left, right) => systemOrder.indexOf(left.href) - systemOrder.indexOf(right.href));
function renderItem(item: SidebarItem, nested = false) {
const Icon = item.icon;
return (
<div key={item.label} className="space-y-1">
<Link
href={item.href}
className={cn(
"flex items-center gap-2 rounded-nested px-3 py-2 text-sm transition-colors",
item.active
? "bg-sidebar-primary text-sidebar-primary-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
nested ? "text-sidebar-foreground/72" : undefined,
)}
>
<Icon className="h-4 w-4" />
<span>{item.label}</span>
</Link>
{item.children?.length ? (
<div className="ml-3 space-y-1 border-l border-sidebar-border pl-3">
{item.children.map((child) => renderItem(child, true))}
</div>
) : null}
</div>
);
}
return (
<AppCard className="sticky top-6 overflow-hidden bg-sidebar text-sidebar-foreground shadow-sidebar">
<CardHeader className="pb-4">
<CardTitle className="text-base font-semibold">{title}</CardTitle>
<p className="text-sm text-sidebar-foreground/72">{description}</p>
</CardHeader>
<CardContent className="space-y-1 px-3 pb-3 pt-0">
{items.map((item) => {
const Icon = item.icon;
<div className="sticky top-6 space-y-4">
<AppCard className="overflow-hidden bg-sidebar text-sidebar-foreground shadow-sidebar">
<CardHeader className="pb-4">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-sidebar-foreground/56">
Root Access
</p>
<CardTitle className="text-base font-semibold">{title}</CardTitle>
<p className="text-sm leading-6 text-sidebar-foreground/72">{description}</p>
</CardHeader>
{footer ? <div className="border-t border-sidebar-border px-4 py-3">{footer}</div> : null}
</AppCard>
return (
<div key={item.label} className="space-y-1">
<Link
href={item.href}
className={cn(
"flex items-center gap-2 rounded-nested px-3 py-2 text-sm transition-colors",
item.active
? "bg-sidebar-primary text-sidebar-primary-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
)}
>
<Icon className="h-4 w-4" />
<span>{item.label}</span>
</Link>
<AppCard className="overflow-hidden bg-sidebar text-sidebar-foreground shadow-sidebar">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold uppercase tracking-[0.2em] text-sidebar-foreground/72">
Overview
</CardTitle>
</CardHeader>
<CardContent className="space-y-1 px-3 pb-3 pt-0">
{systemItems.map((item) => renderItem(item))}
</CardContent>
</AppCard>
{item.children?.length ? (
<div className="ml-3 space-y-1 border-l border-sidebar-border pl-3">
{item.children.map((child) => {
const ChildIcon = child.icon;
return (
<Link
key={child.label}
href={child.href}
className={cn(
"flex items-center gap-2 rounded-nested px-3 py-2 text-sm transition-colors",
child.active
? "bg-sidebar-primary text-sidebar-primary-foreground"
: "text-sidebar-foreground/72 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
)}
>
<ChildIcon className="h-4 w-4" />
<span>{child.label}</span>
</Link>
);
})}
</div>
) : null}
</div>
);
})}
</CardContent>
{footer ? <div className="border-t border-sidebar-border px-4 py-3">{footer}</div> : null}
</AppCard>
{portfolioItem ? (
<AppCard className="overflow-hidden bg-sidebar text-sidebar-foreground shadow-sidebar">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold uppercase tracking-[0.2em] text-sidebar-foreground/72">
Portfolio
</CardTitle>
</CardHeader>
<CardContent className="space-y-1 px-3 pb-3 pt-0">
{renderItem(portfolioItem)}
</CardContent>
</AppCard>
) : null}
</div>
);
}
+30 -30
View File
@@ -199,11 +199,11 @@ export function PortfolioProjectForm({
<AppCard>
<CardHeader>
<CardTitle>Project Basics</CardTitle>
<CardTitle>Projekt Basisdaten</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="categoryId">Category</Label>
<Label htmlFor="categoryId">Kategorie</Label>
<select
id="categoryId"
name="categoryId"
@@ -225,7 +225,7 @@ export function PortfolioProjectForm({
</div>
<div className="space-y-2">
<Label htmlFor="clientName">Client Name</Label>
<Label htmlFor="clientName">Kundenname</Label>
<Input
id="clientName"
name="clientName"
@@ -235,7 +235,7 @@ export function PortfolioProjectForm({
</div>
<div className="space-y-2">
<Label htmlFor="projectYear">Project Year</Label>
<Label htmlFor="projectYear">Projektjahr</Label>
<Input
id="projectYear"
name="projectYear"
@@ -259,7 +259,7 @@ export function PortfolioProjectForm({
</div>
<div className="space-y-2">
<Label htmlFor="sortOrder">Sort Order</Label>
<Label htmlFor="sortOrder">Sortierung</Label>
<Input
id="sortOrder"
name="sortOrder"
@@ -272,7 +272,7 @@ export function PortfolioProjectForm({
<div className="space-y-2 md:col-span-2">
<MediaFieldPicker
title="Cover Image"
title="Cover Bild"
value={coverMedia}
onChange={setCoverMedia}
options={mediaOptions}
@@ -284,19 +284,19 @@ export function PortfolioProjectForm({
<label className="flex items-center gap-3 rounded-nested border border-border px-4 py-3 text-sm">
<input type="checkbox" name="isFeatured" defaultChecked={project?.isFeatured ?? false} />
Featured
Hervorgehoben
</label>
<label className="flex items-center gap-3 rounded-nested border border-border px-4 py-3 text-sm">
<input type="checkbox" name="isPublished" defaultChecked={project?.isPublished ?? false} />
Published
Veroeffentlicht
</label>
</CardContent>
</AppCard>
<AppCard>
<CardHeader>
<CardTitle>Localized Content</CardTitle>
<CardTitle>Lokalisierte Inhalte</CardTitle>
</CardHeader>
<CardContent>
<Tabs defaultValue="de">
@@ -312,13 +312,13 @@ export function PortfolioProjectForm({
<div className="grid gap-4 rounded-surface border border-border p-4 md:grid-cols-2">
<div className="md:col-span-2 text-sm text-muted-foreground">
{locale.key === "ar"
? "Arabic content for the Arabic website."
? "Arabische Inhalte fuer die arabische Website."
: locale.key === "en"
? "English content for the English website."
: "German content for the German website."}
? "Englische Inhalte fuer die englische Website."
: "Deutsche Inhalte fuer die deutsche Website."}
</div>
<div className="space-y-2">
<Label htmlFor={`title${locale.suffix}`}>{`Title ${locale.label}`}</Label>
<Label htmlFor={`title${locale.suffix}`}>{`Titel ${locale.label}`}</Label>
<Input
id={`title${locale.suffix}`}
name={`title${locale.suffix}`}
@@ -327,7 +327,7 @@ export function PortfolioProjectForm({
/>
</div>
<div className="space-y-2">
<Label htmlFor={`serviceLabel${locale.suffix}`}>{`Service Label ${locale.label}`}</Label>
<Label htmlFor={`serviceLabel${locale.suffix}`}>{`Leistungslabel ${locale.label}`}</Label>
<Input
id={`serviceLabel${locale.suffix}`}
name={`serviceLabel${locale.suffix}`}
@@ -336,7 +336,7 @@ export function PortfolioProjectForm({
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`summary${locale.suffix}`}>{`Summary ${locale.label}`}</Label>
<Label htmlFor={`summary${locale.suffix}`}>{`Kurzbeschreibung ${locale.label}`}</Label>
<Textarea
id={`summary${locale.suffix}`}
name={`summary${locale.suffix}`}
@@ -354,14 +354,14 @@ export function PortfolioProjectForm({
<AppCard>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Sections</CardTitle>
<CardTitle>Abschnitte</CardTitle>
<Button
type="button"
variant="outline"
onClick={() => setSections((current) => [...current, createEmptySection(current.length)])}
>
<Plus className="h-4 w-4" />
Add Section
Abschnitt hinzufuegen
</Button>
</CardHeader>
<CardContent className="space-y-4">
@@ -369,7 +369,7 @@ export function PortfolioProjectForm({
<AppCard key={section.id ?? `${section.type}-${index}`} level={2}>
<CardContent className="space-y-4 p-4">
<div className="flex items-center justify-between gap-4">
<p className="text-sm font-medium text-foreground">Section #{index + 1}</p>
<p className="text-sm font-medium text-foreground">{`Abschnitt #${index + 1}`}</p>
<div className="flex gap-2">
<Button
type="button"
@@ -401,14 +401,14 @@ export function PortfolioProjectForm({
disabled={sections.length === 1}
>
<Trash2 className="h-4 w-4" />
Remove
Entfernen
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Type</Label>
<Label>Typ</Label>
<select
value={section.type}
onChange={(event) =>
@@ -432,7 +432,7 @@ export function PortfolioProjectForm({
<div className="space-y-2 md:col-span-2">
<MediaFieldPicker
title="Section Image"
title="Abschnitt Bild"
value={section.media}
onChange={(media) =>
setSections((current) =>
@@ -470,7 +470,7 @@ export function PortfolioProjectForm({
{localeFieldConfig.map((locale) => (
<div key={`${locale.key}-title-${index}`} className="space-y-2">
<Label>{`Title ${locale.label}`}</Label>
<Label>{`Titel ${locale.label}`}</Label>
<Input
value={section[`title${locale.suffix}` as keyof SectionFormValue] as string}
onChange={(event) =>
@@ -491,7 +491,7 @@ export function PortfolioProjectForm({
{localeFieldConfig.map((locale) => (
<div key={`${locale.key}-body-${index}`} className="space-y-2 md:col-span-2">
<Label>{`Body ${locale.label}`}</Label>
<Label>{`Text ${locale.label}`}</Label>
<Textarea
rows={4}
value={section[`body${locale.suffix}` as keyof SectionFormValue] as string}
@@ -519,26 +519,26 @@ export function PortfolioProjectForm({
<AppCard>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Assets</CardTitle>
<CardTitle>Dateien</CardTitle>
<Button
type="button"
variant="outline"
onClick={() => setAssets((current) => [...current, createEmptyAsset(current.length)])}
>
<Plus className="h-4 w-4" />
Add Asset
Datei hinzufuegen
</Button>
</CardHeader>
<CardContent className="space-y-4">
{assets.length === 0 ? (
<p className="text-sm text-muted-foreground">No assets added yet.</p>
<p className="text-sm text-muted-foreground">Noch keine Dateien hinzugefuegt.</p>
) : null}
{assets.map((asset, index) => (
<AppCard key={asset.id ?? `${asset.kind}-${index}`} level={2}>
<CardContent className="space-y-4 p-4">
<div className="flex items-center justify-between gap-4">
<p className="text-sm font-medium text-foreground">Asset #{index + 1}</p>
<p className="text-sm font-medium text-foreground">{`Datei #${index + 1}`}</p>
<div className="flex gap-2">
<Button
type="button"
@@ -565,14 +565,14 @@ export function PortfolioProjectForm({
}
>
<Trash2 className="h-4 w-4" />
Remove
Entfernen
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Kind</Label>
<Label>Typ</Label>
<select
value={asset.kind}
onChange={(event) =>
@@ -603,7 +603,7 @@ export function PortfolioProjectForm({
<div className="space-y-2 md:col-span-2">
<MediaFieldPicker
title="Asset File"
title="Datei"
value={asset.media}
onChange={(media) =>
setAssets((current) =>
+23 -19
View File
@@ -1,5 +1,7 @@
import Link from "next/link";
import { AppCard } from "@/components/ui/app-card";
import { CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
type PortfolioSubnavProps = {
@@ -9,38 +11,40 @@ type PortfolioSubnavProps = {
const items = [
{
key: "overview",
label: "Overview",
label: "Uebersicht",
href: "/root/portfolio",
},
{
key: "categories",
label: "Categories",
label: "Kategorien",
href: "/root/portfolio/categories",
},
{
key: "projects",
label: "Projects",
label: "Projekte",
href: "/root/portfolio/projects",
},
] as const;
export function PortfolioSubnav({ active }: PortfolioSubnavProps) {
return (
<div className="flex flex-wrap gap-2">
{items.map((item) => (
<Link
key={item.key}
href={item.href}
className={cn(
"rounded-pill border px-4 py-2 text-sm transition-colors",
active === item.key
? "border-border-strong bg-foreground text-background"
: "border-border bg-background text-foreground/75 hover:border-border-strong hover:text-foreground",
)}
>
{item.label}
</Link>
))}
</div>
<AppCard level={2}>
<CardContent className="flex flex-wrap gap-2 p-4">
{items.map((item) => (
<Link
key={item.key}
href={item.href}
className={cn(
"rounded-pill border px-4 py-2 text-sm transition-colors",
active === item.key
? "border-border-strong bg-foreground text-background"
: "border-border bg-background text-foreground/75 hover:border-border-strong hover:text-foreground",
)}
>
{item.label}
</Link>
))}
</CardContent>
</AppCard>
);
}
+87 -39
View File
@@ -1,10 +1,19 @@
import type { ReactNode } from "react";
import { ArrowLeft, LogOut } from "lucide-react";
import {
ArrowLeft,
FolderKanban,
ImageIcon,
LayoutDashboard,
LogOut,
PlusSquare,
ShieldAlert,
SwatchBook,
Tags,
} from "lucide-react";
import Link from "next/link";
import { AppHeader } from "@/components/layout/app-header";
import { AppShell } from "@/components/layout/app-shell";
import { AppSidebar } from "@/components/layout/app-sidebar";
import { DashboardLayout } from "@/components/dashboard/dashboard-layout";
import { MotionFade } from "@/components/motion-fade";
import { ThemeToggle } from "@/components/theme-toggle";
import { Button } from "@/components/ui/button";
import { getLocalizedPath } from "@/lib/locale";
@@ -30,6 +39,7 @@ type RootDashboardShellProps = {
headerTitle: string;
headerDescription: string;
headerActions?: ReactNode;
toolbar?: ReactNode;
children: ReactNode;
};
@@ -41,47 +51,85 @@ export function RootDashboardShell({
headerTitle,
headerDescription,
headerActions,
toolbar,
children,
}: RootDashboardShellProps) {
const sidebarItems = getRootNavigation(copy, active, portfolioChild);
const sidebarItems = getRootNavigation(copy, active, portfolioChild).filter(
(item) => item.href !== "/root/maintenance" && item.href !== "/root/ui-kit",
);
const headerIcon =
active === "overview"
? LayoutDashboard
: active === "maintenance"
? ShieldAlert
: active === "ui-kit"
? SwatchBook
: active === "media"
? ImageIcon
: portfolioChild === "categories"
? Tags
: portfolioChild === "new-project"
? PlusSquare
: FolderKanban;
const sharedActions = (
<>
<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>
</>
);
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={headerTitle}
description={headerDescription}
icon={headerIcon}
items={sidebarItems}
sidebarFooter={
<>
<Button
asChild
variant={active === "maintenance" ? "default" : "outline"}
className="w-full justify-between"
>
<Link href="/root/maintenance">
{copy.maintenance}
<ShieldAlert className="h-4 w-4" />
</Link>
</Button>
<Button
asChild
variant={active === "ui-kit" ? "default" : "outline"}
className="w-full justify-between"
>
<Link href="/root/ui-kit">
{copy.uiKit}
<SwatchBook className="h-4 w-4" />
</Link>
</Button>
</>
}
header={
<AppHeader
title={headerTitle}
description={headerDescription}
actions={headerActions}
/>
headerActions={
<div className="flex flex-wrap items-center justify-end gap-2">
{headerActions}
{sharedActions}
</div>
}
>
{children}
</AppShell>
<div className="space-y-6">
{toolbar ? <MotionFade delay={0.05}>{toolbar}</MotionFade> : null}
{children}
</div>
</DashboardLayout>
);
}
+28 -4
View File
@@ -4,13 +4,22 @@ import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Button, type ButtonProps } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type ThemeToggleProps = {
ariaLabel?: string;
label?: string;
variant?: ButtonProps["variant"];
className?: string;
};
export function ThemeToggle({ ariaLabel = "Toggle theme" }: ThemeToggleProps) {
export function ThemeToggle({
ariaLabel = "Toggle theme",
label,
variant = "outline",
className,
}: ThemeToggleProps) {
const { setTheme, theme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
@@ -20,8 +29,15 @@ export function ThemeToggle({ ariaLabel = "Toggle theme" }: ThemeToggleProps) {
if (!mounted) {
return (
<Button type="button" variant="outline" size="icon" aria-label={ariaLabel}>
<Button
type="button"
variant={variant}
size={label ? "sm" : "icon"}
aria-label={ariaLabel}
className={cn(label ? "justify-start" : undefined, className)}
>
<Moon className="h-4 w-4" />
{label ? <span>{label}</span> : null}
</Button>
);
}
@@ -30,8 +46,16 @@ export function ThemeToggle({ ariaLabel = "Toggle theme" }: ThemeToggleProps) {
const isDark = activeTheme === "dark";
return (
<Button type="button" variant="outline" size="icon" onClick={() => setTheme(isDark ? "light" : "dark")} aria-label={ariaLabel}>
<Button
type="button"
variant={variant}
size={label ? "sm" : "icon"}
onClick={() => setTheme(isDark ? "light" : "dark")}
aria-label={ariaLabel}
className={cn(label ? "justify-start" : undefined, className)}
>
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
{label ? <span>{label}</span> : null}
</Button>
);
}
+193
View File
@@ -0,0 +1,193 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => (
<span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
);
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
};
+112
View File
@@ -0,0 +1,112 @@
"use client";
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out",
className,
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = {
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=open]:slide-in-from-right data-[state=closed]:slide-out-to-right sm:max-w-sm",
left:
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=open]:slide-in-from-left data-[state=closed]:slide-out-to-left sm:max-w-sm",
top:
"inset-x-0 top-0 border-b data-[state=open]:slide-in-from-top data-[state=closed]:slide-out-to-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=open]:slide-in-from-bottom data-[state=closed]:slide-out-to-bottom",
};
type SheetContentProps = React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content> & {
side?: keyof typeof sheetVariants;
};
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out",
sheetVariants[side],
className,
)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-left", className)} {...props} />
);
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
};
+112
View File
@@ -0,0 +1,112 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
),
);
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
),
);
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-11 px-4 text-left align-middle font-medium text-muted-foreground",
className,
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td ref={ref} className={cn("p-4 align-middle", className)} {...props} />
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
export {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
};
+5 -5
View File
@@ -253,7 +253,7 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
<TabsContent value="cards">
<UiKitSection title={copy.cards} description={copy.cardsDesc}>
<div className="grid gap-6 lg:grid-cols-2">
<div className="grid gap-6">
<AppCard level={1}>
<CardContent className="space-y-4 p-6">
<div>
@@ -307,7 +307,7 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
<TabsContent value="buttons">
<UiKitSection title={copy.buttons} description={copy.buttonsDesc}>
<div className="grid gap-6 lg:grid-cols-2">
<div className="grid gap-6">
<AppCard level={2}>
<CardContent className="flex flex-wrap gap-3 p-5">
<Button>{copy.default}</Button>
@@ -335,7 +335,7 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
<TabsContent value="inputs">
<UiKitSection title={copy.inputs} description={copy.inputsDesc}>
<div className="grid gap-6 lg:grid-cols-2">
<div className="grid gap-6">
<AppCard level={2}>
<CardContent className="grid gap-4 p-5">
<Label className="grid gap-2">
@@ -431,7 +431,7 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
<TabsContent value="surfaces">
<UiKitSection title={copy.surfaces} description={copy.surfacesDesc}>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="grid gap-4">
<AppCard level={1}>
<CardContent className="p-5">
<p className="font-medium">{copy.surfaceOne}</p>
@@ -489,7 +489,7 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
<TabsContent value="headers">
<UiKitSection title={copy.headers} description={copy.headersDesc}>
<div className="grid gap-6 lg:grid-cols-2">
<div className="grid gap-6">
<AppCard level={3}>
<CardContent className="space-y-3 p-6">
<div className="inline-flex w-fit items-center gap-2 rounded-pill border border-border bg-surface-1 px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-foreground/80">
+788 -1
View File
@@ -10,6 +10,8 @@
"dependencies": {
"@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.2",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -659,6 +661,44 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
"integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.7.6"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
"node_modules/@formatjs/ecma402-abstract": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-3.1.1.tgz",
@@ -1595,6 +1635,79 @@
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
@@ -1610,6 +1723,414 @@
}
}
},
"node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dropdown-menu": {
"version": "2.1.16",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz",
"integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-menu": "2.1.16",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu": {
"version": "2.1.16",
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
"integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-rect": "1.1.1",
"@radix-ui/react-use-size": "1.1.1",
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
"integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
@@ -1628,6 +2149,133 @@
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
"license": "MIT",
"dependencies": {
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-size": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
@@ -2277,7 +2925,7 @@
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
@@ -3057,6 +3705,18 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/aria-query": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
@@ -3928,6 +4588,12 @@
"node": ">=8"
}
},
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -5055,6 +5721,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-port-please": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz",
@@ -7528,6 +8203,75 @@
"dev": true,
"license": "MIT"
},
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
"license": "MIT",
"dependencies": {
"react-remove-scroll-bar": "^2.3.7",
"react-style-singleton": "^2.2.3",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.3",
"use-sidecar": "^1.1.3"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-remove-scroll-bar": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
"license": "MIT",
"dependencies": {
"react-style-singleton": "^2.2.2",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
"license": "MIT",
"dependencies": {
"get-nonce": "^1.0.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -8860,6 +9604,27 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-intl": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.8.3.tgz",
@@ -8881,6 +9646,28 @@
"react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
"license": "MIT",
"dependencies": {
"detect-node-es": "^1.1.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+2
View File
@@ -19,6 +19,8 @@
"dependencies": {
"@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.2",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",