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
+17 -51
View File
@@ -1,27 +1,23 @@
import { ArrowLeft, LogOut, Power } from "lucide-react"; import { Power } from "lucide-react";
import Link from "next/link";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { AppHeader } from "@/components/layout/app-header"; import { MotionFade } from "@/components/motion-fade";
import { AppShell } from "@/components/layout/app-shell"; import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
import { AppSidebar } from "@/components/layout/app-sidebar";
import { ThemeToggle } from "@/components/theme-toggle";
import { routing } from "@/i18n/routing"; import { routing } from "@/i18n/routing";
import { getLocalizedPath } from "@/lib/locale"; import { getLocalizedPath } from "@/lib/locale";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import { getMaintenanceMode, setMaintenanceMode } from "@/lib/app-config"; import { getMaintenanceMode, setMaintenanceMode } from "@/lib/app-config";
import { getRootNavigation } from "@/lib/root-navigation";
import { AppCard } from "@/components/ui/app-card"; import { AppCard } from "@/components/ui/app-card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; 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"; export const dynamic = "force-dynamic";
const copy = { const copy = {
title: "Wartungsmodus", title: "Wartungsmodus",
subtitle: "Steuerung fuer den globalen Maintenance Status.", subtitle: "Steuerung fuer den globalen Wartungsstatus.",
overview: "Uebersicht", overview: "Uebersicht",
maintenance: "Wartungsmodus", maintenance: "Wartungsmodus",
uiKit: "UI Kit", uiKit: "UI Kit",
@@ -75,54 +71,23 @@ export default async function RootMaintenancePage() {
redirect("/root/maintenance"); redirect("/root/maintenance");
} }
const sidebarItems = getRootNavigation(copy, "maintenance");
return ( return (
<AppShell <RootDashboardShell
sidebar={ copy={copy}
<AppSidebar active="maintenance"
title={copy.title} logoutAction={logoutAction}
description={copy.subtitle} headerTitle={copy.title}
items={sidebarItems} headerDescription={copy.subtitle}
footer={ headerActions={
<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"}> <Badge variant={maintenanceEnabled ? "warning" : "success"}>
{maintenanceEnabled ? copy.maintenanceOn : copy.maintenanceOff} {maintenanceEnabled ? copy.maintenanceOn : copy.maintenanceOff}
</Badge> </Badge>
} }
/>
}
> >
<MotionFade delay={0.1}>
<AppCard> <AppCard>
<CardHeader> <CardContent className="space-y-4 p-6">
<CardTitle className="text-xl">{copy.title}</CardTitle> <p className="text-sm text-muted-foreground">{copy.maintenanceText}</p>
<CardDescription>{copy.maintenanceText}</CardDescription>
</CardHeader>
<CardContent>
<form action={updateMaintenanceMode}> <form action={updateMaintenanceMode}>
<input <input
type="hidden" type="hidden"
@@ -136,6 +101,7 @@ export default async function RootMaintenancePage() {
</form> </form>
</CardContent> </CardContent>
</AppCard> </AppCard>
</AppShell> </MotionFade>
</RootDashboardShell>
); );
} }
+6 -6
View File
@@ -47,13 +47,13 @@ export async function createMediaAssetAction(formData: FormData) {
}); });
revalidateMediaPages(); revalidateMediaPages();
redirect(withMessage("/root/media", "success", "Media asset created.")); redirect(withMessage("/root/media", "success", "Datei gespeichert."));
} catch (error) { } catch (error) {
if (isRedirectError(error)) { if (isRedirectError(error)) {
throw 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)); redirect(withMessage("/root/media", "error", message));
} }
} }
@@ -67,13 +67,13 @@ export async function deleteMediaAssetAction(formData: FormData) {
const asset = await getMediaAssetById(assetId); const asset = await getMediaAssetById(assetId);
if (!asset) { 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); const usageCount = await countMediaUsageReferences(asset.id);
if (usageCount > 0) { 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({ await prisma.mediaAsset.delete({
@@ -90,13 +90,13 @@ export async function deleteMediaAssetAction(formData: FormData) {
} }
revalidateMediaPages(); revalidateMediaPages();
redirect(withMessage("/root/media", "success", "Media asset deleted.")); redirect(withMessage("/root/media", "success", "Datei geloescht."));
} catch (error) { } catch (error) {
if (isRedirectError(error)) { if (isRedirectError(error)) {
throw 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)); redirect(withMessage("/root/media", "error", message));
} }
} }
+35 -13
View File
@@ -4,6 +4,7 @@ import { ExternalLink, ImageIcon, Trash2 } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { MotionFade } from "@/components/motion-fade";
import { RootDashboardShell } from "@/components/root/root-dashboard-shell"; import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
import { AppCard } from "@/components/ui/app-card"; import { AppCard } from "@/components/ui/app-card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -27,6 +28,17 @@ const copy = {
portfolio: "Portfolio", portfolio: "Portfolio",
logout: "Ausloggen", logout: "Ausloggen",
backToSite: "Zur Website", 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 = { type RootMediaPageProps = {
@@ -60,31 +72,36 @@ export default async function RootMediaPage({ searchParams }: RootMediaPageProps
> >
<div className="space-y-6"> <div className="space-y-6">
{searchParams?.success ? ( {searchParams?.success ? (
<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"> <p className="rounded-nested border border-status-success/30 bg-status-success/10 px-4 py-3 text-sm text-status-success">
{searchParams.success} {searchParams.success}
</p> </p>
</MotionFade>
) : null} ) : null}
{searchParams?.error ? ( {searchParams?.error ? (
<MotionFade delay={0.12}>
<p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive"> <p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{searchParams.error} {searchParams.error}
</p> </p>
</MotionFade>
) : null} ) : null}
<MotionFade delay={0.15}>
<AppCard> <AppCard>
<CardHeader> <CardHeader>
<CardTitle>New Media Asset</CardTitle> <CardTitle>{copy.createTitle}</CardTitle>
<CardDescription>Upload a file or store an external URL for reuse across the site.</CardDescription> <CardDescription>{copy.createDescription}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form action={createMediaAssetAction} className="grid gap-4 md:grid-cols-2"> <form action={createMediaAssetAction} className="grid gap-4 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="label">Label</Label> <Label htmlFor="label">{copy.label}</Label>
<Input id="label" name="label" required /> <Input id="label" name="label" required />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="kind">Kind</Label> <Label htmlFor="kind">{copy.kind}</Label>
<select <select
id="kind" id="kind"
name="kind" name="kind"
@@ -97,34 +114,36 @@ export default async function RootMediaPage({ searchParams }: RootMediaPageProps
</div> </div>
<div className="space-y-2 md:col-span-2"> <div className="space-y-2 md:col-span-2">
<Label htmlFor="file">Upload File</Label> <Label htmlFor="file">{copy.uploadFile}</Label>
<Input id="file" name="file" type="file" accept="image/*,.svg,.pdf" /> <Input id="file" name="file" type="file" accept="image/*,.svg,.pdf" />
</div> </div>
<div className="space-y-2 md:col-span-2"> <div className="space-y-2 md:col-span-2">
<Label htmlFor="externalUrl">External URL</Label> <Label htmlFor="externalUrl">{copy.externalUrl}</Label>
<Input id="externalUrl" name="externalUrl" placeholder="https://example.com/image.jpg" /> <Input id="externalUrl" name="externalUrl" placeholder="https://example.com/image.jpg" />
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<Button type="submit"> <Button type="submit">
<ImageIcon className="h-4 w-4" /> <ImageIcon className="h-4 w-4" />
Save Media {copy.saveMedia}
</Button> </Button>
</div> </div>
</form> </form>
</CardContent> </CardContent>
</AppCard> </AppCard>
</MotionFade>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{mediaAssets.map((asset) => ( {mediaAssets.map((asset, index) => (
<AppCard key={asset.id}> <MotionFade key={asset.id} delay={0.18 + index * 0.04}>
<AppCard>
<CardHeader> <CardHeader>
<CardTitle className="text-base">{asset.label}</CardTitle> <CardTitle className="text-base">{asset.label}</CardTitle>
<CardDescription className="flex flex-wrap gap-2"> <CardDescription className="flex flex-wrap gap-2">
<span>{asset.kind}</span> <span>{asset.kind}</span>
<span>{asset.source}</span> <span>{asset.source}</span>
<span>{asset.usages.length} usages</span> <span>{asset.usages.length} {copy.usages}</span>
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
@@ -143,7 +162,7 @@ export default async function RootMediaPage({ searchParams }: RootMediaPageProps
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<Link href={asset.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-2 text-foreground"> <Link href={asset.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-2 text-foreground">
<ExternalLink className="h-4 w-4" /> <ExternalLink className="h-4 w-4" />
Open {copy.open}
</Link> </Link>
</div> </div>
</div> </div>
@@ -162,20 +181,23 @@ export default async function RootMediaPage({ searchParams }: RootMediaPageProps
<input type="hidden" name="assetId" value={asset.id} /> <input type="hidden" name="assetId" value={asset.id} />
<Button type="submit" variant="destructive" disabled={asset.usages.length > 0}> <Button type="submit" variant="destructive" disabled={asset.usages.length > 0}>
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
Delete {copy.delete}
</Button> </Button>
</form> </form>
</CardContent> </CardContent>
</AppCard> </AppCard>
</MotionFade>
))} ))}
</div> </div>
{mediaAssets.length === 0 ? ( {mediaAssets.length === 0 ? (
<MotionFade delay={0.2}>
<AppCard> <AppCard>
<CardContent className="p-6 text-sm text-muted-foreground"> <CardContent className="p-6 text-sm text-muted-foreground">
No media assets found yet. {copy.noAssets}
</CardContent> </CardContent>
</AppCard> </AppCard>
</MotionFade>
) : null} ) : null}
</div> </div>
</RootDashboardShell> </RootDashboardShell>
+252 -109
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 Link from "next/link";
import { redirect } from "next/navigation"; 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 { Container } from "@/components/layout/container";
import { MotionFade } from "@/components/motion-fade"; 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 { ThemeToggle } from "@/components/theme-toggle";
import { getLocalizedPath } from "@/lib/locale"; import { Badge, type BadgeProps } from "@/components/ui/badge";
import { AppCard } from "@/components/ui/app-card";
import { Button } from "@/components/ui/button"; 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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { import {
clearAdminSessionCookie, clearAdminSessionCookie,
getAdminLockState, getAdminLockState,
@@ -25,6 +43,12 @@ import {
setAdminSessionCookie, setAdminSessionCookie,
} from "@/lib/admin-auth"; } from "@/lib/admin-auth";
import { getMaintenanceMode } from "@/lib/app-config"; 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"; import { getRootNavigation } from "@/lib/root-navigation";
type RootPageProps = { type RootPageProps = {
@@ -52,7 +76,7 @@ const copy = {
media: "Media", media: "Media",
portfolio: "Portfolio", portfolio: "Portfolio",
portfolioTitle: "Portfolio", portfolioTitle: "Portfolio",
portfolioDescription: "Kategorien, Projekte, Sections und Assets verwalten.", portfolioDescription: "Kategorien, Projekte, Abschnitte und Dateien verwalten.",
portfolioAction: "Zum Portfolio", portfolioAction: "Zum Portfolio",
mediaTitle: "Media Library", mediaTitle: "Media Library",
mediaDescription: "Uploads, externe URLs und Verwendungsorte zentral verwalten.", mediaDescription: "Uploads, externe URLs und Verwendungsorte zentral verwalten.",
@@ -68,6 +92,31 @@ const copy = {
logout: "Ausloggen", logout: "Ausloggen",
backToSite: "Zur Website", backToSite: "Zur Website",
uiKit: "UI Kit", 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) { export default async function RootPage({ searchParams }: RootPageProps) {
@@ -77,7 +126,6 @@ export default async function RootPage({ searchParams }: RootPageProps) {
); );
const authenticated = isAdminAuthenticated(); const authenticated = isAdminAuthenticated();
const lockState = getAdminLockState(); const lockState = getAdminLockState();
const maintenanceEnabled = authenticated ? await getMaintenanceMode() : false;
async function loginAction(formData: FormData) { async function loginAction(formData: FormData) {
"use server"; "use server";
@@ -114,7 +162,7 @@ export default async function RootPage({ searchParams }: RootPageProps) {
return ( return (
<Container size="narrow" className="py-12"> <Container size="narrow" className="py-12">
<MotionFade> <MotionFade>
<AppCard level={3}> <Card className="border-border/70 bg-card/95 shadow-sm">
<CardHeader> <CardHeader>
<p className="inline-flex items-center gap-2 text-sm font-medium text-muted-foreground"> <p className="inline-flex items-center gap-2 text-sm font-medium text-muted-foreground">
<LockKeyhole className="h-4 w-4 text-brand-primary" /> <LockKeyhole className="h-4 w-4 text-brand-primary" />
@@ -159,137 +207,232 @@ export default async function RootPage({ searchParams }: RootPageProps) {
</Button> </Button>
</form> </form>
</CardContent> </CardContent>
</AppCard> </Card>
</MotionFade> </MotionFade>
</Container> </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 ( return (
<AppShell <DashboardLayout
sidebar={
<AppSidebar
title={copy.title} title={copy.title}
description={copy.subtitle} description={copy.subtitle}
items={sidebarItems} icon={LayoutDashboard}
footer={ items={rootNavigation}
<div className="space-y-2"> sidebarFooter={
<div className="flex items-center gap-2"> <>
<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>
</>
}
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" /> <ThemeToggle ariaLabel="Theme wechseln" />
</div> <Button asChild variant="outline" size="sm">
<Button asChild variant="outline" className="w-full justify-start">
<Link href={getLocalizedPath("de")}> <Link href={getLocalizedPath("de")}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
{copy.backToSite} {copy.backToSite}
</Link> </Link>
</Button> </Button>
<form action={logoutAction}> <form action={logoutAction}>
<Button type="submit" variant="destructive" className="w-full justify-start"> <Button type="submit" variant="ghost" size="sm" className="text-destructive hover:bg-destructive/10 hover:text-destructive">
<LogOut className="h-4 w-4" /> <LogOut className="h-4 w-4" />
{copy.logout} {copy.logout}
</Button> </Button>
</form> </form>
</div> </div>
} }
/>
}
header={
<AppHeader
title={copy.title}
description={copy.subtitle}
actions={
maintenanceEnabled ? (
<Button asChild variant="destructive">
<Link href="/root/maintenance">
{copy.maintenanceVisitorsClosed}
</Link>
</Button>
) : null
}
/>
}
> >
<div className="grid gap-6"> <div className="space-y-6">
<section className="grid gap-4 lg:grid-cols-4"> <section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MotionFade delay={0.05}> <MotionFade delay={0.05}>
<AppCard level={2}> <DashboardCard
<CardHeader> title={copy.maintenanceTitle}
<CardDescription>{copy.maintenanceTitle}</CardDescription> value={maintenanceEnabled ? copy.maintenanceStatusOn : copy.maintenanceStatusOff}
</CardHeader> description={copy.maintenanceDescription}
<CardContent className="space-y-4"> icon={ShieldAlert}
<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>
</MotionFade> </MotionFade>
<MotionFade delay={0.1}> <MotionFade delay={0.1}>
<AppCard level={2}> <DashboardCard
<CardHeader> title={copy.totalMedia}
<CardDescription>{copy.uiKitTitle}</CardDescription> value={String(mediaAssets.length)}
</CardHeader> description={copy.mediaCountDescription}
<CardContent className="space-y-4"> icon={ImageIcon}
<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>
</MotionFade> </MotionFade>
<MotionFade delay={0.15}> <MotionFade delay={0.15}>
<AppCard level={2}> <DashboardCard
<CardHeader> title={copy.totalProjects}
<CardDescription>{copy.portfolioTitle}</CardDescription> value={String(projects.length)}
</CardHeader> description={copy.projectCountDescription}
<CardContent className="space-y-4"> icon={FolderKanban}
<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>
</MotionFade> </MotionFade>
<MotionFade delay={0.2}> <MotionFade delay={0.2}>
<AppCard level={2}> <DashboardCard
<CardHeader> title={copy.totalCategories}
<CardDescription>{copy.mediaTitle}</CardDescription> value={String(categories.length)}
</CardHeader> description={copy.categoryCountDescription}
<CardContent className="space-y-4"> icon={Shapes}
<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>
</MotionFade> </MotionFade>
</section> </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> </div>
</AppShell> <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>
</DashboardLayout>
); );
} }
+15 -15
View File
@@ -52,12 +52,12 @@ function parseJsonArray(rawValue: FormDataEntryValue | null, key: string) {
const parsed = JSON.parse(rawValue); const parsed = JSON.parse(rawValue);
if (!Array.isArray(parsed)) { if (!Array.isArray(parsed)) {
throw new Error(`${key} must be an array.`); throw new Error(`${key} muss ein Array sein.`);
} }
return parsed; return parsed;
} catch { } 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); const parsed = JSON.parse(rawValue);
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") { 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; return parsed;
} catch { } catch {
throw new Error(`Invalid ${key} payload.`); throw new Error(`Ungueltige ${key} Nutzdaten.`);
} }
} }
function parseZodError(error: ZodError) { function parseZodError(error: ZodError) {
return error.issues[0]?.message ?? "Validation failed."; return error.issues[0]?.message ?? "Validierung fehlgeschlagen.";
} }
async function revalidatePortfolioPages() { async function revalidatePortfolioPages() {
@@ -135,7 +135,7 @@ export async function upsertCategoryAction(formData: FormData) {
} }
await revalidatePortfolioPages(); await revalidatePortfolioPages();
redirect(withMessage(redirectPath, "success", "Category saved.")); redirect(withMessage(redirectPath, "success", "Kategorie gespeichert."));
} catch (error) { } catch (error) {
if (isRedirectError(error)) { if (isRedirectError(error)) {
throw error; throw error;
@@ -145,8 +145,8 @@ export async function upsertCategoryAction(formData: FormData) {
error instanceof ZodError error instanceof ZodError
? parseZodError(error) ? parseZodError(error)
: error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002" : error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002"
? "Category slug must be unique." ? "Kategorie Slug muss eindeutig sein."
: "Unable to save category."; : "Kategorie konnte nicht gespeichert werden.";
redirect(withMessage(redirectPath, "error", message)); redirect(withMessage(redirectPath, "error", message));
} }
@@ -166,7 +166,7 @@ export async function deleteCategoryAction(formData: FormData) {
}); });
if (projectCount > 0) { 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({ await prisma.category.delete({
@@ -176,13 +176,13 @@ export async function deleteCategoryAction(formData: FormData) {
}); });
await revalidatePortfolioPages(); await revalidatePortfolioPages();
redirect(withMessage(redirectPath, "success", "Category deleted.")); redirect(withMessage(redirectPath, "success", "Kategorie geloescht."));
} catch (error) { } catch (error) {
if (isRedirectError(error)) { if (isRedirectError(error)) {
throw 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) { 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) { if (assetSelection.createdAssetId) {
@@ -521,7 +521,7 @@ export async function saveProjectAction(formData: FormData) {
} }
redirect( redirect(
withMessage(`/root/portfolio/projects/${projectResult.project.id}`, "success", "Project saved."), withMessage(`/root/portfolio/projects/${projectResult.project.id}`, "success", "Projekt gespeichert."),
); );
} catch (error) { } catch (error) {
if (isRedirectError(error)) { if (isRedirectError(error)) {
@@ -532,10 +532,10 @@ export async function saveProjectAction(formData: FormData) {
error instanceof ZodError error instanceof ZodError
? parseZodError(error) ? parseZodError(error)
: error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002" : error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002"
? "Project slug must be unique." ? "Projekt Slug muss eindeutig sein."
: error instanceof Error : error instanceof Error
? error.message ? error.message
: "Unable to save project."; : "Projekt konnte nicht gespeichert werden.";
await removeManagedPaths(uploadedPaths); await removeManagedPaths(uploadedPaths);
if (createdMediaAssetIds.length > 0) { if (createdMediaAssetIds.length > 0) {
+32 -17
View File
@@ -1,5 +1,6 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { MotionFade } from "@/components/motion-fade";
import { PortfolioSubnav } from "@/components/root/portfolio-subnav"; import { PortfolioSubnav } from "@/components/root/portfolio-subnav";
import { RootDashboardShell } from "@/components/root/root-dashboard-shell"; import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
import { AppCard } from "@/components/ui/app-card"; import { AppCard } from "@/components/ui/app-card";
@@ -17,9 +18,9 @@ import { deleteCategoryAction, upsertCategoryAction } from "../actions";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const locales = [ const locales = [
{ key: "Ar", label: "Arabic", hint: "الواجهة العربية" }, { key: "Ar", label: "Arabisch", hint: "الواجهة العربية" },
{ key: "En", label: "English", hint: "English website" }, { key: "En", label: "Englisch", hint: "English website" },
{ key: "De", label: "German", hint: "Deutsche Website" }, { key: "De", label: "Deutsch", hint: "Deutsche Website" },
] as const; ] as const;
const copy = { const copy = {
@@ -32,6 +33,13 @@ const copy = {
portfolio: "Portfolio", portfolio: "Portfolio",
logout: "Ausloggen", logout: "Ausloggen",
backToSite: "Zur Website", backToSite: "Zur Website",
sortOrder: "Sortierung",
active: "Aktiv",
saveCategory: "Kategorie speichern",
save: "Speichern",
delete: "Loeschen",
projects: "Projekte",
description: "Beschreibung",
}; };
type RootPortfolioCategoriesPageProps = { type RootPortfolioCategoriesPageProps = {
@@ -65,22 +73,26 @@ export default async function RootPortfolioCategoriesPage({
logoutAction={logoutAction} logoutAction={logoutAction}
headerTitle={copy.title} headerTitle={copy.title}
headerDescription={copy.subtitle} headerDescription={copy.subtitle}
toolbar={<PortfolioSubnav active="categories" />}
> >
<div className="space-y-6"> <div className="space-y-6">
<PortfolioSubnav active="categories" />
{searchParams?.success ? ( {searchParams?.success ? (
<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"> <p className="rounded-nested border border-status-success/30 bg-status-success/10 px-4 py-3 text-sm text-status-success">
{searchParams.success} {searchParams.success}
</p> </p>
</MotionFade>
) : null} ) : null}
{searchParams?.error ? ( {searchParams?.error ? (
<MotionFade delay={0.12}>
<p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive"> <p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{searchParams.error} {searchParams.error}
</p> </p>
</MotionFade>
) : null} ) : null}
<MotionFade delay={0.15}>
<AppCard> <AppCard>
<CardHeader> <CardHeader>
<CardTitle>Neue Kategorie</CardTitle> <CardTitle>Neue Kategorie</CardTitle>
@@ -94,7 +106,7 @@ export default async function RootPortfolioCategoriesPage({
<Input id="create-slug" name="slug" required /> <Input id="create-slug" name="slug" required />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="create-sortOrder">Sort Order</Label> <Label htmlFor="create-sortOrder">{copy.sortOrder}</Label>
<Input id="create-sortOrder" name="sortOrder" type="number" min="0" defaultValue="0" required /> <Input id="create-sortOrder" name="sortOrder" type="number" min="0" defaultValue="0" required />
</div> </div>
@@ -117,7 +129,7 @@ export default async function RootPortfolioCategoriesPage({
<Input id={`create-name-${locale.key}`} name={`name${locale.key}`} required /> <Input id={`create-name-${locale.key}`} name={`name${locale.key}`} required />
</div> </div>
<div className="space-y-2 md:col-span-2"> <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 <Textarea
id={`create-description-${locale.key}`} id={`create-description-${locale.key}`}
name={`description${locale.key}`} name={`description${locale.key}`}
@@ -133,19 +145,21 @@ export default async function RootPortfolioCategoriesPage({
<label className="flex items-center gap-3 rounded-nested border border-border px-4 py-3 text-sm md:col-span-2"> <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 /> <input type="checkbox" name="isActive" defaultChecked />
Active {copy.active}
</label> </label>
<div className="md:col-span-2"> <div className="md:col-span-2">
<Button type="submit">Save Category</Button> <Button type="submit">{copy.saveCategory}</Button>
</div> </div>
</form> </form>
</CardContent> </CardContent>
</AppCard> </AppCard>
</MotionFade>
<div className="grid gap-4"> <div className="grid gap-4">
{categories.map((category) => ( {categories.map((category, index) => (
<AppCard key={category.id}> <MotionFade key={category.id} delay={0.18 + index * 0.03}>
<AppCard>
<CardContent className="p-6"> <CardContent className="p-6">
<form action={upsertCategoryAction} className="grid gap-4 md:grid-cols-2"> <form action={upsertCategoryAction} className="grid gap-4 md:grid-cols-2">
<input type="hidden" name="id" value={category.id} /> <input type="hidden" name="id" value={category.id} />
@@ -157,7 +171,7 @@ export default async function RootPortfolioCategoriesPage({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={`sortOrder-${category.id}`}>Sort Order</Label> <Label htmlFor={`sortOrder-${category.id}`}>{copy.sortOrder}</Label>
<Input <Input
id={`sortOrder-${category.id}`} id={`sortOrder-${category.id}`}
name="sortOrder" name="sortOrder"
@@ -197,7 +211,7 @@ export default async function RootPortfolioCategoriesPage({
/> />
</div> </div>
<div className="space-y-2 md:col-span-2"> <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 <Textarea
id={`${descriptionKey}-${category.id}`} id={`${descriptionKey}-${category.id}`}
name={descriptionKey} name={descriptionKey}
@@ -215,13 +229,13 @@ export default async function RootPortfolioCategoriesPage({
<label className="flex items-center gap-3 rounded-nested border border-border px-4 py-3 text-sm"> <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} /> <input type="checkbox" name="isActive" defaultChecked={category.isActive} />
Active {copy.active}
</label> </label>
<div className="flex items-end justify-between gap-3"> <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"> <div className="flex gap-3">
<Button type="submit">Save</Button> <Button type="submit">{copy.save}</Button>
</div> </div>
</div> </div>
</form> </form>
@@ -230,11 +244,12 @@ export default async function RootPortfolioCategoriesPage({
<input type="hidden" name="id" value={category.id} /> <input type="hidden" name="id" value={category.id} />
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" /> <input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
<Button type="submit" variant="destructive" disabled={category.projectCount > 0}> <Button type="submit" variant="destructive" disabled={category.projectCount > 0}>
Delete {copy.delete}
</Button> </Button>
</form> </form>
</CardContent> </CardContent>
</AppCard> </AppCard>
</MotionFade>
))} ))}
</div> </div>
</div> </div>
+8 -3
View File
@@ -61,6 +61,7 @@ export default async function RootPortfolioPage() {
logoutAction={logoutAction} logoutAction={logoutAction}
headerTitle={copy.title} headerTitle={copy.title}
headerDescription={copy.subtitle} headerDescription={copy.subtitle}
toolbar={<PortfolioSubnav active="overview" />}
headerActions={ headerActions={
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<Button asChild> <Button asChild>
@@ -79,8 +80,6 @@ export default async function RootPortfolioPage() {
} }
> >
<div className="space-y-6"> <div className="space-y-6">
<PortfolioSubnav active="overview" />
<section className="grid gap-4 md:grid-cols-3"> <section className="grid gap-4 md:grid-cols-3">
{[ {[
{ {
@@ -120,6 +119,7 @@ export default async function RootPortfolioPage() {
</section> </section>
<section className="grid gap-4 lg:grid-cols-3"> <section className="grid gap-4 lg:grid-cols-3">
<MotionFade delay={0.18}>
<AppCard> <AppCard>
<CardHeader> <CardHeader>
<CardTitle>{copy.totalCategories}</CardTitle> <CardTitle>{copy.totalCategories}</CardTitle>
@@ -131,11 +131,13 @@ export default async function RootPortfolioPage() {
</Button> </Button>
</CardContent> </CardContent>
</AppCard> </AppCard>
</MotionFade>
<MotionFade delay={0.22}>
<AppCard> <AppCard>
<CardHeader> <CardHeader>
<CardTitle>{copy.totalProjects}</CardTitle> <CardTitle>{copy.totalProjects}</CardTitle>
<CardDescription>Drafts, veroeffentlichte Projekte und flexible Sections pflegen.</CardDescription> <CardDescription>Entwuerfe, veroeffentlichte Projekte und flexible Abschnitte pflegen.</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex gap-3"> <CardContent className="flex gap-3">
<Button asChild variant="outline"> <Button asChild variant="outline">
@@ -146,7 +148,9 @@ export default async function RootPortfolioPage() {
</Button> </Button>
</CardContent> </CardContent>
</AppCard> </AppCard>
</MotionFade>
<MotionFade delay={0.26}>
<AppCard> <AppCard>
<CardHeader> <CardHeader>
<CardTitle>{copy.media}</CardTitle> <CardTitle>{copy.media}</CardTitle>
@@ -161,6 +165,7 @@ export default async function RootPortfolioPage() {
</Button> </Button>
</CardContent> </CardContent>
</AppCard> </AppCard>
</MotionFade>
</section> </section>
</div> </div>
</RootDashboardShell> </RootDashboardShell>
+18 -6
View File
@@ -1,5 +1,6 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { MotionFade } from "@/components/motion-fade";
import { PortfolioProjectForm } from "@/components/root/portfolio-project-form"; import { PortfolioProjectForm } from "@/components/root/portfolio-project-form";
import { PortfolioSubnav } from "@/components/root/portfolio-subnav"; import { PortfolioSubnav } from "@/components/root/portfolio-subnav";
import { RootDashboardShell } from "@/components/root/root-dashboard-shell"; import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
@@ -27,6 +28,10 @@ const copy = {
portfolio: "Portfolio", portfolio: "Portfolio",
logout: "Ausloggen", logout: "Ausloggen",
backToSite: "Zur Website", 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 = { type RootPortfolioProjectPageProps = {
@@ -72,47 +77,54 @@ export default async function RootPortfolioProjectPage({
logoutAction={logoutAction} logoutAction={logoutAction}
headerTitle={copy.title} headerTitle={copy.title}
headerDescription={copy.subtitle} headerDescription={copy.subtitle}
toolbar={<PortfolioSubnav active="projects" />}
> >
<div className="space-y-6"> <div className="space-y-6">
<PortfolioSubnav active="projects" />
{searchParams?.success ? ( {searchParams?.success ? (
<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"> <p className="rounded-nested border border-status-success/30 bg-status-success/10 px-4 py-3 text-sm text-status-success">
{searchParams.success} {searchParams.success}
</p> </p>
</MotionFade>
) : null} ) : null}
{searchParams?.error ? ( {searchParams?.error ? (
<MotionFade delay={0.12}>
<p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive"> <p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{searchParams.error} {searchParams.error}
</p> </p>
</MotionFade>
) : null} ) : null}
<MotionFade delay={0.15}>
<PortfolioProjectForm <PortfolioProjectForm
action={saveProjectAction} action={saveProjectAction}
categories={categories} categories={categories}
mediaOptions={mediaOptions} mediaOptions={mediaOptions}
project={project} project={project}
redirectPath={`/root/portfolio/projects/${project.id}`} redirectPath={`/root/portfolio/projects/${project.id}`}
submitLabel="Save Project" submitLabel={copy.saveProject}
/> />
</MotionFade>
<MotionFade delay={0.2}>
<AppCard> <AppCard>
<CardContent className="flex items-center justify-between gap-4 p-6"> <CardContent className="flex items-center justify-between gap-4 p-6">
<div> <div>
<p className="text-sm font-medium text-foreground">Danger Zone</p> <p className="text-sm font-medium text-foreground">{copy.dangerZone}</p>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
Project records are deleted from the database. Uploaded files stay on disk. {copy.dangerText}
</p> </p>
</div> </div>
<form action={deleteProjectAction}> <form action={deleteProjectAction}>
<input type="hidden" name="id" value={project.id} /> <input type="hidden" name="id" value={project.id} />
<Button type="submit" variant="destructive"> <Button type="submit" variant="destructive">
Delete Project {copy.deleteProject}
</Button> </Button>
</form> </form>
</CardContent> </CardContent>
</AppCard> </AppCard>
</MotionFade>
</div> </div>
</RootDashboardShell> </RootDashboardShell>
); );
+8 -4
View File
@@ -1,5 +1,6 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { MotionFade } from "@/components/motion-fade";
import { PortfolioProjectForm } from "@/components/root/portfolio-project-form"; import { PortfolioProjectForm } from "@/components/root/portfolio-project-form";
import { PortfolioSubnav } from "@/components/root/portfolio-subnav"; import { PortfolioSubnav } from "@/components/root/portfolio-subnav";
import { RootDashboardShell } from "@/components/root/root-dashboard-shell"; import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
@@ -13,7 +14,7 @@ export const dynamic = "force-dynamic";
const copy = { const copy = {
title: "Neues Portfolio Projekt", title: "Neues Portfolio Projekt",
subtitle: "Projekt mit Kategorie, Sections und Assets anlegen.", subtitle: "Projekt mit Kategorie, Abschnitten und Dateien anlegen.",
overview: "Uebersicht", overview: "Uebersicht",
maintenance: "Wartungsmodus", maintenance: "Wartungsmodus",
uiKit: "UI Kit", uiKit: "UI Kit",
@@ -56,23 +57,26 @@ export default async function RootNewPortfolioProjectPage({
logoutAction={logoutAction} logoutAction={logoutAction}
headerTitle={copy.title} headerTitle={copy.title}
headerDescription={copy.subtitle} headerDescription={copy.subtitle}
toolbar={<PortfolioSubnav active="projects" />}
> >
<div className="space-y-6"> <div className="space-y-6">
<PortfolioSubnav active="projects" />
{searchParams?.error ? ( {searchParams?.error ? (
<MotionFade delay={0.1}>
<p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive"> <p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{searchParams.error} {searchParams.error}
</p> </p>
</MotionFade>
) : null} ) : null}
<MotionFade delay={0.15}>
<PortfolioProjectForm <PortfolioProjectForm
action={saveProjectAction} action={saveProjectAction}
categories={categories} categories={categories}
mediaOptions={mediaOptions} mediaOptions={mediaOptions}
redirectPath="/root/portfolio/projects/new" redirectPath="/root/portfolio/projects/new"
submitLabel="Create Project" submitLabel="Projekt anlegen"
/> />
</MotionFade>
</div> </div>
</RootDashboardShell> </RootDashboardShell>
); );
+39 -16
View File
@@ -2,6 +2,7 @@ import { Plus } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { MotionFade } from "@/components/motion-fade";
import { PortfolioSubnav } from "@/components/root/portfolio-subnav"; import { PortfolioSubnav } from "@/components/root/portfolio-subnav";
import { RootDashboardShell } from "@/components/root/root-dashboard-shell"; import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
import { AppCard } from "@/components/ui/app-card"; import { AppCard } from "@/components/ui/app-card";
@@ -27,6 +28,17 @@ const copy = {
logout: "Ausloggen", logout: "Ausloggen",
backToSite: "Zur Website", backToSite: "Zur Website",
newProject: "Neues Projekt", 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 = { type RootPortfolioProjectsPageProps = {
@@ -71,36 +83,42 @@ export default async function RootPortfolioProjectsPage({
logoutAction={logoutAction} logoutAction={logoutAction}
headerTitle={copy.title} headerTitle={copy.title}
headerDescription={copy.subtitle} headerDescription={copy.subtitle}
toolbar={<PortfolioSubnav active="projects" />}
headerActions={ headerActions={
<MotionFade delay={0.04}>
<Button asChild> <Button asChild>
<Link href="/root/portfolio/projects/new"> <Link href="/root/portfolio/projects/new">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
{copy.newProject} {copy.newProject}
</Link> </Link>
</Button> </Button>
</MotionFade>
} }
> >
<div className="space-y-6"> <div className="space-y-6">
<PortfolioSubnav active="projects" />
{searchParams?.success ? ( {searchParams?.success ? (
<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"> <p className="rounded-nested border border-status-success/30 bg-status-success/10 px-4 py-3 text-sm text-status-success">
{searchParams.success} {searchParams.success}
</p> </p>
</MotionFade>
) : null} ) : null}
{searchParams?.error ? ( {searchParams?.error ? (
<MotionFade delay={0.12}>
<p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive"> <p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{searchParams.error} {searchParams.error}
</p> </p>
</MotionFade>
) : null} ) : null}
<MotionFade delay={0.15}>
<AppCard> <AppCard>
<CardContent className="p-6"> <CardContent className="p-6">
<form className="grid gap-4 md:grid-cols-3"> <form className="grid gap-4 md:grid-cols-3">
<div className="space-y-2"> <div className="space-y-2">
<label htmlFor="category" className="text-sm font-medium text-foreground"> <label htmlFor="category" className="text-sm font-medium text-foreground">
Category {copy.category}
</label> </label>
<select <select
id="category" id="category"
@@ -108,7 +126,7 @@ export default async function RootPortfolioProjectsPage({
defaultValue={searchParams?.category ?? ""} defaultValue={searchParams?.category ?? ""}
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm" 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) => ( {categories.map((category) => (
<option key={category.id} value={category.id}> <option key={category.id} value={category.id}>
{category.name.de} {category.name.de}
@@ -119,7 +137,7 @@ export default async function RootPortfolioProjectsPage({
<div className="space-y-2"> <div className="space-y-2">
<label htmlFor="status" className="text-sm font-medium text-foreground"> <label htmlFor="status" className="text-sm font-medium text-foreground">
Status {copy.status}
</label> </label>
<select <select
id="status" id="status"
@@ -127,24 +145,26 @@ export default async function RootPortfolioProjectsPage({
defaultValue={selectedStatus} defaultValue={selectedStatus}
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm" 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="all">{copy.all}</option>
<option value="draft">Draft</option> <option value="draft">{copy.draft}</option>
<option value="published">Published</option> <option value="published">{copy.published}</option>
</select> </select>
</div> </div>
<div className="flex items-end"> <div className="flex items-end">
<Button type="submit" variant="outline"> <Button type="submit" variant="outline">
Filter {copy.filter}
</Button> </Button>
</div> </div>
</form> </form>
</CardContent> </CardContent>
</AppCard> </AppCard>
</MotionFade>
<div className="grid gap-4"> <div className="grid gap-4">
{projects.map((project) => ( {projects.map((project, index) => (
<AppCard key={project.id} interactive> <MotionFade key={project.id} delay={0.18 + index * 0.03}>
<AppCard interactive>
<CardHeader className="flex flex-row items-center justify-between gap-4"> <CardHeader className="flex flex-row items-center justify-between gap-4">
<div> <div>
<CardTitle>{getLocalizedValue(project.title, "de")}</CardTitle> <CardTitle>{getLocalizedValue(project.title, "de")}</CardTitle>
@@ -154,34 +174,37 @@ export default async function RootPortfolioProjectsPage({
</div> </div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> <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"> <span className="rounded-pill border border-border px-3 py-1">
{project.isPublished ? "Published" : "Draft"} {project.isPublished ? copy.published : copy.draft}
</span> </span>
<span className="rounded-pill border border-border px-3 py-1"> <span className="rounded-pill border border-border px-3 py-1">
{project.projectYear} {project.projectYear}
</span> </span>
<span className="rounded-pill border border-border px-3 py-1"> <span className="rounded-pill border border-border px-3 py-1">
Sort {project.sortOrder} {copy.sort} {project.sortOrder}
</span> </span>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="flex flex-wrap items-center justify-between gap-4"> <CardContent className="flex flex-wrap items-center justify-between gap-4">
<div className="space-y-1 text-sm text-muted-foreground"> <div className="space-y-1 text-sm text-muted-foreground">
<p>{project.slug}</p> <p>{project.slug}</p>
<p>{project.previewUrl ? "Preview link set" : "No preview link"}</p> <p>{project.previewUrl ? copy.previewSet : copy.previewMissing}</p>
</div> </div>
<Button asChild> <Button asChild>
<Link href={`/root/portfolio/projects/${project.id}`}>Edit Project</Link> <Link href={`/root/portfolio/projects/${project.id}`}>{copy.editProject}</Link>
</Button> </Button>
</CardContent> </CardContent>
</AppCard> </AppCard>
</MotionFade>
))} ))}
{projects.length === 0 ? ( {projects.length === 0 ? (
<MotionFade delay={0.18}>
<AppCard> <AppCard>
<CardContent className="p-6 text-sm text-muted-foreground"> <CardContent className="p-6 text-sm text-muted-foreground">
No projects match the selected filters. {copy.empty}
</CardContent> </CardContent>
</AppCard> </AppCard>
</MotionFade>
) : null} ) : null}
</div> </div>
</div> </div>
+11 -40
View File
@@ -1,15 +1,8 @@
import { ArrowLeft, LogOut } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { AppHeader } from "@/components/layout/app-header"; import { MotionFade } from "@/components/motion-fade";
import { AppShell } from "@/components/layout/app-shell"; import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
import { AppSidebar } from "@/components/layout/app-sidebar";
import { ThemeToggle } from "@/components/theme-toggle";
import { getLocalizedPath } from "@/lib/locale";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; 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"; import { UiKitShowcase } from "@/components/ui/ui-kit-showcase";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -40,39 +33,17 @@ export default async function RootUiKitPage() {
redirect("/root"); redirect("/root");
} }
const sidebarItems = getRootNavigation(copy, "ui-kit");
return ( return (
<AppShell <RootDashboardShell
sidebar={ copy={copy}
<AppSidebar active="ui-kit"
title={copy.title} logoutAction={logoutAction}
description={copy.subtitle} headerTitle={copy.title}
items={sidebarItems} headerDescription={copy.subtitle}
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} />}
> >
<MotionFade delay={0.1}>
<UiKitShowcase localeKey="de" /> <UiKitShowcase localeKey="de" />
</AppShell> </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>
);
}
+46 -29
View File
@@ -27,14 +27,13 @@ export function AppSidebar({
items, items,
footer, footer,
}: AppSidebarProps) { }: AppSidebarProps) {
return ( const portfolioItem = items.find((item) => item.href === "/root/portfolio");
<AppCard className="sticky top-6 overflow-hidden bg-sidebar text-sidebar-foreground shadow-sidebar"> const systemOrder = ["/root", "/root/media", "/root/maintenance", "/root/ui-kit"];
<CardHeader className="pb-4"> const systemItems = items
<CardTitle className="text-base font-semibold">{title}</CardTitle> .filter((item) => item.href !== "/root/portfolio")
<p className="text-sm text-sidebar-foreground/72">{description}</p> .sort((left, right) => systemOrder.indexOf(left.href) - systemOrder.indexOf(right.href));
</CardHeader>
<CardContent className="space-y-1 px-3 pb-3 pt-0"> function renderItem(item: SidebarItem, nested = false) {
{items.map((item) => {
const Icon = item.icon; const Icon = item.icon;
return ( return (
@@ -46,6 +45,7 @@ export function AppSidebar({
item.active item.active
? "bg-sidebar-primary text-sidebar-primary-foreground" ? "bg-sidebar-primary text-sidebar-primary-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-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" /> <Icon className="h-4 w-4" />
@@ -54,32 +54,49 @@ export function AppSidebar({
{item.children?.length ? ( {item.children?.length ? (
<div className="ml-3 space-y-1 border-l border-sidebar-border pl-3"> <div className="ml-3 space-y-1 border-l border-sidebar-border pl-3">
{item.children.map((child) => { {item.children.map((child) => renderItem(child, true))}
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> </div>
) : null} ) : null}
</div> </div>
); );
})} }
</CardContent>
return (
<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} {footer ? <div className="border-t border-sidebar-border px-4 py-3">{footer}</div> : null}
</AppCard> </AppCard>
<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>
{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> <AppCard>
<CardHeader> <CardHeader>
<CardTitle>Project Basics</CardTitle> <CardTitle>Projekt Basisdaten</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2"> <CardContent className="grid gap-4 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="categoryId">Category</Label> <Label htmlFor="categoryId">Kategorie</Label>
<select <select
id="categoryId" id="categoryId"
name="categoryId" name="categoryId"
@@ -225,7 +225,7 @@ export function PortfolioProjectForm({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="clientName">Client Name</Label> <Label htmlFor="clientName">Kundenname</Label>
<Input <Input
id="clientName" id="clientName"
name="clientName" name="clientName"
@@ -235,7 +235,7 @@ export function PortfolioProjectForm({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="projectYear">Project Year</Label> <Label htmlFor="projectYear">Projektjahr</Label>
<Input <Input
id="projectYear" id="projectYear"
name="projectYear" name="projectYear"
@@ -259,7 +259,7 @@ export function PortfolioProjectForm({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="sortOrder">Sort Order</Label> <Label htmlFor="sortOrder">Sortierung</Label>
<Input <Input
id="sortOrder" id="sortOrder"
name="sortOrder" name="sortOrder"
@@ -272,7 +272,7 @@ export function PortfolioProjectForm({
<div className="space-y-2 md:col-span-2"> <div className="space-y-2 md:col-span-2">
<MediaFieldPicker <MediaFieldPicker
title="Cover Image" title="Cover Bild"
value={coverMedia} value={coverMedia}
onChange={setCoverMedia} onChange={setCoverMedia}
options={mediaOptions} 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"> <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} /> <input type="checkbox" name="isFeatured" defaultChecked={project?.isFeatured ?? false} />
Featured Hervorgehoben
</label> </label>
<label className="flex items-center gap-3 rounded-nested border border-border px-4 py-3 text-sm"> <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} /> <input type="checkbox" name="isPublished" defaultChecked={project?.isPublished ?? false} />
Published Veroeffentlicht
</label> </label>
</CardContent> </CardContent>
</AppCard> </AppCard>
<AppCard> <AppCard>
<CardHeader> <CardHeader>
<CardTitle>Localized Content</CardTitle> <CardTitle>Lokalisierte Inhalte</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Tabs defaultValue="de"> <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="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"> <div className="md:col-span-2 text-sm text-muted-foreground">
{locale.key === "ar" {locale.key === "ar"
? "Arabic content for the Arabic website." ? "Arabische Inhalte fuer die arabische Website."
: locale.key === "en" : locale.key === "en"
? "English content for the English website." ? "Englische Inhalte fuer die englische Website."
: "German content for the German website."} : "Deutsche Inhalte fuer die deutsche Website."}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={`title${locale.suffix}`}>{`Title ${locale.label}`}</Label> <Label htmlFor={`title${locale.suffix}`}>{`Titel ${locale.label}`}</Label>
<Input <Input
id={`title${locale.suffix}`} id={`title${locale.suffix}`}
name={`title${locale.suffix}`} name={`title${locale.suffix}`}
@@ -327,7 +327,7 @@ export function PortfolioProjectForm({
/> />
</div> </div>
<div className="space-y-2"> <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 <Input
id={`serviceLabel${locale.suffix}`} id={`serviceLabel${locale.suffix}`}
name={`serviceLabel${locale.suffix}`} name={`serviceLabel${locale.suffix}`}
@@ -336,7 +336,7 @@ export function PortfolioProjectForm({
/> />
</div> </div>
<div className="space-y-2 md:col-span-2"> <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 <Textarea
id={`summary${locale.suffix}`} id={`summary${locale.suffix}`}
name={`summary${locale.suffix}`} name={`summary${locale.suffix}`}
@@ -354,14 +354,14 @@ export function PortfolioProjectForm({
<AppCard> <AppCard>
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Sections</CardTitle> <CardTitle>Abschnitte</CardTitle>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={() => setSections((current) => [...current, createEmptySection(current.length)])} onClick={() => setSections((current) => [...current, createEmptySection(current.length)])}
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add Section Abschnitt hinzufuegen
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
@@ -369,7 +369,7 @@ export function PortfolioProjectForm({
<AppCard key={section.id ?? `${section.type}-${index}`} level={2}> <AppCard key={section.id ?? `${section.type}-${index}`} level={2}>
<CardContent className="space-y-4 p-4"> <CardContent className="space-y-4 p-4">
<div className="flex items-center justify-between gap-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"> <div className="flex gap-2">
<Button <Button
type="button" type="button"
@@ -401,14 +401,14 @@ export function PortfolioProjectForm({
disabled={sections.length === 1} disabled={sections.length === 1}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
Remove Entfernen
</Button> </Button>
</div> </div>
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label>Type</Label> <Label>Typ</Label>
<select <select
value={section.type} value={section.type}
onChange={(event) => onChange={(event) =>
@@ -432,7 +432,7 @@ export function PortfolioProjectForm({
<div className="space-y-2 md:col-span-2"> <div className="space-y-2 md:col-span-2">
<MediaFieldPicker <MediaFieldPicker
title="Section Image" title="Abschnitt Bild"
value={section.media} value={section.media}
onChange={(media) => onChange={(media) =>
setSections((current) => setSections((current) =>
@@ -470,7 +470,7 @@ export function PortfolioProjectForm({
{localeFieldConfig.map((locale) => ( {localeFieldConfig.map((locale) => (
<div key={`${locale.key}-title-${index}`} className="space-y-2"> <div key={`${locale.key}-title-${index}`} className="space-y-2">
<Label>{`Title ${locale.label}`}</Label> <Label>{`Titel ${locale.label}`}</Label>
<Input <Input
value={section[`title${locale.suffix}` as keyof SectionFormValue] as string} value={section[`title${locale.suffix}` as keyof SectionFormValue] as string}
onChange={(event) => onChange={(event) =>
@@ -491,7 +491,7 @@ export function PortfolioProjectForm({
{localeFieldConfig.map((locale) => ( {localeFieldConfig.map((locale) => (
<div key={`${locale.key}-body-${index}`} className="space-y-2 md:col-span-2"> <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 <Textarea
rows={4} rows={4}
value={section[`body${locale.suffix}` as keyof SectionFormValue] as string} value={section[`body${locale.suffix}` as keyof SectionFormValue] as string}
@@ -519,26 +519,26 @@ export function PortfolioProjectForm({
<AppCard> <AppCard>
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Assets</CardTitle> <CardTitle>Dateien</CardTitle>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={() => setAssets((current) => [...current, createEmptyAsset(current.length)])} onClick={() => setAssets((current) => [...current, createEmptyAsset(current.length)])}
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add Asset Datei hinzufuegen
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{assets.length === 0 ? ( {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} ) : null}
{assets.map((asset, index) => ( {assets.map((asset, index) => (
<AppCard key={asset.id ?? `${asset.kind}-${index}`} level={2}> <AppCard key={asset.id ?? `${asset.kind}-${index}`} level={2}>
<CardContent className="space-y-4 p-4"> <CardContent className="space-y-4 p-4">
<div className="flex items-center justify-between gap-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"> <div className="flex gap-2">
<Button <Button
type="button" type="button"
@@ -565,14 +565,14 @@ export function PortfolioProjectForm({
} }
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
Remove Entfernen
</Button> </Button>
</div> </div>
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label>Kind</Label> <Label>Typ</Label>
<select <select
value={asset.kind} value={asset.kind}
onChange={(event) => onChange={(event) =>
@@ -603,7 +603,7 @@ export function PortfolioProjectForm({
<div className="space-y-2 md:col-span-2"> <div className="space-y-2 md:col-span-2">
<MediaFieldPicker <MediaFieldPicker
title="Asset File" title="Datei"
value={asset.media} value={asset.media}
onChange={(media) => onChange={(media) =>
setAssets((current) => setAssets((current) =>
+9 -5
View File
@@ -1,5 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { AppCard } from "@/components/ui/app-card";
import { CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type PortfolioSubnavProps = { type PortfolioSubnavProps = {
@@ -9,24 +11,25 @@ type PortfolioSubnavProps = {
const items = [ const items = [
{ {
key: "overview", key: "overview",
label: "Overview", label: "Uebersicht",
href: "/root/portfolio", href: "/root/portfolio",
}, },
{ {
key: "categories", key: "categories",
label: "Categories", label: "Kategorien",
href: "/root/portfolio/categories", href: "/root/portfolio/categories",
}, },
{ {
key: "projects", key: "projects",
label: "Projects", label: "Projekte",
href: "/root/portfolio/projects", href: "/root/portfolio/projects",
}, },
] as const; ] as const;
export function PortfolioSubnav({ active }: PortfolioSubnavProps) { export function PortfolioSubnav({ active }: PortfolioSubnavProps) {
return ( return (
<div className="flex flex-wrap gap-2"> <AppCard level={2}>
<CardContent className="flex flex-wrap gap-2 p-4">
{items.map((item) => ( {items.map((item) => (
<Link <Link
key={item.key} key={item.key}
@@ -41,6 +44,7 @@ export function PortfolioSubnav({ active }: PortfolioSubnavProps) {
{item.label} {item.label}
</Link> </Link>
))} ))}
</div> </CardContent>
</AppCard>
); );
} }
+76 -28
View File
@@ -1,10 +1,19 @@
import type { ReactNode } from "react"; 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 Link from "next/link";
import { AppHeader } from "@/components/layout/app-header"; import { DashboardLayout } from "@/components/dashboard/dashboard-layout";
import { AppShell } from "@/components/layout/app-shell"; import { MotionFade } from "@/components/motion-fade";
import { AppSidebar } from "@/components/layout/app-sidebar";
import { ThemeToggle } from "@/components/theme-toggle"; import { ThemeToggle } from "@/components/theme-toggle";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { getLocalizedPath } from "@/lib/locale"; import { getLocalizedPath } from "@/lib/locale";
@@ -30,6 +39,7 @@ type RootDashboardShellProps = {
headerTitle: string; headerTitle: string;
headerDescription: string; headerDescription: string;
headerActions?: ReactNode; headerActions?: ReactNode;
toolbar?: ReactNode;
children: ReactNode; children: ReactNode;
}; };
@@ -41,47 +51,85 @@ export function RootDashboardShell({
headerTitle, headerTitle,
headerDescription, headerDescription,
headerActions, headerActions,
toolbar,
children, children,
}: RootDashboardShellProps) { }: RootDashboardShellProps) {
const sidebarItems = getRootNavigation(copy, active, portfolioChild); const sidebarItems = getRootNavigation(copy, active, portfolioChild).filter(
(item) => item.href !== "/root/maintenance" && item.href !== "/root/ui-kit",
return ( );
<AppShell const headerIcon =
sidebar={ active === "overview"
<AppSidebar ? LayoutDashboard
title={copy.title} : active === "maintenance"
description={copy.subtitle} ? ShieldAlert
items={sidebarItems} : active === "ui-kit"
footer={ ? SwatchBook
<div className="space-y-2"> : active === "media"
<div className="flex items-center gap-2"> ? ImageIcon
: portfolioChild === "categories"
? Tags
: portfolioChild === "new-project"
? PlusSquare
: FolderKanban;
const sharedActions = (
<>
<ThemeToggle ariaLabel="Theme wechseln" /> <ThemeToggle ariaLabel="Theme wechseln" />
</div> <Button asChild variant="outline" size="sm">
<Button asChild variant="outline" className="w-full justify-start">
<Link href={getLocalizedPath("de")}> <Link href={getLocalizedPath("de")}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
{copy.backToSite} {copy.backToSite}
</Link> </Link>
</Button> </Button>
<form action={logoutAction}> <form action={logoutAction}>
<Button type="submit" variant="destructive" className="w-full justify-start"> <Button type="submit" variant="ghost" size="sm" className="text-destructive hover:bg-destructive/10 hover:text-destructive">
<LogOut className="h-4 w-4" /> <LogOut className="h-4 w-4" />
{copy.logout} {copy.logout}
</Button> </Button>
</form> </form>
</div> </>
} );
/>
} return (
header={ <DashboardLayout
<AppHeader
title={headerTitle} title={headerTitle}
description={headerDescription} description={headerDescription}
actions={headerActions} 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>
</>
}
headerActions={
<div className="flex flex-wrap items-center justify-end gap-2">
{headerActions}
{sharedActions}
</div>
} }
> >
<div className="space-y-6">
{toolbar ? <MotionFade delay={0.05}>{toolbar}</MotionFade> : null}
{children} {children}
</AppShell> </div>
</DashboardLayout>
); );
} }
+28 -4
View File
@@ -4,13 +4,22 @@ import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useEffect, useState } from "react"; 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 = { type ThemeToggleProps = {
ariaLabel?: string; 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 { setTheme, theme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
@@ -20,8 +29,15 @@ export function ThemeToggle({ ariaLabel = "Toggle theme" }: ThemeToggleProps) {
if (!mounted) { if (!mounted) {
return ( 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" /> <Moon className="h-4 w-4" />
{label ? <span>{label}</span> : null}
</Button> </Button>
); );
} }
@@ -30,8 +46,16 @@ export function ThemeToggle({ ariaLabel = "Toggle theme" }: ThemeToggleProps) {
const isDark = activeTheme === "dark"; const isDark = activeTheme === "dark";
return ( 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" />} {isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
{label ? <span>{label}</span> : null}
</Button> </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"> <TabsContent value="cards">
<UiKitSection title={copy.cards} description={copy.cardsDesc}> <UiKitSection title={copy.cards} description={copy.cardsDesc}>
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6">
<AppCard level={1}> <AppCard level={1}>
<CardContent className="space-y-4 p-6"> <CardContent className="space-y-4 p-6">
<div> <div>
@@ -307,7 +307,7 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
<TabsContent value="buttons"> <TabsContent value="buttons">
<UiKitSection title={copy.buttons} description={copy.buttonsDesc}> <UiKitSection title={copy.buttons} description={copy.buttonsDesc}>
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6">
<AppCard level={2}> <AppCard level={2}>
<CardContent className="flex flex-wrap gap-3 p-5"> <CardContent className="flex flex-wrap gap-3 p-5">
<Button>{copy.default}</Button> <Button>{copy.default}</Button>
@@ -335,7 +335,7 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
<TabsContent value="inputs"> <TabsContent value="inputs">
<UiKitSection title={copy.inputs} description={copy.inputsDesc}> <UiKitSection title={copy.inputs} description={copy.inputsDesc}>
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6">
<AppCard level={2}> <AppCard level={2}>
<CardContent className="grid gap-4 p-5"> <CardContent className="grid gap-4 p-5">
<Label className="grid gap-2"> <Label className="grid gap-2">
@@ -431,7 +431,7 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
<TabsContent value="surfaces"> <TabsContent value="surfaces">
<UiKitSection title={copy.surfaces} description={copy.surfacesDesc}> <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}> <AppCard level={1}>
<CardContent className="p-5"> <CardContent className="p-5">
<p className="font-medium">{copy.surfaceOne}</p> <p className="font-medium">{copy.surfaceOne}</p>
@@ -489,7 +489,7 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
<TabsContent value="headers"> <TabsContent value="headers">
<UiKitSection title={copy.headers} description={copy.headersDesc}> <UiKitSection title={copy.headers} description={copy.headersDesc}>
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6">
<AppCard level={3}> <AppCard level={3}>
<CardContent className="space-y-3 p-6"> <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"> <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": { "dependencies": {
"@prisma/adapter-pg": "^7.4.2", "@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^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", "@radix-ui/react-slot": "^1.2.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -659,6 +661,44 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "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": { "node_modules/@formatjs/ecma402-abstract": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-3.1.1.tgz", "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" "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": { "node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", "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": { "node_modules/@radix-ui/react-slot": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", "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": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.59.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", "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", "version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^18.0.0" "@types/react": "^18.0.0"
@@ -3057,6 +3705,18 @@
"dev": true, "dev": true,
"license": "Python-2.0" "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": { "node_modules/aria-query": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
@@ -3928,6 +4588,12 @@
"node": ">=8" "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": { "node_modules/didyoumean": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -5055,6 +5721,15 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/get-port-please": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz",
@@ -7528,6 +8203,75 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -8860,6 +9604,27 @@
"punycode": "^2.1.0" "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": { "node_modules/use-intl": {
"version": "4.8.3", "version": "4.8.3",
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.8.3.tgz", "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" "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": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+2
View File
@@ -19,6 +19,8 @@
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.4.2", "@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^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", "@radix-ui/react-slot": "^1.2.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",