Refine root UI around shadcn primitives
CI / quality (push) Waiting to run

This commit is contained in:
MOH
2026-03-07 22:28:52 +01:00
parent 985fbe1745
commit a18370d003
36 changed files with 1344 additions and 597 deletions
+1 -1
View File
@@ -101,7 +101,7 @@ export default async function AboutPage({ params: { locale } }: AboutPageProps)
{[t("processOne"), t("processTwo"), t("processThree")].map((step, index) => (
<AppCard key={step} level={2}>
<CardContent className="p-4">
<span className="mb-2 inline-flex h-6 w-6 items-center justify-center rounded-pill bg-surface-1 text-xs font-semibold text-foreground/80">
<span className="mb-2 inline-flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-semibold text-foreground/80">
{index + 1}
</span>
<p className="text-sm text-foreground/80">{step}</p>
+2 -2
View File
@@ -189,11 +189,11 @@ export default async function HomePage({ params: { locale } }: HomePageProps) {
<CardContent className="p-6 lg:p-8">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="inline-flex items-center gap-2 text-sm font-medium text-surface-inverse-foreground/80">
<p className="inline-flex items-center gap-2 text-sm font-medium text-primary-foreground/80">
<Mail className="h-4 w-4" />
{t("ctaTitle")}
</p>
<p className="mt-2 max-w-2xl text-sm text-surface-inverse-foreground/88 sm:text-base">
<p className="mt-2 max-w-2xl text-sm text-primary-foreground/88 sm:text-base">
{t("ctaText")}
</p>
</div>
@@ -106,7 +106,7 @@ export default async function PortfolioItemPage({
</p>
{item.coverImagePath ? (
<div className="mt-6 overflow-hidden rounded-surface border border-border">
<div className="mt-6 overflow-hidden rounded-lg border border-border bg-card">
<PortfolioImage
src={item.coverImagePath}
alt={getLocalizedValue(item.title, localeKey)}
@@ -180,7 +180,7 @@ export default async function PortfolioItemPage({
alt={getLocalizedValue(section.title, localeKey)}
width={1200}
height={720}
className="mt-4 h-48 w-full rounded-nested object-cover"
className="mt-4 h-48 w-full rounded-md object-cover"
/>
) : null}
{section.linkUrl ? (
@@ -204,7 +204,7 @@ export default async function PortfolioItemPage({
<h2 className="text-xl font-semibold text-foreground">{t("gallery")}</h2>
<div className="mt-4 grid gap-4 md:grid-cols-2">
{item.assets.map((asset) => (
<div key={asset.id} className="overflow-hidden rounded-surface border border-border">
<div key={asset.id} className="overflow-hidden rounded-lg border border-border bg-card">
{asset.kind === "IMAGE" ? (
<PortfolioImage
src={asset.filePath}
@@ -214,7 +214,7 @@ export default async function PortfolioItemPage({
className="h-64 w-full object-cover"
/>
) : (
<div className="flex h-64 items-center justify-center bg-surface-1 p-6 text-center text-sm text-muted-foreground">
<div className="flex h-64 items-center justify-center bg-muted/40 p-6 text-center text-sm text-muted-foreground">
<div className="space-y-3">
<p>{getLocalizedValue(asset.alt, localeKey)}</p>
<Button asChild variant="outline">
+6 -6
View File
@@ -75,10 +75,10 @@ export default async function PortfolioPage({
<section className="flex flex-wrap gap-3">
<Link
href={getLocalizedPath(localeKey, "/portfolio")}
className={`rounded-pill border px-4 py-2 text-sm transition-colors ${
className={`rounded-md border px-4 py-2 text-sm transition-colors ${
selectedCategory === ""
? "border-border-strong bg-foreground text-background"
: "border-border bg-background text-foreground/80 hover:border-border-strong hover:text-foreground"
? "border-input bg-primary text-primary-foreground"
: "border-input bg-background text-foreground/80 hover:bg-accent hover:text-accent-foreground"
}`}
>
{t("all")}
@@ -87,10 +87,10 @@ export default async function PortfolioPage({
<Link
key={category.id}
href={getLocalizedPath(localeKey, `/portfolio?category=${category.slug}`)}
className={`rounded-pill border px-4 py-2 text-sm transition-colors ${
className={`rounded-md border px-4 py-2 text-sm transition-colors ${
selectedCategory === category.slug
? "border-border-strong bg-foreground text-background"
: "border-border bg-background text-foreground/80 hover:border-border-strong hover:text-foreground"
? "border-input bg-primary text-primary-foreground"
: "border-input bg-background text-foreground/80 hover:bg-accent hover:text-accent-foreground"
}`}
>
{getLocalizedValue(category.name, localeKey)}
+1 -1
View File
@@ -121,7 +121,7 @@ export default async function ProductPage({ params: { locale, slug } }: ProductP
{[t("stepOne"), t("stepTwo"), t("stepThree")].map((step, index) => (
<AppCard key={step} level={2}>
<CardContent className="p-4">
<span className="mb-2 inline-flex h-6 w-6 items-center justify-center rounded-pill bg-surface-1 text-xs font-semibold text-foreground/80">
<span className="mb-2 inline-flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-semibold text-foreground/80">
{index + 1}
</span>
<p className="text-sm text-foreground/80">{step}</p>
+2 -2
View File
@@ -48,14 +48,14 @@ export default async function ComingSoonPage({
<MotionFade className="w-full">
<AppCard
level={3}
className="mx-auto w-full max-w-4xl overflow-hidden border-border/70 bg-surface-1/88 shadow-none"
className="mx-auto w-full max-w-4xl overflow-hidden border-border/70 bg-card/95 shadow-sm"
>
<CardContent className="relative p-6 text-center sm:p-10 lg:p-14">
<div className="pointer-events-none absolute -right-20 -top-20 h-48 w-48 rounded-full bg-brand-secondary/15 blur-3xl sm:h-64 sm:w-64" />
<div className="pointer-events-none absolute -bottom-24 -left-16 h-56 w-56 rounded-full bg-brand-primary/15 blur-3xl sm:h-72 sm:w-72" />
<div className="relative">
<p className="inline-flex 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">
<p className="inline-flex items-center gap-2 rounded-full border border-input bg-background px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-foreground/80">
<Sparkles className="h-3.5 w-3.5 text-brand-secondary" />
{t("badge")}
</p>
+54 -69
View File
@@ -3,23 +3,23 @@
@tailwind utilities;
:root {
--background: 210 20% 98%;
--foreground: 222 30% 12%;
--background: 0 0% 100%;
--foreground: 224 71.4% 4.1%;
--card: 0 0% 100%;
--card-foreground: 222 30% 12%;
--card-foreground: 224 71.4% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 222 30% 12%;
--popover-foreground: 224 71.4% 4.1%;
--primary: 214 84% 42%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 24% 94%;
--secondary-foreground: 222 30% 12%;
--muted: 210 20% 95%;
--muted-foreground: 215 16% 38%;
--accent: 191 78% 42%;
--accent-foreground: 210 40% 98%;
--destructive: 0 72% 47%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--surface-1: 0 0% 100%;
@@ -28,10 +28,10 @@
--surface-inverse: 222 34% 14%;
--surface-inverse-foreground: 210 40% 98%;
--border: 214 22% 88%;
--border-strong: 214 20% 80%;
--input: 214 20% 84%;
--ring: 214 84% 42%;
--border: 214.3 31.8% 91.4%;
--border-strong: 214.3 31.8% 85%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--status-success: 145 63% 42%;
--status-success-soft: 145 58% 92%;
@@ -40,20 +40,20 @@
--brand-primary: 214 84% 42%;
--brand-secondary: 191 78% 42%;
--ambient-primary-alpha: 0.18;
--ambient-secondary-alpha: 0.14;
--ambient-grid-alpha: 0.4;
--ambient-primary-alpha: 0.08;
--ambient-secondary-alpha: 0.06;
--ambient-grid-alpha: 0;
--radius-surface: 0rem;
--radius-nested: 0rem;
--radius-pill: 9999px;
--radius-pill: 0px;
--shadow-xs: 0 1px 2px hsl(220 26% 18% / 0.05);
--shadow-sm: 0 12px 30px -20px hsl(220 32% 18% / 0.16);
--shadow-md: 0 22px 48px -28px hsl(220 32% 18% / 0.2);
--shadow-lg: 0 28px 70px -34px hsl(220 32% 18% / 0.28);
--shadow-card: 0 20px 46px -30px hsl(220 32% 18% / 0.2);
--shadow-panel: 0 18px 50px -32px hsl(220 32% 18% / 0.18);
--shadow-sm: 0 1px 3px hsl(220 26% 18% / 0.1), 0 1px 2px -1px hsl(220 26% 18% / 0.1);
--shadow-md: 0 4px 6px -1px hsl(220 26% 18% / 0.1), 0 2px 4px -2px hsl(220 26% 18% / 0.1);
--shadow-lg: 0 10px 15px -3px hsl(220 26% 18% / 0.12), 0 4px 6px -4px hsl(220 26% 18% / 0.12);
--shadow-card: 0 1px 3px hsl(220 26% 18% / 0.1), 0 1px 2px -1px hsl(220 26% 18% / 0.08);
--shadow-panel: 0 4px 6px -1px hsl(220 26% 18% / 0.1), 0 2px 4px -2px hsl(220 26% 18% / 0.08);
--shadow-sidebar: inset 0 1px 0 hsl(0 0% 100% / 0.7);
--focus-ring: 0 0 0 3px hsl(var(--ring) / 0.18);
@@ -77,23 +77,23 @@
}
.dark {
--background: 222 33% 8%;
--foreground: 210 25% 92%;
--background: 224 71.4% 4.1%;
--foreground: 210 20% 98%;
--card: 222 28% 11%;
--card-foreground: 210 25% 92%;
--popover: 222 28% 11%;
--popover-foreground: 210 25% 92%;
--card: 224 71.4% 4.1%;
--card-foreground: 210 20% 98%;
--popover: 224 71.4% 4.1%;
--popover-foreground: 210 20% 98%;
--primary: 205 88% 64%;
--primary-foreground: 222 33% 8%;
--secondary: 222 18% 18%;
--secondary-foreground: 210 25% 92%;
--muted: 222 18% 16%;
--muted-foreground: 215 16% 72%;
--accent: 190 76% 57%;
--accent-foreground: 222 33% 8%;
--destructive: 0 72% 58%;
--primary: 210 20% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 215 27.9% 16.9%;
--secondary-foreground: 210 20% 98%;
--muted: 215 27.9% 16.9%;
--muted-foreground: 217.9 10.6% 64.9%;
--accent: 215 27.9% 16.9%;
--accent-foreground: 210 20% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--surface-1: 222 28% 11%;
@@ -102,10 +102,10 @@
--surface-inverse: 210 40% 98%;
--surface-inverse-foreground: 222 33% 8%;
--border: 221 18% 21%;
--border-strong: 221 18% 28%;
--input: 221 18% 26%;
--ring: 205 88% 64%;
--border: 215 27.9% 16.9%;
--border-strong: 215 20.2% 25%;
--input: 215 27.9% 16.9%;
--ring: 216 12.2% 83.9%;
--status-success: 145 68% 66%;
--status-success-soft: 145 36% 18%;
@@ -114,16 +114,16 @@
--brand-primary: 205 88% 64%;
--brand-secondary: 190 76% 57%;
--ambient-primary-alpha: 0.22;
--ambient-secondary-alpha: 0.18;
--ambient-grid-alpha: 0.32;
--ambient-primary-alpha: 0.08;
--ambient-secondary-alpha: 0.06;
--ambient-grid-alpha: 0;
--shadow-xs: 0 1px 2px hsl(220 50% 2% / 0.42);
--shadow-sm: 0 14px 30px -20px hsl(220 50% 2% / 0.48);
--shadow-md: 0 24px 48px -28px hsl(220 50% 2% / 0.58);
--shadow-lg: 0 34px 78px -34px hsl(220 50% 2% / 0.72);
--shadow-card: 0 20px 52px -28px hsl(220 50% 2% / 0.55);
--shadow-panel: 0 22px 56px -32px hsl(220 50% 2% / 0.52);
--shadow-xs: 0 1px 2px hsl(220 50% 2% / 0.32);
--shadow-sm: 0 1px 3px hsl(220 50% 2% / 0.28), 0 1px 2px -1px hsl(220 50% 2% / 0.28);
--shadow-md: 0 4px 6px -1px hsl(220 50% 2% / 0.32), 0 2px 4px -2px hsl(220 50% 2% / 0.32);
--shadow-lg: 0 10px 15px -3px hsl(220 50% 2% / 0.36), 0 4px 6px -4px hsl(220 50% 2% / 0.36);
--shadow-card: 0 1px 3px hsl(220 50% 2% / 0.28), 0 1px 2px -1px hsl(220 50% 2% / 0.28);
--shadow-panel: 0 4px 6px -1px hsl(220 50% 2% / 0.32), 0 2px 4px -2px hsl(220 50% 2% / 0.32);
--shadow-sidebar: inset 0 1px 0 hsl(210 25% 92% / 0.06);
--sidebar-background: 222 25% 10%;
@@ -153,22 +153,7 @@ html.dark {
body {
@apply min-h-screen text-foreground antialiased;
background-color: hsl(var(--background));
background-image:
radial-gradient(
circle at top,
hsl(var(--brand-primary) / var(--ambient-primary-alpha)),
transparent 34%
),
radial-gradient(
circle at bottom right,
hsl(var(--brand-secondary) / var(--ambient-secondary-alpha)),
transparent 30%
),
linear-gradient(hsl(var(--border) / var(--ambient-grid-alpha)) 1px, transparent 1px),
linear-gradient(90deg, hsl(var(--border) / var(--ambient-grid-alpha)) 1px, transparent 1px);
background-size: auto, auto, 24px 24px, 24px 24px;
background-position: center top, right bottom, center center, center center;
background-attachment: fixed;
background-image: none;
}
a {
+47 -20
View File
@@ -1,6 +1,6 @@
/* eslint-disable @next/next/no-img-element */
import { ExternalLink, ImageIcon, Trash2 } from "lucide-react";
import { ExternalLink, FileType2, ImageIcon, Link2, Tag, Trash2, Upload } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
@@ -12,6 +12,13 @@ import { Button } from "@/components/ui/button";
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import { getAdminMediaAssets } from "@/lib/media";
@@ -94,31 +101,51 @@ export default async function RootMediaPage({ searchParams }: RootMediaPageProps
<CardContent>
<form action={createMediaAssetAction} className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="label">{copy.label}</Label>
<Input id="label" name="label" required />
<Label htmlFor="label" className="sr-only">
{copy.label}
</Label>
<div className="relative">
<Tag className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input id="label" name="label" required placeholder={copy.label} className="pl-9" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="kind">{copy.kind}</Label>
<select
id="kind"
name="kind"
defaultValue="IMAGE"
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
>
<option value="IMAGE">IMAGE</option>
<option value="DOCUMENT">DOCUMENT</option>
</select>
<Label htmlFor="kind" className="sr-only">
{copy.kind}
</Label>
<div className="relative">
<FileType2 className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Select name="kind" defaultValue="IMAGE">
<SelectTrigger id="kind" className="pl-9">
<SelectValue placeholder={copy.kind} />
</SelectTrigger>
<SelectContent>
<SelectItem value="IMAGE">IMAGE</SelectItem>
<SelectItem value="DOCUMENT">DOCUMENT</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="file">{copy.uploadFile}</Label>
<Input id="file" name="file" type="file" accept="image/*,.svg,.pdf" />
<Label htmlFor="file" className="sr-only">
{copy.uploadFile}
</Label>
<div className="relative">
<Upload className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input id="file" name="file" type="file" accept="image/*,.svg,.pdf" className="pl-9 file:mr-3" />
</div>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="externalUrl">{copy.externalUrl}</Label>
<Input id="externalUrl" name="externalUrl" placeholder="https://example.com/image.jpg" />
<Label htmlFor="externalUrl" className="sr-only">
{copy.externalUrl}
</Label>
<div className="relative">
<Link2 className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input id="externalUrl" name="externalUrl" placeholder={copy.externalUrl} className="pl-9" />
</div>
</div>
<div className="md:col-span-2">
@@ -146,11 +173,11 @@ export default async function RootMediaPage({ searchParams }: RootMediaPageProps
</CardHeader>
<CardContent className="space-y-4">
{asset.kind === "IMAGE" ? (
<div className="overflow-hidden rounded-surface border border-border bg-surface-1">
<div className="overflow-hidden rounded-lg border bg-card">
<img src={asset.url} alt={asset.label} className="h-48 w-full object-cover" />
</div>
) : (
<div className="rounded-surface border border-border bg-surface-1 px-4 py-6 text-sm text-muted-foreground">
<div className="rounded-lg border bg-card px-4 py-6 text-sm text-muted-foreground">
{asset.fileName}
</div>
)}
@@ -166,7 +193,7 @@ export default async function RootMediaPage({ searchParams }: RootMediaPageProps
</div>
{asset.usages.length > 0 ? (
<div className="space-y-2 rounded-nested border border-border bg-surface-1 px-4 py-3 text-xs text-muted-foreground">
<div className="space-y-2 rounded-md border bg-muted/40 px-4 py-3 text-xs text-muted-foreground">
{asset.usages.map((usage) => (
<p key={usage.id}>
{usage.usageType} / {usage.entityType} / {usage.fieldKey}
+20 -6
View File
@@ -171,32 +171,46 @@ export default async function RootPage({ searchParams }: RootPageProps) {
</CardHeader>
<CardContent>
{!authConfigured ? (
<p className="mb-4 rounded-nested border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<p className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{copy.configMissing}
</p>
) : null}
{!basicConfigured ? (
<p className="mb-4 rounded-nested border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<p className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{copy.basicAuthMissing}
</p>
) : null}
{searchParams?.error === "invalid" ? (
<p className="mb-4 rounded-nested border border-status-warning/30 bg-status-warning-soft px-3 py-2 text-sm text-status-warning">
<p className="mb-4 rounded-md border border-status-warning/30 bg-status-warning-soft px-3 py-2 text-sm text-status-warning">
{copy.invalidLogin}
</p>
) : null}
{searchParams?.error === "locked" || lockState.locked ? (
<p className="mb-4 rounded-nested border border-status-warning/30 bg-status-warning-soft px-3 py-2 text-sm text-status-warning">
<p className="mb-4 rounded-md border border-status-warning/30 bg-status-warning-soft px-3 py-2 text-sm text-status-warning">
{copy.lockedLogin}
</p>
) : null}
<form action={loginAction} className="space-y-3">
<Label htmlFor="password">{copy.passwordLabel}</Label>
<Input id="password" type="password" name="password" required />
<div className="space-y-2">
<Label htmlFor="password" className="sr-only">
{copy.passwordLabel}
</Label>
<div className="relative">
<LockKeyhole className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="password"
type="password"
name="password"
required
placeholder={copy.passwordLabel}
className="pl-9"
/>
</div>
</div>
<Button
type="submit"
disabled={!authConfigured || !basicConfigured || lockState.locked}
+52 -18
View File
@@ -1,4 +1,5 @@
import { redirect } from "next/navigation";
import { FileText, Hash, Text } from "lucide-react";
import { MotionFade } from "@/components/motion-fade";
import { FlashMessage } from "@/components/root/flash-message";
@@ -7,6 +8,7 @@ import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
import { AppCard } from "@/components/ui/app-card";
import { Button } from "@/components/ui/button";
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -106,12 +108,18 @@ export default async function RootPortfolioCategoriesPage({
>
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
<div className="space-y-2">
<Label htmlFor="create-slug">Slug</Label>
<Input id="create-slug" name="slug" required />
<Label htmlFor="create-slug" className="sr-only">Slug</Label>
<div className="relative">
<Text className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input id="create-slug" name="slug" required placeholder="Slug" className="pl-9" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="create-sortOrder">{copy.sortOrder}</Label>
<Input id="create-sortOrder" name="sortOrder" type="number" min="0" defaultValue="0" required />
<Label htmlFor="create-sortOrder" className="sr-only">{copy.sortOrder}</Label>
<div className="relative">
<Hash className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input id="create-sortOrder" name="sortOrder" type="number" min="0" defaultValue="0" required placeholder={copy.sortOrder} className="pl-9" />
</div>
</div>
<div className="md:col-span-2">
@@ -126,29 +134,37 @@ export default async function RootPortfolioCategoriesPage({
{locales.map((locale) => (
<TabsContent key={locale.key} value={locale.key}>
<div className="grid gap-4 rounded-surface border border-border p-4 md:grid-cols-2">
<div className="grid gap-4 rounded-lg border bg-card p-4 shadow-sm md:grid-cols-2">
<div className="md:col-span-2 text-sm text-muted-foreground">{locale.hint}</div>
<div className="space-y-2">
<Label htmlFor={`create-name-${locale.key}`}>{`Name ${locale.label}`}</Label>
<Input id={`create-name-${locale.key}`} name={`name${locale.key}`} required />
<Label htmlFor={`create-name-${locale.key}`} className="sr-only">{`Name ${locale.label}`}</Label>
<div className="relative">
<Text className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input id={`create-name-${locale.key}`} name={`name${locale.key}`} required placeholder={`Name ${locale.label}`} className="pl-9" />
</div>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`create-description-${locale.key}`}>{`${copy.description} ${locale.label}`}</Label>
<Label htmlFor={`create-description-${locale.key}`} className="sr-only">{`${copy.description} ${locale.label}`}</Label>
<div className="relative">
<FileText className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Textarea
id={`create-description-${locale.key}`}
name={`description${locale.key}`}
rows={4}
required
placeholder={`${copy.description} ${locale.label}`}
className="pl-9"
/>
</div>
</div>
</div>
</TabsContent>
))}
</Tabs>
</div>
<label className="flex items-center gap-3 rounded-nested border border-border px-4 py-3 text-sm md:col-span-2">
<input type="checkbox" name="isActive" defaultChecked />
<label className="flex items-center gap-3 rounded-md border border-input bg-card px-4 py-3 text-sm md:col-span-2">
<Checkbox name="isActive" defaultChecked />
{copy.active}
</label>
@@ -175,12 +191,17 @@ export default async function RootPortfolioCategoriesPage({
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
<div className="space-y-2">
<Label htmlFor={`slug-${category.id}`}>Slug</Label>
<Input id={`slug-${category.id}`} name="slug" defaultValue={category.slug} required />
<Label htmlFor={`slug-${category.id}`} className="sr-only">Slug</Label>
<div className="relative">
<Text className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input id={`slug-${category.id}`} name="slug" defaultValue={category.slug} required placeholder="Slug" className="pl-9" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`sortOrder-${category.id}`}>{copy.sortOrder}</Label>
<Label htmlFor={`sortOrder-${category.id}`} className="sr-only">{copy.sortOrder}</Label>
<div className="relative">
<Hash className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id={`sortOrder-${category.id}`}
name="sortOrder"
@@ -188,8 +209,11 @@ export default async function RootPortfolioCategoriesPage({
min="0"
defaultValue={category.sortOrder}
required
placeholder={copy.sortOrder}
className="pl-9"
/>
</div>
</div>
<div className="md:col-span-2">
<Tabs defaultValue="Ar">
@@ -208,36 +232,46 @@ export default async function RootPortfolioCategoriesPage({
return (
<TabsContent key={`${category.id}-content-${locale.key}`} value={locale.key}>
<div className="grid gap-4 rounded-surface border border-border p-4 md:grid-cols-2">
<div className="grid gap-4 rounded-lg border bg-card p-4 shadow-sm md:grid-cols-2">
<div className="md:col-span-2 text-sm text-muted-foreground">{locale.hint}</div>
<div className="space-y-2">
<Label htmlFor={`${nameKey}-${category.id}`}>{`Name ${locale.label}`}</Label>
<Label htmlFor={`${nameKey}-${category.id}`} className="sr-only">{`Name ${locale.label}`}</Label>
<div className="relative">
<Text className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id={`${nameKey}-${category.id}`}
name={nameKey}
defaultValue={category.name[lowerLocale]}
required
placeholder={`Name ${locale.label}`}
className="pl-9"
/>
</div>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`${descriptionKey}-${category.id}`}>{`${copy.description} ${locale.label}`}</Label>
<Label htmlFor={`${descriptionKey}-${category.id}`} className="sr-only">{`${copy.description} ${locale.label}`}</Label>
<div className="relative">
<FileText className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Textarea
id={`${descriptionKey}-${category.id}`}
name={descriptionKey}
rows={4}
defaultValue={category.description[lowerLocale]}
required
placeholder={`${copy.description} ${locale.label}`}
className="pl-9"
/>
</div>
</div>
</div>
</TabsContent>
);
})}
</Tabs>
</div>
<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} />
<label className="flex items-center gap-3 rounded-md border border-input bg-card px-4 py-3 text-sm">
<Checkbox name="isActive" defaultChecked={category.isActive} />
{copy.active}
</label>
+13 -192
View File
@@ -1,26 +1,17 @@
import { Boxes, ExternalLink, FolderKanban, Layers3, Plus, Tags } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
import { MotionFade } from "@/components/motion-fade";
import { FlashMessage } from "@/components/root/flash-message";
import { AppCard } from "@/components/ui/app-card";
import { Button } from "@/components/ui/button";
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PortfolioProjectsOverview } from "@/components/root/portfolio-projects-overview";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import {
getAdminPortfolioCategories,
getAdminPortfolioProjects,
getLocalizedValue,
} from "@/lib/portfolio";
import { getLocalizedPath } from "@/lib/locale";
import { getAdminPortfolioCategories, getAdminPortfolioProjects } from "@/lib/portfolio";
export const dynamic = "force-dynamic";
const copy = {
title: "Portfolio",
subtitle: "Verwaltung fuer Kategorien, Projekte und Inhalte.",
subtitle: "Zentrale Steuerung fuer Projekte, Inhalte und Medien.",
overview: "Uebersicht",
maintenance: "Wartungsmodus",
uiKit: "UI Kit",
@@ -29,23 +20,6 @@ const copy = {
portfolio: "Portfolio",
logout: "Ausloggen",
backToSite: "Zur Website",
totalCategories: "Kategorien",
totalProjects: "Projekte",
publishedProjects: "Veroeffentlicht",
newProject: "Neues Projekt",
newCategory: "Neue Kategorie",
category: "Kategorie",
all: "Alle",
status: "Status",
draft: "Entwurf",
published: "Veroeffentlicht",
filter: "Filtern",
sort: "Sortierung",
previewSet: "Preview Link gesetzt",
previewMissing: "Kein Preview Link",
editProject: "Projekt bearbeiten",
openProject: "Projekt ansehen",
empty: "Keine Projekte fuer die aktuellen Filter gefunden.",
};
type RootPortfolioPageProps = {
@@ -69,17 +43,19 @@ export default async function RootPortfolioPage({ searchParams }: RootPortfolioP
redirect("/root");
}
const selectedCategory = searchParams?.category && searchParams.category !== "__all__"
? searchParams.category
: "";
const selectedStatus = searchParams?.status === "draft" || searchParams?.status === "published"
? searchParams.status
: "all";
const [categories, projects] = await Promise.all([
getAdminPortfolioCategories(),
getAdminPortfolioProjects({
categoryId: searchParams?.category || undefined,
categoryId: selectedCategory || undefined,
status: selectedStatus,
}),
]);
const publishedProjects = projects.filter((project) => project.isPublished).length;
return (
<RootDashboardShell
@@ -89,22 +65,6 @@ export default async function RootPortfolioPage({ searchParams }: RootPortfolioP
logoutAction={logoutAction}
headerTitle={copy.title}
headerDescription={copy.subtitle}
headerActions={
<div className="flex flex-wrap gap-3">
<Button asChild>
<Link href="/root/portfolio/projects/new">
<Plus className="h-4 w-4" />
{copy.newProject}
</Link>
</Button>
<Button asChild variant="outline">
<Link href="/root/portfolio/categories">
<Tags className="h-4 w-4" />
{copy.newCategory}
</Link>
</Button>
</div>
}
>
<div className="space-y-6">
{searchParams?.success ? (
@@ -119,151 +79,12 @@ export default async function RootPortfolioPage({ searchParams }: RootPortfolioP
</MotionFade>
) : null}
<section className="grid gap-4 md:grid-cols-3">
{[
{
icon: Layers3,
label: copy.totalCategories,
value: categories.length,
},
{
icon: FolderKanban,
label: copy.totalProjects,
value: projects.length,
},
{
icon: Boxes,
label: copy.publishedProjects,
value: publishedProjects,
},
].map((item, index) => {
const Icon = item.icon;
return (
<MotionFade key={item.label} delay={index * 0.05}>
<AppCard level={2}>
<CardContent className="flex items-center gap-4 p-6">
<div className="flex h-12 w-12 items-center justify-center rounded-pill bg-surface-1">
<Icon className="h-5 w-5 text-brand-primary" />
</div>
<div>
<p className="text-sm text-muted-foreground">{item.label}</p>
<p className="text-3xl font-semibold text-foreground">{item.value}</p>
</div>
</CardContent>
</AppCard>
</MotionFade>
);
})}
</section>
<MotionFade delay={0.15}>
<AppCard>
<CardContent className="p-6">
<form className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<label htmlFor="category" className="text-sm font-medium text-foreground">
{copy.category}
</label>
<select
id="category"
name="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"
>
<option value="">{copy.all}</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name.de}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label htmlFor="status" className="text-sm font-medium text-foreground">
{copy.status}
</label>
<select
id="status"
name="status"
defaultValue={selectedStatus}
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
>
<option value="all">{copy.all}</option>
<option value="draft">{copy.draft}</option>
<option value="published">{copy.published}</option>
</select>
</div>
<div className="flex items-end">
<Button type="submit" variant="outline">
{copy.filter}
</Button>
</div>
</form>
</CardContent>
</AppCard>
</MotionFade>
<div className="grid gap-4">
{projects.map((project, index) => (
<MotionFade key={project.id} delay={0.18 + index * 0.03}>
<AppCard interactive>
<CardHeader className="flex flex-row items-center justify-between gap-4">
<div>
<CardTitle>{getLocalizedValue(project.title, "de")}</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">
{project.category.name.de}
</p>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="rounded-pill border border-border px-3 py-1">
{project.isPublished ? copy.published : copy.draft}
</span>
<span className="rounded-pill border border-border px-3 py-1">
{project.projectYear}
</span>
<span className="rounded-pill border border-border px-3 py-1">
{copy.sort} {project.sortOrder}
</span>
</div>
</CardHeader>
<CardContent className="flex flex-wrap items-center justify-between gap-4">
<div className="space-y-1 text-sm text-muted-foreground">
<p>{project.slug}</p>
<p>{project.previewUrl ? copy.previewSet : copy.previewMissing}</p>
</div>
<div className="flex flex-wrap gap-3">
<Button asChild variant="outline">
<Link
href={getLocalizedPath("de", `/portfolio/${project.slug}`)}
target="_blank"
rel="noreferrer"
>
<ExternalLink className="h-4 w-4" />
{copy.openProject}
</Link>
</Button>
<Button asChild>
<Link href={`/root/portfolio/projects/${project.id}`}>{copy.editProject}</Link>
</Button>
</div>
</CardContent>
</AppCard>
</MotionFade>
))}
{projects.length === 0 ? (
<MotionFade delay={0.18}>
<AppCard>
<CardContent className="p-6 text-sm text-muted-foreground">
{copy.empty}
</CardContent>
</AppCard>
</MotionFade>
) : null}
</div>
<PortfolioProjectsOverview
categories={categories}
projects={projects}
selectedCategory={selectedCategory}
selectedStatus={selectedStatus}
/>
</div>
</RootDashboardShell>
);
+70 -13
View File
@@ -1,7 +1,27 @@
import { redirect } from "next/navigation";
import { MotionFade } from "@/components/motion-fade";
import { FlashMessage } from "@/components/root/flash-message";
import { PortfolioProjectsOverview } from "@/components/root/portfolio-projects-overview";
import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import { getAdminPortfolioCategories, getAdminPortfolioProjects } from "@/lib/portfolio";
export const dynamic = "force-dynamic";
const copy = {
title: "Portfolio Projekte",
subtitle: "Projektliste und Einstieg in die komplette Bearbeitung.",
overview: "Uebersicht",
maintenance: "Wartungsmodus",
uiKit: "UI Kit",
media: "Media",
siteSettings: "SEO",
portfolio: "Portfolio",
logout: "Ausloggen",
backToSite: "Zur Website",
};
type RootPortfolioProjectsPageProps = {
searchParams?: {
category?: string;
@@ -14,23 +34,60 @@ type RootPortfolioProjectsPageProps = {
export default async function RootPortfolioProjectsPage({
searchParams,
}: RootPortfolioProjectsPageProps) {
const params = new URLSearchParams();
if (searchParams?.category) {
params.set("category", searchParams.category);
if (!isAdminAuthenticated()) {
redirect("/root");
}
if (searchParams?.status) {
params.set("status", searchParams.status);
async function logoutAction() {
"use server";
clearAdminSessionCookie();
redirect("/root");
}
if (searchParams?.success) {
params.set("success", searchParams.success);
}
const selectedCategory = searchParams?.category && searchParams.category !== "__all__"
? searchParams.category
: "";
const selectedStatus = searchParams?.status === "draft" || searchParams?.status === "published"
? searchParams.status
: "all";
const [categories, projects] = await Promise.all([
getAdminPortfolioCategories(),
getAdminPortfolioProjects({
categoryId: selectedCategory || undefined,
status: selectedStatus,
}),
]);
if (searchParams?.error) {
params.set("error", searchParams.error);
}
return (
<RootDashboardShell
copy={copy}
active="portfolio"
portfolioChild="projects"
logoutAction={logoutAction}
headerTitle={copy.title}
headerDescription={copy.subtitle}
>
<div className="space-y-6">
{searchParams?.success ? (
<MotionFade delay={0.08}>
<FlashMessage type="success" message={searchParams.success} />
</MotionFade>
) : null}
redirect(params.toString() ? `/root/portfolio?${params.toString()}` : "/root/portfolio");
{searchParams?.error ? (
<MotionFade delay={0.1}>
<FlashMessage type="error" message={searchParams.error} />
</MotionFade>
) : null}
<PortfolioProjectsOverview
categories={categories}
projects={projects}
selectedCategory={selectedCategory}
selectedStatus={selectedStatus}
/>
</div>
</RootDashboardShell>
);
}
+1 -1
View File
@@ -41,7 +41,7 @@ export function AppSidebar({
<Link
href={item.href}
className={cn(
"flex items-center gap-2 rounded-nested px-3 py-2 text-sm transition-colors",
"flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors",
item.active
? "bg-sidebar-primary text-sidebar-primary-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
+1 -1
View File
@@ -20,7 +20,7 @@ export function FloatingPreferences({
return (
<div className={cn("fixed inset-x-0 top-0 z-50 px-4 pt-4 sm:px-6", className)}>
<div className="mx-auto flex w-full max-w-max items-center justify-center gap-2">
<ThemeToggle ariaLabel={t("themeToggle")} className="bg-surface-1/90" />
<ThemeToggle ariaLabel={t("themeToggle")} className="bg-background/90" />
<LocaleToggle locale={locale} />
</div>
</div>
+1 -1
View File
@@ -22,7 +22,7 @@ export function SiteFooter({ isAdmin = false }: SiteFooterProps) {
const tFooter = useTranslations("footer");
return (
<footer className="border-t border-border bg-surface-2">
<footer className="border-t border-border bg-background">
<Container className="flex flex-col gap-4 py-8">
<nav className="flex flex-wrap gap-x-5 gap-y-3 text-sm">
{navItems.map((item) => (
+2 -2
View File
@@ -71,7 +71,7 @@ export function SiteHeader({ isAdmin = false }: SiteHeaderProps) {
<div className="flex items-center gap-4">
<Link
href={getLocalizedPath(locale)}
className="inline-flex items-center rounded-nested border border-border bg-surface-1 px-3 py-2 shadow-xs"
className="inline-flex items-center rounded-md border border-input bg-background px-3 py-2 shadow-sm"
>
<Image
src="/logos/light-primary.svg"
@@ -115,7 +115,7 @@ export function SiteHeader({ isAdmin = false }: SiteHeaderProps) {
</Container>
{isOpen ? (
<div className="border-t border-border/80 bg-surface-1 md:hidden">
<div className="border-t border-border/80 bg-background md:hidden">
<Container className="py-4">
<nav className="flex flex-col gap-3">
<NavLinks isAdmin={isAdmin} onNavigate={() => setIsOpen(false)} />
+1 -1
View File
@@ -55,7 +55,7 @@ export function FlashMessage({
return (
<p
className={cn(
"rounded-nested border px-4 py-3 text-sm",
"rounded-md border px-4 py-3 text-sm",
type === "success"
? "border-status-success/30 bg-status-success/10 text-status-success"
: "border-destructive/30 bg-destructive/10 text-destructive",
+32 -21
View File
@@ -3,7 +3,7 @@
/* eslint-disable @next/next/no-img-element */
import type { MediaKind } from "@prisma/client";
import { Check, Search } from "lucide-react";
import { Check, Link2, Search, Type, Upload } from "lucide-react";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import type { MediaOption } from "@/lib/media";
@@ -110,9 +110,9 @@ export function MediaFieldPicker({
}, [serializedValue]);
return (
<div className="space-y-3 rounded-surface border border-border p-4">
<div className="space-y-3 rounded-lg border bg-card p-4 shadow-sm">
<div className="space-y-2">
<Label>{title}</Label>
<Label className="sr-only">{title}</Label>
<div className="flex flex-wrap gap-2">
{modeOptions.map((mode) => (
<button
@@ -128,10 +128,10 @@ export function MediaFieldPicker({
})
}
className={cn(
"rounded-pill border px-4 py-2 text-sm transition-colors",
"rounded-md border px-4 py-2 text-sm transition-colors",
value.mode === mode
? "border-border-strong bg-foreground text-background"
: "border-border bg-background text-foreground/75 hover:border-border-strong hover:text-foreground",
? "border-input bg-primary text-primary-foreground"
: "border-input bg-background text-foreground/75 hover:bg-accent hover:text-accent-foreground",
)}
>
{mode}
@@ -150,7 +150,7 @@ export function MediaFieldPicker({
isCleared: true,
})
}
className="rounded-pill border border-destructive/25 px-4 py-2 text-sm text-destructive transition-colors hover:border-destructive/50 hover:bg-destructive/5"
className="rounded-md border border-destructive/25 px-4 py-2 text-sm text-destructive transition-colors hover:bg-destructive/5"
>
{clearLabel}
</button>
@@ -167,37 +167,48 @@ export function MediaFieldPicker({
{value.mode !== "library" ? (
<div className="space-y-2">
<Label>Label</Label>
<Label className="sr-only">Label</Label>
<div className="relative">
<Type className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={value.label}
onChange={(event) => onChange({ ...value, label: event.target.value, isCleared: false })}
placeholder="Homepage Hero"
placeholder="Label"
className="pl-9"
/>
</div>
</div>
) : null}
{value.mode === "upload" ? (
<div className="space-y-2">
<Label>{fileLabel ?? fileFieldName}</Label>
<Input name={fileFieldName} type="file" accept={accept} />
<Label className="sr-only">{fileLabel ?? fileFieldName}</Label>
<div className="relative">
<Upload className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input name={fileFieldName} type="file" accept={accept} className="pl-9 file:mr-3" />
</div>
</div>
) : null}
{value.mode === "external" ? (
<div className="space-y-2">
<Label>{externalLabel ?? "External URL"}</Label>
<Label className="sr-only">{externalLabel ?? "External URL"}</Label>
<div className="relative">
<Link2 className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={value.url}
onChange={(event) => onChange({ ...value, url: event.target.value, isCleared: false })}
placeholder="https://example.com/image.jpg"
placeholder={externalLabel ?? "External URL"}
className="pl-9"
/>
</div>
</div>
) : null}
{value.mode === "library" ? (
<div className="space-y-3">
<Label>{libraryLabel ?? "Media Library"}</Label>
<div className="space-y-2 rounded-nested border border-border bg-surface-1 p-3">
<Label className="sr-only">{libraryLabel ?? "Media Library"}</Label>
<div className="space-y-2 rounded-md border bg-card p-3">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
@@ -227,10 +238,10 @@ export function MediaFieldPicker({
})
}
className={cn(
"flex w-full items-center gap-3 rounded-nested border px-3 py-2 text-left transition-colors",
"flex w-full items-center gap-3 rounded-md border px-3 py-2 text-left transition-colors",
isActive
? "border-border-strong bg-background"
: "border-border bg-background/70 hover:border-border-strong hover:bg-background",
? "border-input bg-accent/40"
: "border-input bg-background hover:bg-accent/20",
)}
>
{option.url ? (
@@ -247,7 +258,7 @@ export function MediaFieldPicker({
);
})
) : (
<div className="rounded-nested border border-dashed border-border px-3 py-4 text-sm text-muted-foreground">
<div className="rounded-md border border-dashed border-input px-3 py-4 text-sm text-muted-foreground">
No media found.
</div>
)}
@@ -258,11 +269,11 @@ export function MediaFieldPicker({
{previewUrl ? (
value.kind === "IMAGE" ? (
<div className="overflow-hidden rounded-nested border border-border bg-surface-1">
<div className="overflow-hidden rounded-md border bg-card">
<img src={previewUrl} alt={value.label || title} className="h-40 w-full object-cover" />
</div>
) : (
<div className="rounded-nested border border-border bg-surface-1 px-4 py-3 text-sm text-muted-foreground">
<div className="rounded-md border bg-card px-4 py-3 text-sm text-muted-foreground">
{previewUrl}
</div>
)
+113 -34
View File
@@ -1,15 +1,34 @@
"use client";
import type { MediaKind, PortfolioAssetKind, PortfolioSectionType } from "@prisma/client";
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
import {
ArrowDown,
ArrowUp,
BriefcaseBusiness,
CalendarRange,
FolderTree,
Hash,
Link2,
Plus,
Sparkles,
Trash2,
} from "lucide-react";
import { useState } from "react";
import { MediaFieldPicker, type MediaFieldState } from "@/components/root/media-field-picker";
import { AppCard } from "@/components/ui/app-card";
import { Button } from "@/components/ui/button";
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { moveArrayItem } from "@/lib/array";
@@ -176,6 +195,8 @@ export function PortfolioProjectForm({
}))
: [],
);
const [isFeatured, setIsFeatured] = useState(project?.isFeatured ?? false);
const [isPublished, setIsPublished] = useState(project?.isPublished ?? false);
const sectionsPayload = JSON.stringify(
sections.map((section, index) => ({
@@ -205,39 +226,62 @@ export function PortfolioProjectForm({
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="categoryId">Kategorie</Label>
<select
id="categoryId"
<Label htmlFor="categoryId" className="sr-only">Kategorie</Label>
<div className="relative">
<FolderTree className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Select
name="categoryId"
defaultValue={project?.category.id ?? categories[0]?.id ?? ""}
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
required
>
<SelectTrigger id="categoryId" className="pl-9">
<SelectValue placeholder="Kategorie" />
</SelectTrigger>
<SelectContent>
{categories.map((category) => (
<option key={category.id} value={category.id}>
<SelectItem key={category.id} value={category.id}>
{category.name.de} / {category.name.en}
</option>
</SelectItem>
))}
</select>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="slug">Slug</Label>
<Input id="slug" name="slug" defaultValue={project?.slug ?? ""} required />
<Label htmlFor="slug" className="sr-only">Slug</Label>
<div className="relative">
<Hash className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="slug"
name="slug"
defaultValue={project?.slug ?? ""}
placeholder="Slug"
className="pl-9"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="clientName">Kundenname</Label>
<Label htmlFor="clientName" className="sr-only">Kundenname</Label>
<div className="relative">
<BriefcaseBusiness className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="clientName"
name="clientName"
defaultValue={project?.clientName ?? ""}
placeholder="Kundenname"
className="pl-9"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="projectYear">Projektjahr</Label>
<Label htmlFor="projectYear" className="sr-only">Projektjahr</Label>
<div className="relative">
<CalendarRange className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="projectYear"
name="projectYear"
@@ -245,32 +289,44 @@ export function PortfolioProjectForm({
min="2000"
max="2100"
defaultValue={project?.projectYear ?? new Date().getFullYear()}
placeholder="Projektjahr"
className="pl-9"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="previewUrl">Preview URL</Label>
<Label htmlFor="previewUrl" className="sr-only">Preview URL</Label>
<div className="relative">
<Link2 className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="previewUrl"
name="previewUrl"
type="url"
defaultValue={project?.previewUrl ?? ""}
placeholder="https://example.com"
className="pl-9"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="sortOrder">Sortierung</Label>
<Label htmlFor="sortOrder" className="sr-only">Sortierung</Label>
<div className="relative">
<Hash className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="sortOrder"
name="sortOrder"
type="number"
min="0"
defaultValue={project?.sortOrder ?? 0}
placeholder="Sortierung"
className="pl-9"
required
/>
</div>
</div>
<div className="space-y-2 md:col-span-2">
<MediaFieldPicker
@@ -284,13 +340,23 @@ export function PortfolioProjectForm({
/>
</div>
<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} />
<label className="flex items-center gap-3 rounded-md border border-border px-4 py-3 text-sm">
<Checkbox
name="isFeatured"
checked={isFeatured}
onCheckedChange={(checked) => setIsFeatured(checked === true)}
/>
<Sparkles className="h-4 w-4 text-muted-foreground" />
Hervorgehoben
</label>
<label className="flex items-center gap-3 rounded-nested border border-border px-4 py-3 text-sm">
<input type="checkbox" name="isPublished" defaultChecked={project?.isPublished ?? false} />
<label className="flex items-center gap-3 rounded-md border border-border px-4 py-3 text-sm">
<Checkbox
name="isPublished"
checked={isPublished}
onCheckedChange={(checked) => setIsPublished(checked === true)}
/>
<Sparkles className="h-4 w-4 text-muted-foreground" />
Veroeffentlicht
</label>
</CardContent>
@@ -411,25 +477,29 @@ export function PortfolioProjectForm({
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Typ</Label>
<select
<Select
value={section.type}
onChange={(event) =>
onValueChange={(value) =>
setSections((current) =>
current.map((item, currentIndex) =>
currentIndex === index
? { ...item, type: event.target.value as PortfolioSectionType }
? { ...item, type: value as PortfolioSectionType }
: item,
),
)
}
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
>
<SelectTrigger>
<SelectValue placeholder="Typ" />
</SelectTrigger>
<SelectContent>
{sectionTypeOptions.map((type) => (
<option key={type} value={type}>
<SelectItem key={type} value={type}>
{type}
</option>
</SelectItem>
))}
</select>
</SelectContent>
</Select>
</div>
<div className="space-y-2 md:col-span-2">
@@ -457,7 +527,9 @@ export function PortfolioProjectForm({
</div>
<div className="space-y-2 md:col-span-2">
<Label>Link URL</Label>
<Label className="sr-only">Link URL</Label>
<div className="relative">
<Link2 className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={section.linkUrl}
onChange={(event) =>
@@ -467,8 +539,11 @@ export function PortfolioProjectForm({
),
)
}
placeholder="Link URL"
className="pl-9"
/>
</div>
</div>
{localeFieldConfig.map((locale) => (
<div key={`${locale.key}-title-${index}`} className="space-y-2">
@@ -575,32 +650,36 @@ export function PortfolioProjectForm({
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Typ</Label>
<select
<Select
value={asset.kind}
onChange={(event) =>
onValueChange={(value) =>
setAssets((current) =>
current.map((item, currentIndex) =>
currentIndex === index
? {
...item,
kind: event.target.value as PortfolioAssetKind,
kind: value as PortfolioAssetKind,
media: {
...item.media,
kind: event.target.value as PortfolioAssetKind,
kind: value as PortfolioAssetKind,
},
}
: item,
),
)
}
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
>
<SelectTrigger>
<SelectValue placeholder="Typ" />
</SelectTrigger>
<SelectContent>
{assetKindOptions.map((kind) => (
<option key={kind} value={kind}>
<SelectItem key={kind} value={kind}>
{kind}
</option>
</SelectItem>
))}
</select>
</SelectContent>
</Select>
</div>
<div className="space-y-2 md:col-span-2">
@@ -0,0 +1,316 @@
import { Boxes, ExternalLink, Filter, FolderKanban, Layers3, Plus, Tags } from "lucide-react";
import Link from "next/link";
import { MotionFade } from "@/components/motion-fade";
import { PortfolioSubnav } from "@/components/root/portfolio-subnav";
import { AppCard } from "@/components/ui/app-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { getLocalizedPath } from "@/lib/locale";
import { getLocalizedValue, type PortfolioCategoryView, type PortfolioProjectView } from "@/lib/portfolio";
type PortfolioProjectsOverviewProps = {
categories: PortfolioCategoryView[];
projects: PortfolioProjectView[];
selectedCategory: string;
selectedStatus: "all" | "draft" | "published";
};
const copy = {
summary: "Portfolio cockpit",
description: "Alle Projekte, Filter und Einstiegspunkte fuer die Bearbeitung an einem Ort.",
totalCategories: "Kategorien",
totalProjects: "Projekte",
publishedProjects: "Veroeffentlicht",
newProject: "Neues Projekt",
newCategory: "Neue Kategorie",
category: "Kategorie",
all: "Alle",
status: "Status",
draft: "Entwurf",
published: "Veroeffentlicht",
filter: "Filter anwenden",
empty: "Noch keine Projekte vorhanden. Lege das erste Projekt an und beginne direkt mit Inhalt, Abschnitten und Dateien.",
openProject: "Projekt ansehen",
editProject: "Projekt bearbeiten",
review: "Bearbeitungsstand",
reviewDone: "Bereit",
reviewMissing: "Fehlt etwas",
untitled: "Unbenanntes Projekt",
noCategory: "Ohne Kategorie",
noPreview: "Kein Preview Link",
hasPreview: "Preview Link vorhanden",
hasCover: "Cover gesetzt",
missingCover: "Cover fehlt",
hasSections: "Abschnitte vorhanden",
noSections: "Keine Abschnitte",
hasAssets: "Dateien vorhanden",
noAssets: "Keine Dateien",
};
function getProjectCompletion(project: PortfolioProjectView) {
const completedChecks = [
Boolean(project.slug.trim()),
Boolean(project.coverImagePath),
project.sections.length > 0,
project.assets.length > 0,
Boolean(project.title.ar.trim() && project.title.en.trim() && project.title.de.trim()),
Boolean(project.summary.ar.trim() && project.summary.en.trim() && project.summary.de.trim()),
].filter(Boolean).length;
return {
completedChecks,
totalChecks: 6,
ready: completedChecks === 6,
};
}
export function PortfolioProjectsOverview({
categories,
projects,
selectedCategory,
selectedStatus,
}: PortfolioProjectsOverviewProps) {
const publishedProjects = projects.filter((project) => project.isPublished).length;
return (
<div className="space-y-6">
<PortfolioSubnav active="projects" />
<MotionFade delay={0.05}>
<AppCard level={3}>
<CardContent className="flex flex-col gap-6 p-6 lg:flex-row lg:items-end lg:justify-between lg:p-8">
<div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
{copy.summary}
</p>
<div className="space-y-2">
<h2 className="text-2xl font-semibold text-foreground">Projektverwaltung</h2>
<p className="max-w-2xl text-sm text-muted-foreground">{copy.description}</p>
</div>
</div>
<div className="flex flex-wrap gap-3">
<Button asChild>
<Link href="/root/portfolio/projects/new">
<Plus className="h-4 w-4" />
{copy.newProject}
</Link>
</Button>
<Button asChild variant="outline">
<Link href="/root/portfolio/categories">
<Tags className="h-4 w-4" />
{copy.newCategory}
</Link>
</Button>
</div>
</CardContent>
</AppCard>
</MotionFade>
<section className="grid gap-4 md:grid-cols-3">
{[
{
icon: Layers3,
label: copy.totalCategories,
value: categories.length,
},
{
icon: FolderKanban,
label: copy.totalProjects,
value: projects.length,
},
{
icon: Boxes,
label: copy.publishedProjects,
value: publishedProjects,
},
].map((item, index) => {
const Icon = item.icon;
return (
<MotionFade key={item.label} delay={0.08 + index * 0.04}>
<AppCard level={2}>
<CardContent className="flex items-center gap-4 p-6">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<Icon className="h-5 w-5 text-brand-primary" />
</div>
<div>
<p className="text-sm text-muted-foreground">{item.label}</p>
<p className="text-3xl font-semibold text-foreground">{item.value}</p>
</div>
</CardContent>
</AppCard>
</MotionFade>
);
})}
</section>
<MotionFade delay={0.14}>
<AppCard>
<CardContent className="p-6">
<form className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
<div className="space-y-2">
<Label htmlFor="portfolio-filter-category" className="sr-only">
{copy.category}
</Label>
<div className="relative">
<Tags className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Select name="category" defaultValue={selectedCategory || "__all__"}>
<SelectTrigger id="portfolio-filter-category" className="pl-9">
<SelectValue placeholder={copy.category} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">{copy.all}</SelectItem>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name.de}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="portfolio-filter-status" className="sr-only">
{copy.status}
</Label>
<div className="relative">
<Filter className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Select name="status" defaultValue={selectedStatus}>
<SelectTrigger id="portfolio-filter-status" className="pl-9">
<SelectValue placeholder={copy.status} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{copy.all}</SelectItem>
<SelectItem value="draft">{copy.draft}</SelectItem>
<SelectItem value="published">{copy.published}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-end">
<Button type="submit" variant="outline">
{copy.filter}
</Button>
</div>
</form>
</CardContent>
</AppCard>
</MotionFade>
<div className="grid gap-4">
{projects.map((project, index) => {
const completion = getProjectCompletion(project);
return (
<MotionFade key={project.id} delay={0.18 + index * 0.03}>
<AppCard interactive>
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-xl">
{getLocalizedValue(project.title, "de") || copy.untitled}
</CardTitle>
<Badge variant={project.isPublished ? "success" : "warning"}>
{project.isPublished ? copy.published : copy.draft}
</Badge>
</div>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>{project.category.name.de || copy.noCategory}</span>
<span></span>
<span>{project.projectYear}</span>
<span></span>
<span>{project.slug}</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Badge variant={completion.ready ? "success" : "warning"}>
{copy.review}: {completion.completedChecks}/{completion.totalChecks}
</Badge>
<Badge variant="outline">
{completion.ready ? copy.reviewDone : copy.reviewMissing}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-5">
<p className="max-w-3xl text-sm text-muted-foreground">
{getLocalizedValue(project.summary, "de") || "Noch keine Kurzbeschreibung hinterlegt."}
</p>
<div className="flex flex-wrap gap-2">
<Badge variant={project.previewUrl ? "success" : "outline"}>
{project.previewUrl ? copy.hasPreview : copy.noPreview}
</Badge>
<Badge variant={project.coverImagePath ? "success" : "outline"}>
{project.coverImagePath ? copy.hasCover : copy.missingCover}
</Badge>
<Badge variant={project.sections.length > 0 ? "success" : "outline"}>
{project.sections.length > 0 ? copy.hasSections : copy.noSections}
</Badge>
<Badge variant={project.assets.length > 0 ? "success" : "outline"}>
{project.assets.length > 0 ? copy.hasAssets : copy.noAssets}
</Badge>
</div>
<div className="flex flex-wrap gap-3">
<Button asChild variant="outline">
<Link
href={getLocalizedPath("de", `/portfolio/${project.slug}`)}
target="_blank"
rel="noreferrer"
>
<ExternalLink className="h-4 w-4" />
{copy.openProject}
</Link>
</Button>
<Button asChild>
<Link href={`/root/portfolio/projects/${project.id}`}>{copy.editProject}</Link>
</Button>
</div>
</CardContent>
</AppCard>
</MotionFade>
);
})}
{projects.length === 0 ? (
<MotionFade delay={0.18}>
<AppCard>
<CardContent className="space-y-4 p-6">
<p className="text-sm text-muted-foreground">{copy.empty}</p>
<div className="flex flex-wrap gap-3">
<Button asChild>
<Link href="/root/portfolio/projects/new">
<Plus className="h-4 w-4" />
{copy.newProject}
</Link>
</Button>
<Button asChild variant="outline">
<Link href="/root/portfolio/categories">
<Tags className="h-4 w-4" />
{copy.newCategory}
</Link>
</Button>
</div>
</CardContent>
</AppCard>
</MotionFade>
) : null}
</div>
</div>
);
}
+6 -6
View File
@@ -5,13 +5,13 @@ import { CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
type PortfolioSubnavProps = {
active: "overview" | "categories";
active: "projects" | "categories";
};
const items = [
{
key: "overview",
label: "Uebersicht",
key: "projects",
label: "Projekte",
href: "/root/portfolio",
},
{
@@ -30,10 +30,10 @@ export function PortfolioSubnav({ active }: PortfolioSubnavProps) {
key={item.key}
href={item.href}
className={cn(
"rounded-pill border px-4 py-2 text-sm transition-colors",
"rounded-md border px-4 py-2 text-sm transition-colors",
active === item.key
? "border-border-strong bg-foreground text-background"
: "border-border bg-background text-foreground/75 hover:border-border-strong hover:text-foreground",
? "border-input bg-primary text-primary-foreground"
: "border-input bg-background text-foreground/75 hover:bg-accent hover:text-accent-foreground",
)}
>
{item.label}
@@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
type SidebarMaintenanceControlProps = {
@@ -33,10 +34,10 @@ export function SidebarMaintenanceControl({
<label
htmlFor="sidebar-maintenance-enabled"
className={cn(
"flex cursor-pointer items-center justify-between gap-3 rounded-nested border px-3 py-2 transition-colors",
"flex cursor-pointer items-center justify-between gap-3 rounded-md border px-3 py-2 transition-colors",
enabled
? "border-status-warning/40 bg-status-warning-soft/80"
: "border-border bg-surface-1 hover:bg-surface-2",
: "border-input bg-card hover:bg-accent/20",
)}
>
<span className="flex min-w-0 items-center gap-3">
@@ -63,11 +64,10 @@ export function SidebarMaintenanceControl({
</Badge>
</label>
<input
<Checkbox
id="sidebar-maintenance-enabled"
type="checkbox"
checked={enabled}
onChange={(event) => setEnabled(event.target.checked)}
onCheckedChange={(checked) => setEnabled(checked === true)}
className="sr-only"
/>
</form>
+31 -12
View File
@@ -3,7 +3,7 @@
/* eslint-disable @next/next/no-img-element */
import { MediaKind } from "@prisma/client";
import { ImageIcon, Search, Type } from "lucide-react";
import { FileText, ImageIcon, Search, Type } from "lucide-react";
import { useState } from "react";
import { MediaFieldPicker, type MediaFieldState } from "@/components/root/media-field-picker";
@@ -179,14 +179,16 @@ export function SiteSettingsForm({
</CardHeader>
<CardContent className="grid gap-4 lg:grid-cols-3">
{localeFields.map((locale) => (
<div key={locale.key} className="space-y-4 rounded-surface border border-border bg-surface-1 p-4">
<div key={locale.key} className="space-y-4 rounded-lg border bg-card p-4 shadow-sm">
<div>
<p className="text-sm font-medium text-foreground">{locale.label}</p>
<p className="mt-1 text-xs text-muted-foreground">Globale Standardwerte fuer diese Sprache.</p>
</div>
<div className="space-y-2">
<Label htmlFor={`siteName${locale.suffix}`}>Site Name</Label>
<Label htmlFor={`siteName${locale.suffix}`} className="sr-only">Site Name</Label>
<div className="relative">
<Type className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id={`siteName${locale.suffix}`}
name={`siteName${locale.suffix}`}
@@ -203,11 +205,16 @@ export function SiteSettingsForm({
},
}))
}
placeholder="Site Name"
className="pl-9"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`titleTemplate${locale.suffix}`}>Title Template</Label>
<Label htmlFor={`titleTemplate${locale.suffix}`} className="sr-only">Title Template</Label>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id={`titleTemplate${locale.suffix}`}
name={`titleTemplate${locale.suffix}`}
@@ -224,12 +231,16 @@ export function SiteSettingsForm({
},
}))
}
placeholder="{pageTitle} | {siteName}"
placeholder="Title Template"
className="pl-9"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`siteDescription${locale.suffix}`}>Site Description</Label>
<Label htmlFor={`siteDescription${locale.suffix}`} className="sr-only">Site Description</Label>
<div className="relative">
<FileText className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id={`siteDescription${locale.suffix}`}
name={`siteDescription${locale.suffix}`}
@@ -246,11 +257,16 @@ export function SiteSettingsForm({
},
}))
}
placeholder="Site Description"
className="pl-9"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`subhead${locale.suffix}`}>Subhead</Label>
<Label htmlFor={`subhead${locale.suffix}`} className="sr-only">Subhead</Label>
<div className="relative">
<Type className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id={`subhead${locale.suffix}`}
name={`subhead${locale.suffix}`}
@@ -267,9 +283,12 @@ export function SiteSettingsForm({
},
}))
}
placeholder="Subhead"
className="pl-9"
/>
</div>
</div>
</div>
))}
</CardContent>
</AppCard>
@@ -344,7 +363,7 @@ export function SiteSettingsForm({
: `${locale.sampleTitle} | ${localeSettings.siteName}`;
return (
<div key={locale.key} className="rounded-nested border border-border bg-background p-3">
<div key={locale.key} className="rounded-md border bg-card p-3">
<p className="font-medium text-foreground">{locale.label}</p>
<p className="mt-2 text-xs text-muted-foreground">
Example page title: {locale.sampleTitle}
@@ -365,12 +384,12 @@ export function SiteSettingsForm({
<Search className="h-4 w-4 text-brand-primary" />
Search Preview
</div>
<div className="rounded-nested border border-border bg-background p-4">
<div className="rounded-md border bg-card p-4">
<div className="flex items-center gap-3">
{faviconPreviewUrl ? (
<img src={faviconPreviewUrl} alt="Favicon" className="h-5 w-5 rounded-sm" />
) : (
<div className="flex h-5 w-5 items-center justify-center rounded-sm border border-border bg-surface-1">
<div className="flex h-5 w-5 items-center justify-center rounded-sm border bg-muted/40">
<Type className="h-3.5 w-3.5 text-muted-foreground" />
</div>
)}
@@ -394,11 +413,11 @@ export function SiteSettingsForm({
<ImageIcon className="h-4 w-4 text-brand-secondary" />
Social Preview
</div>
<div className="overflow-hidden rounded-nested border border-border bg-background">
<div className="overflow-hidden rounded-md border bg-card">
{defaultOgImagePreviewUrl ? (
<img src={defaultOgImagePreviewUrl} alt="Default OG" className="h-32 w-full object-cover" />
) : (
<div className="flex h-32 items-center justify-center bg-surface-1 text-sm text-muted-foreground">
<div className="flex h-32 items-center justify-center bg-muted/40 text-sm text-muted-foreground">
No OG image selected
</div>
)}
+6 -6
View File
@@ -5,14 +5,14 @@ import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils";
const appCardVariants = cva(
"rounded-surface border transition-all duration-200",
"rounded-lg transition-colors",
{
variants: {
level: {
1: "border-border bg-surface-1 text-foreground shadow-card",
2: "border-border bg-surface-2 text-foreground shadow-sm",
3: "border-border/90 bg-surface-3 text-foreground shadow-panel",
inverse: "border-transparent bg-surface-inverse text-surface-inverse-foreground shadow-lg",
1: "border bg-card text-card-foreground shadow-sm",
2: "border bg-card text-card-foreground shadow-sm",
3: "border bg-card text-card-foreground shadow-md",
inverse: "border-transparent bg-foreground text-background shadow-sm",
},
padding: {
none: "",
@@ -21,7 +21,7 @@ const appCardVariants = cva(
lg: "p-8",
},
interactive: {
true: "hover:-translate-y-0.5 hover:border-border-strong hover:shadow-md",
true: "hover:bg-accent/30 hover:text-accent-foreground",
false: "",
},
},
+1 -1
View File
@@ -10,7 +10,7 @@ const badgeVariants = cva(
variant: {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
outline: "border-border bg-surface-1 text-foreground",
outline: "border-input bg-background text-foreground",
success: "border-transparent bg-status-success-soft text-status-success",
warning: "border-transparent bg-status-warning-soft text-status-warning",
},
+5 -5
View File
@@ -5,16 +5,16 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-nested text-sm font-medium ring-offset-background transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-xs hover:brightness-95",
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
outline: "border border-border bg-surface-1 text-foreground shadow-xs hover:border-border-strong hover:bg-surface-2",
default: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "text-muted-foreground hover:bg-muted hover:text-foreground",
link: "rounded-none text-primary underline-offset-4 hover:underline",
destructive: "bg-destructive text-destructive-foreground shadow-xs hover:brightness-95",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
},
size: {
default: "h-10 px-4 py-2",
+1 -1
View File
@@ -7,7 +7,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
<div
ref={ref}
className={cn(
"rounded-surface border border-border bg-surface-1 text-card-foreground shadow-card",
"rounded-lg border bg-card text-card-foreground shadow-sm",
className,
)}
{...props}
+50
View File
@@ -0,0 +1,50 @@
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
type CheckboxProps = React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> & {
name?: string;
};
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
CheckboxProps
>(({ className, name, checked, defaultChecked, onCheckedChange, ...props }, ref) => {
const [internalChecked, setInternalChecked] = React.useState(defaultChecked === true);
const isControlled = checked !== undefined;
const currentChecked = isControlled ? checked === true : internalChecked;
return (
<>
{name ? <input type="hidden" name={name} value={currentChecked ? "on" : "false"} /> : null}
<CheckboxPrimitive.Root
ref={ref}
checked={checked}
defaultChecked={defaultChecked}
onCheckedChange={(nextChecked) => {
if (!isControlled) {
setInternalChecked(nextChecked === true);
}
onCheckedChange?.(nextChecked);
}}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
<Check className="h-3.5 w-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
</>
);
});
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };
+1 -1
View File
@@ -7,7 +7,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
<input
type={type}
className={cn(
"flex h-10 w-full rounded-nested border border-input bg-surface-1 px-3 py-2 text-sm text-foreground ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base text-foreground ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
+195
View File
@@ -0,0 +1,195 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
type SelectProps = React.ComponentPropsWithoutRef<typeof SelectPrimitive.Root> & {
name?: string;
required?: boolean;
};
function Select({
name,
required,
value,
defaultValue,
onValueChange,
children,
...props
}: SelectProps) {
const [internalValue, setInternalValue] = React.useState(defaultValue ?? "");
const isControlled = value !== undefined;
const currentValue = (isControlled ? value : internalValue) as string;
return (
<>
{name ? <input type="hidden" name={name} value={currentValue} required={required} /> : null}
<SelectPrimitive.Root
value={value}
defaultValue={defaultValue}
onValueChange={(nextValue) => {
if (!isControlled) {
setInternalValue(nextValue);
}
onValueChange?.(nextValue);
}}
{...props}
>
{children}
</SelectPrimitive.Root>
</>
);
}
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUp className="h-4 w-4 rotate-180" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground 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">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
+3 -3
View File
@@ -44,7 +44,7 @@ function TabsList({
return (
<div
className={cn(
"inline-flex h-auto flex-wrap items-center gap-2 rounded-surface border border-border bg-surface-2 p-1.5",
"inline-flex h-auto flex-wrap items-center gap-2 rounded-lg border bg-muted/40 p-1.5",
className,
)}
{...props}
@@ -69,8 +69,8 @@ function TabsTrigger({
<button
type="button"
className={cn(
"inline-flex items-center justify-center rounded-nested px-3 py-2 text-sm font-medium text-muted-foreground transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
isActive && "bg-surface-1 text-foreground shadow-xs",
"inline-flex items-center justify-center rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
isActive && "bg-background text-foreground shadow-sm",
className,
)}
onClick={(event) => {
+1 -1
View File
@@ -6,7 +6,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"tex
({ className, ...props }, ref) => (
<textarea
className={cn(
"flex min-h-[112px] w-full rounded-nested border border-input bg-surface-1 px-3 py-2 text-sm text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
+6 -6
View File
@@ -224,7 +224,7 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
<div className="flex flex-col gap-section">
<AppCard level={3}>
<CardContent className="flex flex-col gap-4 p-6 lg:p-10">
<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-full border border-input bg-background px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-foreground/80">
<Layers3 className="h-3.5 w-3.5 text-brand-secondary" />
{copy.title}
</div>
@@ -292,10 +292,10 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
</AppCard>
<AppCard level="inverse">
<CardContent className="p-5">
<p className="text-sm font-medium text-surface-inverse-foreground/80">
<p className="text-sm font-medium text-background/80">
{copy.inverseOuterCard}
</p>
<p className="mt-2 text-sm text-surface-inverse-foreground/88">
<p className="mt-2 text-sm text-background/88">
{copy.inverseOuterCardText}
</p>
</CardContent>
@@ -452,8 +452,8 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
</AppCard>
<AppCard level="inverse">
<CardContent className="p-5">
<p className="font-medium text-surface-inverse-foreground">{copy.inverse}</p>
<p className="mt-2 text-sm text-surface-inverse-foreground/88">{copy.inverseText}</p>
<p className="font-medium text-background">{copy.inverse}</p>
<p className="mt-2 text-sm text-background/88">{copy.inverseText}</p>
</CardContent>
</AppCard>
</div>
@@ -492,7 +492,7 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
<div className="grid gap-6">
<AppCard level={3}>
<CardContent className="space-y-3 p-6">
<div className="inline-flex w-fit items-center gap-2 rounded-pill border border-border bg-surface-1 px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-foreground/80">
<div className="inline-flex w-fit items-center gap-2 rounded-full border border-input bg-background px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-foreground/80">
<Type className="h-3.5 w-3.5 text-brand-primary" />
{copy.pageHeader}
</div>
+3 -3
View File
@@ -72,10 +72,10 @@ export function getRootNavigation(
expanded: active === "portfolio",
children: [
{
label: "Overview",
label: "Projects",
href: "/root/portfolio",
icon: LayoutDashboard,
active: portfolioChild === "overview",
icon: FolderKanban,
active: portfolioChild === "overview" || portfolioChild === "projects",
},
{
label: "Add Project",
+137
View File
@@ -10,8 +10,10 @@
"dependencies": {
"@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.2",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -1635,6 +1637,12 @@
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
"license": "MIT"
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
@@ -1664,6 +1672,36 @@
}
}
},
"node_modules/@radix-ui/react-checkbox": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
"integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==",
"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-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "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-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
@@ -2131,6 +2169,67 @@
}
}
},
"node_modules/@radix-ui/react-select": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
"integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@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-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-visually-hidden": "1.2.3",
"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-select/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-slot": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
@@ -2234,6 +2333,21 @@
}
}
},
"node_modules/@radix-ui/react-use-previous": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
"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",
@@ -2270,6 +2384,29 @@
}
}
},
"node_modules/@radix-ui/react-visually-hidden": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
"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/rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
+2
View File
@@ -19,8 +19,10 @@
"dependencies": {
"@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.2",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",