Refactor root admin dashboard UI

This commit is contained in:
MOH
2026-03-07 17:36:45 +01:00
parent ead75769ef
commit 8606f6e315
25 changed files with 2345 additions and 685 deletions
+34
View File
@@ -0,0 +1,34 @@
import type { LucideIcon } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
type DashboardCardProps = {
title: string;
value: string;
description: string;
icon: LucideIcon;
};
export function DashboardCard({
title,
value,
description,
icon: Icon,
}: DashboardCardProps) {
return (
<Card className="border-border/70 bg-card/95 shadow-sm">
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
<div className="space-y-1">
<CardDescription>{title}</CardDescription>
<CardTitle className="text-3xl font-semibold tracking-tight">{value}</CardTitle>
</div>
<div className="rounded-md border border-border bg-muted/60 p-2 text-muted-foreground">
<Icon className="h-4 w-4" />
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{description}</p>
</CardContent>
</Card>
);
}
+81
View File
@@ -0,0 +1,81 @@
import type { ReactNode } from "react";
import { Menu, type LucideIcon } from "lucide-react";
import { DashboardSidebar } from "@/components/dashboard/sidebar";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import type { RootNavItem } from "@/lib/root-navigation";
type DashboardLayoutProps = {
title: string;
description: string;
icon?: LucideIcon;
items: RootNavItem[];
sidebarFooter?: ReactNode;
headerActions?: ReactNode;
children: ReactNode;
};
export function DashboardLayout({
title,
description,
icon: Icon,
items,
sidebarFooter,
headerActions,
children,
}: DashboardLayoutProps) {
return (
<div className="min-h-screen bg-muted/30">
<div className="grid min-h-screen lg:grid-cols-[280px_minmax(0,1fr)]">
<aside className="hidden border-r border-border/70 bg-sidebar/60 lg:block">
<DashboardSidebar items={items} footer={sidebarFooter} />
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-30 flex h-16 items-center gap-3 border-b border-border/70 bg-background/95 px-4 backdrop-blur lg:px-6">
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" size="icon" className="lg:hidden">
<Menu className="h-4 w-4" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="p-0">
<SheetHeader className="sr-only">
<SheetTitle>{title}</SheetTitle>
<SheetDescription>{description}</SheetDescription>
</SheetHeader>
<DashboardSidebar items={items} footer={sidebarFooter} />
</SheetContent>
</Sheet>
<div className="flex min-w-0 flex-1 items-center gap-3">
{Icon ? (
<div className="hidden rounded-lg border border-border/70 bg-muted/50 p-2 text-muted-foreground sm:flex">
<Icon className="h-4 w-4" />
</div>
) : null}
<div className="min-w-0">
<p className="truncate text-lg font-semibold text-foreground">{title}</p>
<p className="truncate text-sm text-muted-foreground">{description}</p>
</div>
</div>
{headerActions ? (
<div className="flex items-center gap-2">{headerActions}</div>
) : null}
</header>
<main className="flex-1 p-4 lg:p-6">{children}</main>
</div>
</div>
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
import type { ReactNode } from "react";
import Link from "next/link";
import type { LucideIcon } from "lucide-react";
import { ShieldCheck } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
type DashboardSidebarItem = {
label: string;
href: string;
icon: LucideIcon;
active?: boolean;
children?: DashboardSidebarItem[];
};
type DashboardSidebarProps = {
items: DashboardSidebarItem[];
footer?: ReactNode;
};
export function DashboardSidebar({ items, footer }: DashboardSidebarProps) {
function renderItem(item: DashboardSidebarItem, nested = false) {
const Icon = item.icon;
return (
<div key={item.href} className="space-y-1">
<Link
href={item.href}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
item.active
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
nested ? "ml-4" : undefined,
)}
>
<Icon className="h-4 w-4" />
<span>{item.label}</span>
</Link>
{item.children?.length ? item.children.map((child) => renderItem(child, true)) : null}
</div>
);
}
return (
<div className="flex h-full flex-col bg-sidebar/40">
<div className="flex items-center gap-3 px-4 py-5">
<div className="rounded-xl bg-primary/10 p-2 text-primary">
<ShieldCheck className="h-5 w-5" />
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-foreground">Mohs Admin</p>
<p className="truncate text-xs text-muted-foreground">Website owner workspace</p>
</div>
</div>
<div className="px-4">
<div className="rounded-xl border border-border/70 bg-background/80 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-foreground">Private panel</p>
<p className="mt-1 text-xs text-muted-foreground">Single owner access</p>
</div>
<Badge variant="outline">Private</Badge>
</div>
</div>
</div>
<Separator className="my-4" />
<div className="flex-1 space-y-6 overflow-y-auto px-4 pb-4">
<nav className="space-y-1">{items.map((item) => renderItem(item))}</nav>
</div>
{footer ? (
<>
<Separator />
<div className="space-y-2 p-4">{footer}</div>
</>
) : null}
</div>
);
}
+68 -51
View File
@@ -27,59 +27,76 @@ export function AppSidebar({
items,
footer,
}: AppSidebarProps) {
const portfolioItem = items.find((item) => item.href === "/root/portfolio");
const systemOrder = ["/root", "/root/media", "/root/maintenance", "/root/ui-kit"];
const systemItems = items
.filter((item) => item.href !== "/root/portfolio")
.sort((left, right) => systemOrder.indexOf(left.href) - systemOrder.indexOf(right.href));
function renderItem(item: SidebarItem, nested = false) {
const Icon = item.icon;
return (
<div key={item.label} className="space-y-1">
<Link
href={item.href}
className={cn(
"flex items-center gap-2 rounded-nested px-3 py-2 text-sm transition-colors",
item.active
? "bg-sidebar-primary text-sidebar-primary-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
nested ? "text-sidebar-foreground/72" : undefined,
)}
>
<Icon className="h-4 w-4" />
<span>{item.label}</span>
</Link>
{item.children?.length ? (
<div className="ml-3 space-y-1 border-l border-sidebar-border pl-3">
{item.children.map((child) => renderItem(child, true))}
</div>
) : null}
</div>
);
}
return (
<AppCard className="sticky top-6 overflow-hidden bg-sidebar text-sidebar-foreground shadow-sidebar">
<CardHeader className="pb-4">
<CardTitle className="text-base font-semibold">{title}</CardTitle>
<p className="text-sm text-sidebar-foreground/72">{description}</p>
</CardHeader>
<CardContent className="space-y-1 px-3 pb-3 pt-0">
{items.map((item) => {
const Icon = item.icon;
<div className="sticky top-6 space-y-4">
<AppCard className="overflow-hidden bg-sidebar text-sidebar-foreground shadow-sidebar">
<CardHeader className="pb-4">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-sidebar-foreground/56">
Root Access
</p>
<CardTitle className="text-base font-semibold">{title}</CardTitle>
<p className="text-sm leading-6 text-sidebar-foreground/72">{description}</p>
</CardHeader>
{footer ? <div className="border-t border-sidebar-border px-4 py-3">{footer}</div> : null}
</AppCard>
return (
<div key={item.label} className="space-y-1">
<Link
href={item.href}
className={cn(
"flex items-center gap-2 rounded-nested px-3 py-2 text-sm transition-colors",
item.active
? "bg-sidebar-primary text-sidebar-primary-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
)}
>
<Icon className="h-4 w-4" />
<span>{item.label}</span>
</Link>
<AppCard className="overflow-hidden bg-sidebar text-sidebar-foreground shadow-sidebar">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold uppercase tracking-[0.2em] text-sidebar-foreground/72">
Overview
</CardTitle>
</CardHeader>
<CardContent className="space-y-1 px-3 pb-3 pt-0">
{systemItems.map((item) => renderItem(item))}
</CardContent>
</AppCard>
{item.children?.length ? (
<div className="ml-3 space-y-1 border-l border-sidebar-border pl-3">
{item.children.map((child) => {
const ChildIcon = child.icon;
return (
<Link
key={child.label}
href={child.href}
className={cn(
"flex items-center gap-2 rounded-nested px-3 py-2 text-sm transition-colors",
child.active
? "bg-sidebar-primary text-sidebar-primary-foreground"
: "text-sidebar-foreground/72 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
)}
>
<ChildIcon className="h-4 w-4" />
<span>{child.label}</span>
</Link>
);
})}
</div>
) : null}
</div>
);
})}
</CardContent>
{footer ? <div className="border-t border-sidebar-border px-4 py-3">{footer}</div> : null}
</AppCard>
{portfolioItem ? (
<AppCard className="overflow-hidden bg-sidebar text-sidebar-foreground shadow-sidebar">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold uppercase tracking-[0.2em] text-sidebar-foreground/72">
Portfolio
</CardTitle>
</CardHeader>
<CardContent className="space-y-1 px-3 pb-3 pt-0">
{renderItem(portfolioItem)}
</CardContent>
</AppCard>
) : null}
</div>
);
}
+30 -30
View File
@@ -199,11 +199,11 @@ export function PortfolioProjectForm({
<AppCard>
<CardHeader>
<CardTitle>Project Basics</CardTitle>
<CardTitle>Projekt Basisdaten</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="categoryId">Category</Label>
<Label htmlFor="categoryId">Kategorie</Label>
<select
id="categoryId"
name="categoryId"
@@ -225,7 +225,7 @@ export function PortfolioProjectForm({
</div>
<div className="space-y-2">
<Label htmlFor="clientName">Client Name</Label>
<Label htmlFor="clientName">Kundenname</Label>
<Input
id="clientName"
name="clientName"
@@ -235,7 +235,7 @@ export function PortfolioProjectForm({
</div>
<div className="space-y-2">
<Label htmlFor="projectYear">Project Year</Label>
<Label htmlFor="projectYear">Projektjahr</Label>
<Input
id="projectYear"
name="projectYear"
@@ -259,7 +259,7 @@ export function PortfolioProjectForm({
</div>
<div className="space-y-2">
<Label htmlFor="sortOrder">Sort Order</Label>
<Label htmlFor="sortOrder">Sortierung</Label>
<Input
id="sortOrder"
name="sortOrder"
@@ -272,7 +272,7 @@ export function PortfolioProjectForm({
<div className="space-y-2 md:col-span-2">
<MediaFieldPicker
title="Cover Image"
title="Cover Bild"
value={coverMedia}
onChange={setCoverMedia}
options={mediaOptions}
@@ -284,19 +284,19 @@ export function PortfolioProjectForm({
<label className="flex items-center gap-3 rounded-nested border border-border px-4 py-3 text-sm">
<input type="checkbox" name="isFeatured" defaultChecked={project?.isFeatured ?? false} />
Featured
Hervorgehoben
</label>
<label className="flex items-center gap-3 rounded-nested border border-border px-4 py-3 text-sm">
<input type="checkbox" name="isPublished" defaultChecked={project?.isPublished ?? false} />
Published
Veroeffentlicht
</label>
</CardContent>
</AppCard>
<AppCard>
<CardHeader>
<CardTitle>Localized Content</CardTitle>
<CardTitle>Lokalisierte Inhalte</CardTitle>
</CardHeader>
<CardContent>
<Tabs defaultValue="de">
@@ -312,13 +312,13 @@ export function PortfolioProjectForm({
<div className="grid gap-4 rounded-surface border border-border p-4 md:grid-cols-2">
<div className="md:col-span-2 text-sm text-muted-foreground">
{locale.key === "ar"
? "Arabic content for the Arabic website."
? "Arabische Inhalte fuer die arabische Website."
: locale.key === "en"
? "English content for the English website."
: "German content for the German website."}
? "Englische Inhalte fuer die englische Website."
: "Deutsche Inhalte fuer die deutsche Website."}
</div>
<div className="space-y-2">
<Label htmlFor={`title${locale.suffix}`}>{`Title ${locale.label}`}</Label>
<Label htmlFor={`title${locale.suffix}`}>{`Titel ${locale.label}`}</Label>
<Input
id={`title${locale.suffix}`}
name={`title${locale.suffix}`}
@@ -327,7 +327,7 @@ export function PortfolioProjectForm({
/>
</div>
<div className="space-y-2">
<Label htmlFor={`serviceLabel${locale.suffix}`}>{`Service Label ${locale.label}`}</Label>
<Label htmlFor={`serviceLabel${locale.suffix}`}>{`Leistungslabel ${locale.label}`}</Label>
<Input
id={`serviceLabel${locale.suffix}`}
name={`serviceLabel${locale.suffix}`}
@@ -336,7 +336,7 @@ export function PortfolioProjectForm({
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`summary${locale.suffix}`}>{`Summary ${locale.label}`}</Label>
<Label htmlFor={`summary${locale.suffix}`}>{`Kurzbeschreibung ${locale.label}`}</Label>
<Textarea
id={`summary${locale.suffix}`}
name={`summary${locale.suffix}`}
@@ -354,14 +354,14 @@ export function PortfolioProjectForm({
<AppCard>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Sections</CardTitle>
<CardTitle>Abschnitte</CardTitle>
<Button
type="button"
variant="outline"
onClick={() => setSections((current) => [...current, createEmptySection(current.length)])}
>
<Plus className="h-4 w-4" />
Add Section
Abschnitt hinzufuegen
</Button>
</CardHeader>
<CardContent className="space-y-4">
@@ -369,7 +369,7 @@ export function PortfolioProjectForm({
<AppCard key={section.id ?? `${section.type}-${index}`} level={2}>
<CardContent className="space-y-4 p-4">
<div className="flex items-center justify-between gap-4">
<p className="text-sm font-medium text-foreground">Section #{index + 1}</p>
<p className="text-sm font-medium text-foreground">{`Abschnitt #${index + 1}`}</p>
<div className="flex gap-2">
<Button
type="button"
@@ -401,14 +401,14 @@ export function PortfolioProjectForm({
disabled={sections.length === 1}
>
<Trash2 className="h-4 w-4" />
Remove
Entfernen
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Type</Label>
<Label>Typ</Label>
<select
value={section.type}
onChange={(event) =>
@@ -432,7 +432,7 @@ export function PortfolioProjectForm({
<div className="space-y-2 md:col-span-2">
<MediaFieldPicker
title="Section Image"
title="Abschnitt Bild"
value={section.media}
onChange={(media) =>
setSections((current) =>
@@ -470,7 +470,7 @@ export function PortfolioProjectForm({
{localeFieldConfig.map((locale) => (
<div key={`${locale.key}-title-${index}`} className="space-y-2">
<Label>{`Title ${locale.label}`}</Label>
<Label>{`Titel ${locale.label}`}</Label>
<Input
value={section[`title${locale.suffix}` as keyof SectionFormValue] as string}
onChange={(event) =>
@@ -491,7 +491,7 @@ export function PortfolioProjectForm({
{localeFieldConfig.map((locale) => (
<div key={`${locale.key}-body-${index}`} className="space-y-2 md:col-span-2">
<Label>{`Body ${locale.label}`}</Label>
<Label>{`Text ${locale.label}`}</Label>
<Textarea
rows={4}
value={section[`body${locale.suffix}` as keyof SectionFormValue] as string}
@@ -519,26 +519,26 @@ export function PortfolioProjectForm({
<AppCard>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Assets</CardTitle>
<CardTitle>Dateien</CardTitle>
<Button
type="button"
variant="outline"
onClick={() => setAssets((current) => [...current, createEmptyAsset(current.length)])}
>
<Plus className="h-4 w-4" />
Add Asset
Datei hinzufuegen
</Button>
</CardHeader>
<CardContent className="space-y-4">
{assets.length === 0 ? (
<p className="text-sm text-muted-foreground">No assets added yet.</p>
<p className="text-sm text-muted-foreground">Noch keine Dateien hinzugefuegt.</p>
) : null}
{assets.map((asset, index) => (
<AppCard key={asset.id ?? `${asset.kind}-${index}`} level={2}>
<CardContent className="space-y-4 p-4">
<div className="flex items-center justify-between gap-4">
<p className="text-sm font-medium text-foreground">Asset #{index + 1}</p>
<p className="text-sm font-medium text-foreground">{`Datei #${index + 1}`}</p>
<div className="flex gap-2">
<Button
type="button"
@@ -565,14 +565,14 @@ export function PortfolioProjectForm({
}
>
<Trash2 className="h-4 w-4" />
Remove
Entfernen
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Kind</Label>
<Label>Typ</Label>
<select
value={asset.kind}
onChange={(event) =>
@@ -603,7 +603,7 @@ export function PortfolioProjectForm({
<div className="space-y-2 md:col-span-2">
<MediaFieldPicker
title="Asset File"
title="Datei"
value={asset.media}
onChange={(media) =>
setAssets((current) =>
+23 -19
View File
@@ -1,5 +1,7 @@
import Link from "next/link";
import { AppCard } from "@/components/ui/app-card";
import { CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
type PortfolioSubnavProps = {
@@ -9,38 +11,40 @@ type PortfolioSubnavProps = {
const items = [
{
key: "overview",
label: "Overview",
label: "Uebersicht",
href: "/root/portfolio",
},
{
key: "categories",
label: "Categories",
label: "Kategorien",
href: "/root/portfolio/categories",
},
{
key: "projects",
label: "Projects",
label: "Projekte",
href: "/root/portfolio/projects",
},
] as const;
export function PortfolioSubnav({ active }: PortfolioSubnavProps) {
return (
<div className="flex flex-wrap gap-2">
{items.map((item) => (
<Link
key={item.key}
href={item.href}
className={cn(
"rounded-pill border px-4 py-2 text-sm transition-colors",
active === item.key
? "border-border-strong bg-foreground text-background"
: "border-border bg-background text-foreground/75 hover:border-border-strong hover:text-foreground",
)}
>
{item.label}
</Link>
))}
</div>
<AppCard level={2}>
<CardContent className="flex flex-wrap gap-2 p-4">
{items.map((item) => (
<Link
key={item.key}
href={item.href}
className={cn(
"rounded-pill border px-4 py-2 text-sm transition-colors",
active === item.key
? "border-border-strong bg-foreground text-background"
: "border-border bg-background text-foreground/75 hover:border-border-strong hover:text-foreground",
)}
>
{item.label}
</Link>
))}
</CardContent>
</AppCard>
);
}
+87 -39
View File
@@ -1,10 +1,19 @@
import type { ReactNode } from "react";
import { ArrowLeft, LogOut } from "lucide-react";
import {
ArrowLeft,
FolderKanban,
ImageIcon,
LayoutDashboard,
LogOut,
PlusSquare,
ShieldAlert,
SwatchBook,
Tags,
} from "lucide-react";
import Link from "next/link";
import { AppHeader } from "@/components/layout/app-header";
import { AppShell } from "@/components/layout/app-shell";
import { AppSidebar } from "@/components/layout/app-sidebar";
import { DashboardLayout } from "@/components/dashboard/dashboard-layout";
import { MotionFade } from "@/components/motion-fade";
import { ThemeToggle } from "@/components/theme-toggle";
import { Button } from "@/components/ui/button";
import { getLocalizedPath } from "@/lib/locale";
@@ -30,6 +39,7 @@ type RootDashboardShellProps = {
headerTitle: string;
headerDescription: string;
headerActions?: ReactNode;
toolbar?: ReactNode;
children: ReactNode;
};
@@ -41,47 +51,85 @@ export function RootDashboardShell({
headerTitle,
headerDescription,
headerActions,
toolbar,
children,
}: RootDashboardShellProps) {
const sidebarItems = getRootNavigation(copy, active, portfolioChild);
const sidebarItems = getRootNavigation(copy, active, portfolioChild).filter(
(item) => item.href !== "/root/maintenance" && item.href !== "/root/ui-kit",
);
const headerIcon =
active === "overview"
? LayoutDashboard
: active === "maintenance"
? ShieldAlert
: active === "ui-kit"
? SwatchBook
: active === "media"
? ImageIcon
: portfolioChild === "categories"
? Tags
: portfolioChild === "new-project"
? PlusSquare
: FolderKanban;
const sharedActions = (
<>
<ThemeToggle ariaLabel="Theme wechseln" />
<Button asChild variant="outline" size="sm">
<Link href={getLocalizedPath("de")}>
<ArrowLeft className="h-4 w-4" />
{copy.backToSite}
</Link>
</Button>
<form action={logoutAction}>
<Button type="submit" variant="ghost" size="sm" className="text-destructive hover:bg-destructive/10 hover:text-destructive">
<LogOut className="h-4 w-4" />
{copy.logout}
</Button>
</form>
</>
);
return (
<AppShell
sidebar={
<AppSidebar
title={copy.title}
description={copy.subtitle}
items={sidebarItems}
footer={
<div className="space-y-2">
<div className="flex items-center gap-2">
<ThemeToggle ariaLabel="Theme wechseln" />
</div>
<Button asChild variant="outline" className="w-full justify-start">
<Link href={getLocalizedPath("de")}>
<ArrowLeft className="h-4 w-4" />
{copy.backToSite}
</Link>
</Button>
<form action={logoutAction}>
<Button type="submit" variant="destructive" className="w-full justify-start">
<LogOut className="h-4 w-4" />
{copy.logout}
</Button>
</form>
</div>
}
/>
<DashboardLayout
title={headerTitle}
description={headerDescription}
icon={headerIcon}
items={sidebarItems}
sidebarFooter={
<>
<Button
asChild
variant={active === "maintenance" ? "default" : "outline"}
className="w-full justify-between"
>
<Link href="/root/maintenance">
{copy.maintenance}
<ShieldAlert className="h-4 w-4" />
</Link>
</Button>
<Button
asChild
variant={active === "ui-kit" ? "default" : "outline"}
className="w-full justify-between"
>
<Link href="/root/ui-kit">
{copy.uiKit}
<SwatchBook className="h-4 w-4" />
</Link>
</Button>
</>
}
header={
<AppHeader
title={headerTitle}
description={headerDescription}
actions={headerActions}
/>
headerActions={
<div className="flex flex-wrap items-center justify-end gap-2">
{headerActions}
{sharedActions}
</div>
}
>
{children}
</AppShell>
<div className="space-y-6">
{toolbar ? <MotionFade delay={0.05}>{toolbar}</MotionFade> : null}
{children}
</div>
</DashboardLayout>
);
}
+28 -4
View File
@@ -4,13 +4,22 @@ import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Button, type ButtonProps } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type ThemeToggleProps = {
ariaLabel?: string;
label?: string;
variant?: ButtonProps["variant"];
className?: string;
};
export function ThemeToggle({ ariaLabel = "Toggle theme" }: ThemeToggleProps) {
export function ThemeToggle({
ariaLabel = "Toggle theme",
label,
variant = "outline",
className,
}: ThemeToggleProps) {
const { setTheme, theme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
@@ -20,8 +29,15 @@ export function ThemeToggle({ ariaLabel = "Toggle theme" }: ThemeToggleProps) {
if (!mounted) {
return (
<Button type="button" variant="outline" size="icon" aria-label={ariaLabel}>
<Button
type="button"
variant={variant}
size={label ? "sm" : "icon"}
aria-label={ariaLabel}
className={cn(label ? "justify-start" : undefined, className)}
>
<Moon className="h-4 w-4" />
{label ? <span>{label}</span> : null}
</Button>
);
}
@@ -30,8 +46,16 @@ export function ThemeToggle({ ariaLabel = "Toggle theme" }: ThemeToggleProps) {
const isDark = activeTheme === "dark";
return (
<Button type="button" variant="outline" size="icon" onClick={() => setTheme(isDark ? "light" : "dark")} aria-label={ariaLabel}>
<Button
type="button"
variant={variant}
size={label ? "sm" : "icon"}
onClick={() => setTheme(isDark ? "light" : "dark")}
aria-label={ariaLabel}
className={cn(label ? "justify-start" : undefined, className)}
>
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
{label ? <span>{label}</span> : null}
</Button>
);
}
+193
View File
@@ -0,0 +1,193 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => (
<span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
);
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
};
+112
View File
@@ -0,0 +1,112 @@
"use client";
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out",
className,
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = {
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=open]:slide-in-from-right data-[state=closed]:slide-out-to-right sm:max-w-sm",
left:
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=open]:slide-in-from-left data-[state=closed]:slide-out-to-left sm:max-w-sm",
top:
"inset-x-0 top-0 border-b data-[state=open]:slide-in-from-top data-[state=closed]:slide-out-to-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=open]:slide-in-from-bottom data-[state=closed]:slide-out-to-bottom",
};
type SheetContentProps = React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content> & {
side?: keyof typeof sheetVariants;
};
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out",
sheetVariants[side],
className,
)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-left", className)} {...props} />
);
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
};
+112
View File
@@ -0,0 +1,112 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
),
);
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
),
);
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-11 px-4 text-left align-middle font-medium text-muted-foreground",
className,
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td ref={ref} className={cn("p-4 align-middle", className)} {...props} />
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
export {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
};
+5 -5
View File
@@ -253,7 +253,7 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
<TabsContent value="cards">
<UiKitSection title={copy.cards} description={copy.cardsDesc}>
<div className="grid gap-6 lg:grid-cols-2">
<div className="grid gap-6">
<AppCard level={1}>
<CardContent className="space-y-4 p-6">
<div>
@@ -307,7 +307,7 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
<TabsContent value="buttons">
<UiKitSection title={copy.buttons} description={copy.buttonsDesc}>
<div className="grid gap-6 lg:grid-cols-2">
<div className="grid gap-6">
<AppCard level={2}>
<CardContent className="flex flex-wrap gap-3 p-5">
<Button>{copy.default}</Button>
@@ -335,7 +335,7 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
<TabsContent value="inputs">
<UiKitSection title={copy.inputs} description={copy.inputsDesc}>
<div className="grid gap-6 lg:grid-cols-2">
<div className="grid gap-6">
<AppCard level={2}>
<CardContent className="grid gap-4 p-5">
<Label className="grid gap-2">
@@ -431,7 +431,7 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
<TabsContent value="surfaces">
<UiKitSection title={copy.surfaces} description={copy.surfacesDesc}>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="grid gap-4">
<AppCard level={1}>
<CardContent className="p-5">
<p className="font-medium">{copy.surfaceOne}</p>
@@ -489,7 +489,7 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
<TabsContent value="headers">
<UiKitSection title={copy.headers} description={copy.headersDesc}>
<div className="grid gap-6 lg:grid-cols-2">
<div className="grid gap-6">
<AppCard level={3}>
<CardContent className="space-y-3 p-6">
<div className="inline-flex w-fit items-center gap-2 rounded-pill border border-border bg-surface-1 px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-foreground/80">