Refactor site header, heroes, and design system

This commit is contained in:
MOH
2026-03-08 00:29:51 +01:00
parent a18370d003
commit 0c2b67e378
56 changed files with 2714 additions and 1318 deletions
+2 -2
View File
@@ -32,7 +32,7 @@ export function DashboardSidebar({ items, iconSrc, top, footer }: DashboardSideb
<Link
href={item.href}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
"flex items-center gap-3 rounded-nested px-3 py-2 text-sm transition-colors",
item.active
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
@@ -51,7 +51,7 @@ export function DashboardSidebar({ items, iconSrc, top, footer }: DashboardSideb
return (
<div className="flex h-full flex-col bg-sidebar/40">
<div className="flex items-center gap-3 px-4 py-5">
<div className="overflow-hidden rounded-xl border border-border/70 bg-background shadow-sm">
<div className="overflow-hidden rounded-surface border border-border/70 bg-background shadow-sm">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={iconSrc || "/favicon.ico"}
+1 -1
View File
@@ -41,7 +41,7 @@ export function AppSidebar({
<Link
href={item.href}
className={cn(
"flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors",
"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",
+70
View File
@@ -0,0 +1,70 @@
"use client";
import { motion } from "framer-motion";
type HeroAtmosphereProps = {
compact?: boolean;
};
export function HeroAtmosphere({ compact = false }: HeroAtmosphereProps) {
return (
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<motion.div
animate={{
x: [0, 90, -48, 0],
y: [0, -70, 32, 0],
scale: [1, 1.12, 0.96, 1],
}}
transition={{
duration: 20,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
className="absolute left-[-10%] top-[4%] h-[24rem] w-[24rem] rounded-full bg-brand-primary/22 blur-3xl sm:h-[34rem] sm:w-[34rem] lg:h-[42rem] lg:w-[42rem]"
/>
<motion.div
animate={{
x: [0, -110, 40, 0],
y: [0, 48, -26, 0],
scale: [1, 0.92, 1.08, 1],
}}
transition={{
duration: 24,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
className="absolute right-[-14%] top-[2%] h-[22rem] w-[22rem] rounded-full bg-brand-secondary/18 blur-3xl sm:h-[30rem] sm:w-[30rem] lg:h-[38rem] lg:w-[38rem]"
/>
<motion.div
animate={{
x: [0, 42, -24, 0],
y: [0, -28, 30, 0],
opacity: [0.2, 0.34, 0.18, 0.2],
}}
transition={{
duration: 18,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
className="absolute bottom-[-18%] left-[18%] h-[18rem] w-[18rem] rounded-full bg-brand-primary/14 blur-3xl sm:h-[24rem] sm:w-[24rem] lg:h-[30rem] lg:w-[30rem]"
/>
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.58),rgba(255,255,255,0.32)_32%,rgba(255,255,255,0.08)_68%,rgba(255,255,255,0.02)),radial-gradient(circle_at_18%_24%,rgba(221,65,36,0.16),transparent_30%),radial-gradient(circle_at_78%_20%,rgba(235,143,117,0.18),transparent_34%),radial-gradient(circle_at_50%_100%,rgba(174,196,255,0.18),transparent_38%)] dark:bg-[linear-gradient(180deg,rgba(10,14,22,0.3),rgba(10,14,22,0.42)_34%,rgba(10,14,22,0.7)_100%),radial-gradient(circle_at_18%_24%,rgba(221,65,36,0.2),transparent_30%),radial-gradient(circle_at_78%_20%,rgba(235,143,117,0.12),transparent_34%),radial-gradient(circle_at_50%_100%,rgba(91,117,214,0.12),transparent_38%)]" />
<motion.div
animate={{
x: [0, compact ? 18 : 26, compact ? -12 : -18, 0],
y: [0, compact ? -14 : -22, compact ? 10 : 16, 0],
}}
transition={{
duration: compact ? 8 : 10,
repeat: Number.POSITIVE_INFINITY,
ease: "linear",
}}
className="hero-noise absolute inset-[-8%] opacity-55 mix-blend-soft-light dark:opacity-22"
/>
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { ArrowRight } from "lucide-react";
import Link from "next/link";
import { HeroAtmosphere } from "@/components/layout/hero-atmosphere";
import { MotionFade } from "@/components/motion-fade";
import { Button } from "@/components/ui/button";
import { getLocalizedPath } from "@/lib/locale";
type HomeHeroProps = {
locale: string;
kicker: string;
title: string;
description: string;
portfolioLabel: string;
contactLabel: string;
};
export function HomeHero({
locale,
kicker,
title,
description,
portfolioLabel,
contactLabel,
}: HomeHeroProps) {
return (
<section className="relative isolate flex min-h-[85vh] items-center overflow-hidden">
<HeroAtmosphere />
<div className="mx-auto flex w-full max-w-[72rem] flex-col items-center justify-center px-4 pb-12 pt-24 text-center sm:px-6 lg:px-8 lg:pb-16 lg:pt-28">
<MotionFade className="w-full">
<div className="mx-auto max-w-[54rem]">
<p className="text-sm font-medium tracking-[0.08em] text-foreground/62">
{kicker}
</p>
<h1 className="mt-6 whitespace-pre-line text-balance text-[3rem] font-semibold leading-[0.92] tracking-[-0.06em] text-foreground sm:text-[4.5rem] lg:text-[6.75rem]">
{title}
</h1>
<p className="mx-auto mt-6 max-w-[30rem] text-sm leading-6 text-foreground/66 sm:text-base">
{description}
</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
<Button asChild size="lg" className="px-6">
<Link href={getLocalizedPath(locale, "/portfolio")}>
{portfolioLabel}
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
<Button asChild variant="outline" size="lg" className="px-6">
<Link href={getLocalizedPath(locale, "/contact")}>{contactLabel}</Link>
</Button>
</div>
</div>
</MotionFade>
</div>
</section>
);
}
+12 -3
View File
@@ -11,12 +11,15 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { AppLocale, getLocalizedPath, stripLocalePrefix } from "@/lib/locale";
import { cn } from "@/lib/utils";
type LocaleToggleProps = {
locale: string;
className?: string;
showLabel?: boolean;
};
export function LocaleToggle({ locale }: LocaleToggleProps) {
export function LocaleToggle({ locale, className, showLabel = false }: LocaleToggleProps) {
const pathname = usePathname();
const locales: AppLocale[] = ["de", "en", "ar"];
const currentPath = stripLocalePrefix(pathname);
@@ -27,11 +30,17 @@ export function LocaleToggle({ locale }: LocaleToggleProps) {
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
variant="ghost"
size={showLabel ? "sm" : "icon"}
aria-label={`Locale: ${currentLocale.toUpperCase()}`}
className={cn(
"rounded-pill border border-transparent text-muted-foreground hover:border-border/70 hover:bg-background/80 hover:text-foreground",
showLabel ? "gap-2 px-3" : undefined,
className,
)}
>
<Languages className="h-4 w-4" />
{showLabel ? <span className="text-xs font-semibold uppercase tracking-[0.22em]">{currentLocale}</span> : null}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[8rem]">
+30
View File
@@ -0,0 +1,30 @@
import { HeroAtmosphere } from "@/components/layout/hero-atmosphere";
import { MotionFade } from "@/components/motion-fade";
type PageHeroProps = {
title: string;
description?: string;
};
export function PageHero({ title, description }: PageHeroProps) {
return (
<section className="relative isolate flex min-h-[42vh] items-end overflow-hidden">
<HeroAtmosphere compact />
<div className="mx-auto w-full max-w-[72rem] px-4 pb-10 pt-24 sm:px-6 lg:px-8 lg:pb-12 lg:pt-28">
<MotionFade className="max-w-[44rem]">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-foreground/48">
{title}
</p>
<h1 className="mt-4 text-balance text-4xl font-semibold leading-[0.95] tracking-[-0.05em] text-foreground sm:text-5xl lg:text-6xl">
{title}
</h1>
{description ? (
<p className="mt-5 max-w-[36rem] text-sm leading-6 text-foreground/66 sm:text-base">
{description}
</p>
) : null}
</MotionFade>
</div>
</section>
);
}
@@ -0,0 +1,62 @@
"use client";
import { motion } from "framer-motion";
export function SiteAmbientBackdrop() {
return (
<div className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
<motion.div
animate={{
x: [0, 72, -36, 0],
y: [0, -54, 28, 0],
scale: [1, 1.1, 0.95, 1],
}}
transition={{
duration: 18,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
className="absolute left-[-10%] top-[4%] h-[30rem] w-[30rem] rounded-full bg-brand-primary/18 blur-3xl sm:h-[38rem] sm:w-[38rem] lg:h-[46rem] lg:w-[46rem]"
/>
<motion.div
animate={{
x: [0, -84, 26, 0],
y: [0, 44, -22, 0],
scale: [1, 0.9, 1.08, 1],
}}
transition={{
duration: 22,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
className="absolute right-[-12%] top-[8%] h-[26rem] w-[26rem] rounded-full bg-brand-secondary/16 blur-3xl sm:h-[34rem] sm:w-[34rem] lg:h-[42rem] lg:w-[42rem]"
/>
<motion.div
animate={{
x: [0, 34, -20, 0],
y: [0, -24, 34, 0],
opacity: [0.22, 0.38, 0.16, 0.22],
}}
transition={{
duration: 16,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
className="absolute bottom-[-12%] left-[22%] h-[20rem] w-[20rem] rounded-full bg-brand-primary/14 blur-3xl sm:h-[28rem] sm:w-[28rem] lg:h-[34rem] lg:w-[34rem]"
/>
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.64),rgba(255,255,255,0.42)_24%,rgba(255,255,255,0.14)_52%,rgba(255,255,255,0.02)_100%),radial-gradient(circle_at_16%_18%,rgba(221,65,36,0.12),transparent_30%),radial-gradient(circle_at_78%_14%,rgba(235,143,117,0.16),transparent_34%),radial-gradient(circle_at_50%_100%,rgba(160,182,255,0.16),transparent_40%)] dark:bg-[linear-gradient(180deg,rgba(11,15,24,0.24),rgba(11,15,24,0.36)_26%,rgba(11,15,24,0.72)_100%),radial-gradient(circle_at_16%_18%,rgba(221,65,36,0.16),transparent_30%),radial-gradient(circle_at_78%_14%,rgba(235,143,117,0.1),transparent_34%),radial-gradient(circle_at_50%_100%,rgba(109,130,214,0.12),transparent_40%)]" />
<motion.div
animate={{
x: [0, 20, -14, 0],
y: [0, -16, 12, 0],
}}
transition={{
duration: 9,
repeat: Number.POSITIVE_INFINITY,
ease: "linear",
}}
className="hero-noise absolute inset-[-6%] opacity-48 mix-blend-soft-light dark:opacity-18"
/>
</div>
);
}
+209 -64
View File
@@ -1,16 +1,19 @@
"use client";
import { Menu, X } from "lucide-react";
import Image from "next/image";
import { AnimatePresence, motion } from "framer-motion";
import { LayoutDashboard, Menu, X } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useLocale, useTranslations } from "next-intl";
import { useState } from "react";
import { Container } from "@/components/layout/container";
import { LocaleToggle } from "@/components/layout/locale-toggle";
import { SiteLogo } from "@/components/layout/site-logo";
import { ThemeToggle } from "@/components/theme-toggle";
import { Button } from "@/components/ui/button";
import { getLocalizedPath } from "@/lib/locale";
import { getLocalizedPath, stripLocalePrefix } from "@/lib/locale";
import { cn } from "@/lib/utils";
const navItems = [
{ key: "home", path: "" },
@@ -22,111 +25,253 @@ const navItems = [
type SiteHeaderProps = {
isAdmin?: boolean;
lightLogoUrl?: string | null;
darkLogoUrl?: string | null;
};
function isNavItemActive(currentPath: string, itemPath: string) {
if (itemPath === "/") {
return currentPath === "/";
}
return currentPath === itemPath || currentPath.startsWith(`${itemPath}/`);
}
function NavLinks({
isAdmin,
onNavigate,
mobile = false,
}: {
isAdmin: boolean;
onNavigate?: () => void;
mobile?: boolean;
}) {
const locale = useLocale();
const pathname = usePathname();
const t = useTranslations("navigation");
const currentPath = stripLocalePrefix(pathname);
return (
<>
{navItems.map((item) => (
<Link
key={item.key}
href={getLocalizedPath(locale, item.path || "/")}
className="text-sm text-muted-foreground hover:text-foreground"
onClick={onNavigate}
>
{t(item.key)}
</Link>
(() => {
const itemPath = item.path || "/";
const isActive = isNavItemActive(currentPath, itemPath);
return (
<Link
key={item.key}
href={getLocalizedPath(locale, itemPath)}
className={cn(
"relative z-10 rounded-[var(--radius-pill)] text-sm font-medium transition-colors",
mobile ? "px-4 py-3" : "px-3.5 py-2 xl:px-4",
isActive ? "text-primary-foreground" : "text-muted-foreground hover:text-foreground",
)}
onClick={onNavigate}
>
{isActive ? (
<motion.span
layoutId="site-header-active-tab"
className="absolute inset-0 -z-10 rounded-[var(--radius-pill)] bg-primary shadow-[0_14px_34px_-22px_hsl(var(--brand-primary)/0.65)]"
transition={{ type: "spring", stiffness: 380, damping: 30 }}
/>
) : null}
{t(item.key)}
</Link>
);
})()
))}
{isAdmin ? (
<a
href="/root"
className="text-sm text-muted-foreground hover:text-foreground"
onClick={onNavigate}
>
{t("root")}
</a>
) : null}
</>
);
}
export function SiteHeader({ isAdmin = false }: SiteHeaderProps) {
export function SiteHeader({
isAdmin = false,
lightLogoUrl,
darkLogoUrl,
}: SiteHeaderProps) {
const [isOpen, setIsOpen] = useState(false);
const pathname = usePathname();
const locale = useLocale();
const t = useTranslations("navigation");
return (
<header className="sticky top-0 z-40 border-b border-border/80 bg-background/88 backdrop-blur-chrome">
<Container>
<div className="flex min-h-header items-center justify-between gap-4">
<div className="flex items-center gap-4">
<header className="sticky top-3 z-40 bg-transparent">
<Container className="max-w-[88rem]">
<div className="relative grid min-h-[4.5rem] grid-cols-[auto_1fr_auto] items-center gap-3 lg:min-h-[5rem]">
<div className="flex items-center">
<Link
href={getLocalizedPath(locale)}
className="inline-flex items-center rounded-md border border-input bg-background px-3 py-2 shadow-sm"
className="inline-flex items-center py-1 sm:py-1.5 lg:py-2"
>
<Image
src="/logos/light-primary.svg"
<SiteLogo
lightLogoUrl={lightLogoUrl}
darkLogoUrl={darkLogoUrl}
alt="mohfarawati"
width={140}
height={24}
className="block h-6 w-auto dark:hidden"
priority
/>
<Image
src="/logos/dark-primary.svg"
alt="mohfarawati"
width={140}
height={24}
className="hidden h-6 w-auto dark:block"
priority
/>
</Link>
<nav className="hidden items-center gap-5 md:flex">
<NavLinks isAdmin={isAdmin} />
</nav>
</div>
<div className="hidden items-center gap-2 md:flex">
<ThemeToggle ariaLabel={t("themeToggle")} />
<LocaleToggle locale={locale} />
<div className="pointer-events-none absolute left-1/2 top-1/2 hidden -translate-x-1/2 -translate-y-1/2 lg:flex">
<motion.nav
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.28, ease: "easeOut" }}
className="pointer-events-auto inline-flex min-w-[27rem] max-w-[42rem] items-center justify-center gap-1 rounded-[var(--radius-pill)] border border-white/24 bg-[linear-gradient(180deg,rgba(255,255,255,0.22),rgba(255,255,255,0.1))] p-1.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.46),inset_0_-1px_0_rgba(255,255,255,0.08),0_18px_44px_-28px_rgba(15,23,42,0.32)] backdrop-blur-[28px] dark:border-white/12 dark:bg-[linear-gradient(180deg,rgba(255,255,255,0.09),rgba(255,255,255,0.03))] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.12),inset_0_-1px_0_rgba(255,255,255,0.03),0_20px_48px_-28px_rgba(0,0,0,0.72)]"
>
<NavLinks />
</motion.nav>
</div>
<div className="hidden items-center justify-end lg:flex">
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.28, ease: "easeOut", delay: 0.03 }}
className="inline-flex items-center gap-1 rounded-[var(--radius-pill)] border border-white/24 bg-[linear-gradient(180deg,rgba(255,255,255,0.22),rgba(255,255,255,0.1))] p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.46),inset_0_-1px_0_rgba(255,255,255,0.08),0_18px_44px_-28px_rgba(15,23,42,0.32)] backdrop-blur-[28px] dark:border-white/12 dark:bg-[linear-gradient(180deg,rgba(255,255,255,0.09),rgba(255,255,255,0.03))] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.12),inset_0_-1px_0_rgba(255,255,255,0.03),0_20px_48px_-28px_rgba(0,0,0,0.72)]"
>
<LocaleToggle
locale={locale}
showLabel
className="h-9 rounded-[var(--radius-pill)] px-3 text-foreground hover:bg-white/34 dark:hover:bg-white/10"
/>
{isAdmin ? (
<Button asChild variant="ghost" size="icon" className="h-9 w-9 rounded-[var(--radius-pill)] border border-transparent text-foreground hover:border-white/12 hover:bg-white/34 dark:hover:border-white/10 dark:hover:bg-white/10">
<Link href="/root" aria-label={t("root")}>
<LayoutDashboard className="h-4 w-4" />
</Link>
</Button>
) : null}
<ThemeToggle
ariaLabel={t("themeToggle")}
variant="ghost"
className="h-9 w-9 rounded-[var(--radius-pill)] border border-transparent text-foreground hover:border-white/12 hover:bg-white/34 dark:hover:border-white/10 dark:hover:bg-white/10"
/>
</motion.div>
</div>
<Button
type="button"
onClick={() => setIsOpen((open) => !open)}
variant="outline"
variant="ghost"
size="icon"
className="md:hidden"
className="ml-auto h-11 w-11 rounded-[var(--radius-pill)] border border-white/24 bg-[linear-gradient(180deg,rgba(255,255,255,0.24),rgba(255,255,255,0.11))] text-foreground shadow-[inset_0_1px_0_rgba(255,255,255,0.46),inset_0_-1px_0_rgba(255,255,255,0.08),0_18px_44px_-28px_rgba(15,23,42,0.32)] backdrop-blur-[28px] hover:bg-[linear-gradient(180deg,rgba(255,255,255,0.3),rgba(255,255,255,0.14))] dark:border-white/12 dark:bg-[linear-gradient(180deg,rgba(255,255,255,0.09),rgba(255,255,255,0.03))] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.12),inset_0_-1px_0_rgba(255,255,255,0.03),0_20px_48px_-28px_rgba(0,0,0,0.72)] dark:hover:bg-[linear-gradient(180deg,rgba(255,255,255,0.12),rgba(255,255,255,0.05))] lg:hidden"
aria-label={isOpen ? t("closeMenu") : t("openMenu")}
>
{isOpen ? <X className="h-4 w-4" /> : <Menu className="h-4 w-4" />}
<motion.span
animate={{ rotate: isOpen ? 180 : 0, scale: isOpen ? 0.96 : 1 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="flex items-center justify-center"
>
{isOpen ? <X className="h-4 w-4" /> : <Menu className="h-4 w-4" />}
</motion.span>
</Button>
</div>
</Container>
{isOpen ? (
<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)} />
</nav>
<div className="mt-4 flex items-center gap-2">
<ThemeToggle ariaLabel={t("themeToggle")} />
<LocaleToggle locale={locale} />
</div>
</Container>
</div>
) : null}
<AnimatePresence initial={false}>
{isOpen ? (
<motion.div
initial={{ opacity: 0, y: -14 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.22, ease: "easeOut" }}
className="bg-transparent lg:hidden"
>
<Container className="pb-2 pt-3">
<motion.div
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="overflow-hidden rounded-surface border border-white/24 bg-[linear-gradient(180deg,rgba(255,255,255,0.24),rgba(255,255,255,0.1))] shadow-[inset_0_1px_0_rgba(255,255,255,0.46),inset_0_-1px_0_rgba(255,255,255,0.08),0_18px_44px_-28px_rgba(15,23,42,0.32)] backdrop-blur-[28px] dark:border-white/12 dark:bg-[linear-gradient(180deg,rgba(255,255,255,0.09),rgba(255,255,255,0.03))] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.12),inset_0_-1px_0_rgba(255,255,255,0.03),0_20px_48px_-28px_rgba(0,0,0,0.72)]"
>
<motion.nav
initial="closed"
animate="open"
exit="closed"
variants={{
open: {
transition: {
staggerChildren: 0.04,
delayChildren: 0.02,
},
},
closed: {
transition: {
staggerChildren: 0.03,
staggerDirection: -1,
},
},
}}
className="flex flex-col gap-1 p-2"
>
{navItems.map((item) => {
const itemPath = item.path || "/";
const isActive = isNavItemActive(stripLocalePrefix(pathname), itemPath);
return (
<motion.div
key={item.key}
variants={{
open: { opacity: 1, y: 0 },
closed: { opacity: 0, y: -8 },
}}
transition={{ duration: 0.18, ease: "easeOut" }}
>
<Link
href={getLocalizedPath(locale, itemPath)}
className={cn(
"flex items-center justify-between rounded-nested px-4 py-3 text-sm font-medium transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "text-foreground hover:bg-accent hover:text-accent-foreground",
)}
onClick={() => setIsOpen(false)}
>
<span>{t(item.key)}</span>
<span className="text-xs uppercase tracking-[0.18em] opacity-60">
0{navItems.findIndex((navItem) => navItem.key === item.key) + 1}
</span>
</Link>
</motion.div>
);
})}
</motion.nav>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
transition={{ duration: 0.18, ease: "easeOut", delay: 0.04 }}
className="border-t border-white/18 bg-[linear-gradient(180deg,rgba(255,255,255,0.14),rgba(255,255,255,0.06))] p-2 dark:border-white/8 dark:bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(255,255,255,0.02))]"
>
<div className="flex items-center justify-between gap-2">
<LocaleToggle
locale={locale}
showLabel
className="h-10 rounded-nested px-3 text-foreground hover:bg-white/34 dark:hover:bg-white/10"
/>
<div className="flex items-center gap-2">
{isAdmin ? (
<Button asChild variant="ghost" size="icon" className="h-10 w-10 rounded-nested border border-white/18 bg-white/18 text-foreground hover:bg-white/34 dark:border-white/10 dark:bg-white/6 dark:hover:bg-white/10">
<Link href="/root" aria-label={t("root")} onClick={() => setIsOpen(false)}>
<LayoutDashboard className="h-4 w-4" />
</Link>
</Button>
) : null}
<ThemeToggle
ariaLabel={t("themeToggle")}
variant="ghost"
className="h-10 w-10 rounded-nested border border-white/18 bg-white/18 text-foreground hover:bg-white/34 dark:border-white/10 dark:bg-white/6 dark:hover:bg-white/10"
/>
</div>
</div>
</motion.div>
</motion.div>
</Container>
</motion.div>
) : null}
</AnimatePresence>
</header>
);
}
+45
View File
@@ -0,0 +1,45 @@
"use client";
import Image from "next/image";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
type SiteLogoProps = {
lightLogoUrl?: string | null;
darkLogoUrl?: string | null;
alt: string;
priority?: boolean;
};
export function SiteLogo({
lightLogoUrl,
darkLogoUrl,
alt,
priority = false,
}: SiteLogoProps) {
const { theme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const activeTheme = theme === "system" ? resolvedTheme : theme;
const isDark = mounted && activeTheme === "dark";
const fallbackSrc = isDark ? "/logos/dark-primary.svg" : "/logos/light-primary.svg";
const src = isDark ? darkLogoUrl || lightLogoUrl || fallbackSrc : lightLogoUrl || darkLogoUrl || fallbackSrc;
return (
<span className="block">
<Image
src={src}
alt={alt}
width={360}
height={92}
sizes="(min-width: 1280px) 360px, (min-width: 1024px) 320px, (min-width: 640px) 280px, 240px"
className="h-9 w-auto object-contain object-left sm:h-10 lg:h-11 xl:h-12"
priority={priority}
/>
</span>
);
}
+4 -4
View File
@@ -110,7 +110,7 @@ export function MediaFieldPicker({
}, [serializedValue]);
return (
<div className="space-y-3 rounded-lg border bg-card p-4 shadow-sm">
<div className="space-y-3 rounded-surface border border-border/80 bg-card p-4 shadow-card">
<div className="space-y-2">
<Label className="sr-only">{title}</Label>
<div className="flex flex-wrap gap-2">
@@ -128,7 +128,7 @@ export function MediaFieldPicker({
})
}
className={cn(
"rounded-md border px-4 py-2 text-sm transition-colors",
"rounded-nested border px-4 py-2 text-sm transition-colors",
value.mode === mode
? "border-input bg-primary text-primary-foreground"
: "border-input bg-background text-foreground/75 hover:bg-accent hover:text-accent-foreground",
@@ -150,7 +150,7 @@ export function MediaFieldPicker({
isCleared: true,
})
}
className="rounded-md border border-destructive/25 px-4 py-2 text-sm text-destructive transition-colors hover:bg-destructive/5"
className="rounded-nested border border-destructive/25 px-4 py-2 text-sm text-destructive transition-colors hover:bg-destructive/5"
>
{clearLabel}
</button>
@@ -208,7 +208,7 @@ export function MediaFieldPicker({
{value.mode === "library" ? (
<div className="space-y-3">
<Label className="sr-only">{libraryLabel ?? "Media Library"}</Label>
<div className="space-y-2 rounded-md border bg-card p-3">
<div className="space-y-2 rounded-nested border border-border/80 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
@@ -0,0 +1,382 @@
"use client";
import { useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { FileText, FolderPlus, Hash, Layers3, Pencil, Text, Trash2 } from "lucide-react";
import type { deleteCategoryAction, upsertCategoryAction } from "@/app/root/portfolio/actions";
import { AppCard } from "@/components/ui/app-card";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import type { PortfolioCategoryView } from "@/lib/portfolio";
const locales = [
{ key: "Ar", lowerKey: "ar" as const, label: "Arabic" },
{ key: "En", lowerKey: "en" as const, label: "English" },
{ key: "De", lowerKey: "de" as const, label: "German" },
] as const;
const copy = {
addCategory: "Kategorie hinzufuegen",
saveCategory: "Kategorie speichern",
save: "Speichern",
delete: "Loeschen",
active: "Aktiv",
sortOrder: "Sortierung",
projects: "Projekte",
description: "Beschreibung",
currentCategories: "Aktuelle Kategorien",
modalDescription: "Neue Kategorie direkt im Popup anlegen.",
editDescription: "Kategorie im Popup aendern oder loeschen.",
empty: "Noch keine Kategorien vorhanden.",
deleteBlocked: "Loeschen erst moeglich, wenn keine Projekte mehr zugeordnet sind.",
editCategory: "Kategorie bearbeiten",
};
type CategoryAction = typeof upsertCategoryAction;
type CategoryDeleteAction = typeof deleteCategoryAction;
type PortfolioCategoriesManagerProps = {
categories: Array<PortfolioCategoryView & { projectCount: number }>;
activeCount: number;
assignedProjects: number;
saveCategoryAction: CategoryAction;
removeCategoryAction: CategoryDeleteAction;
};
function CategoryLocaleFields({
idPrefix,
values,
}: {
idPrefix: string;
values?: {
nameAr?: string;
nameEn?: string;
nameDe?: string;
descriptionAr?: string;
descriptionEn?: string;
descriptionDe?: string;
};
}) {
return (
<div className="grid gap-4 xl:grid-cols-3">
{locales.map((locale) => {
const nameKey = `name${locale.key}` as const;
const descriptionKey = `description${locale.key}` as const;
return (
<div key={`${idPrefix}-${locale.key}`} className="space-y-4 rounded-lg border border-input bg-background p-4">
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">{locale.label}</p>
</div>
<div className="space-y-2">
<Label htmlFor={`${idPrefix}-${nameKey}`} 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={`${idPrefix}-${nameKey}`}
name={nameKey}
defaultValue={values?.[nameKey] ?? ""}
required
placeholder={`Name ${locale.label}`}
className="pl-9"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`${idPrefix}-${descriptionKey}`} 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={`${idPrefix}-${descriptionKey}`}
name={descriptionKey}
rows={5}
defaultValue={values?.[descriptionKey] ?? ""}
required
placeholder={`${copy.description} ${locale.label}`}
className="pl-9"
/>
</div>
</div>
</div>
);
})}
</div>
);
}
function EditCategoryDialog({
category,
open,
onOpenChange,
saveCategoryAction,
removeCategoryAction,
}: {
category: PortfolioCategoriesManagerProps["categories"][number];
open: boolean;
onOpenChange: (open: boolean) => void;
saveCategoryAction: CategoryAction;
removeCategoryAction: CategoryDeleteAction;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{copy.editCategory}</DialogTitle>
<DialogDescription>{copy.editDescription}</DialogDescription>
</DialogHeader>
<form id={`portfolio-category-form-${category.id}`} action={saveCategoryAction} className="space-y-6">
<input type="hidden" name="id" value={category.id} />
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
<div className="grid gap-4 lg:grid-cols-3">
<div className="space-y-2">
<Label htmlFor={`slug-${category.id}`}>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 className="pl-9" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`sortOrder-${category.id}`}>{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"
type="number"
min="0"
defaultValue={category.sortOrder}
required
className="pl-9"
/>
</div>
</div>
<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>
</div>
<CategoryLocaleFields
idPrefix={`category-${category.id}`}
values={{
nameAr: category.name.ar,
nameEn: category.name.en,
nameDe: category.name.de,
descriptionAr: category.description.ar,
descriptionEn: category.description.en,
descriptionDe: category.description.de,
}}
/>
</form>
<DialogFooter className="items-center justify-between sm:flex-row">
<p className="text-sm text-muted-foreground">
{category.projectCount > 0 ? copy.deleteBlocked : "Kategorie kann geloescht werden."}
</p>
<div className="flex w-full flex-col-reverse gap-2 sm:w-auto sm:flex-row">
<form action={removeCategoryAction}>
<input type="hidden" name="id" value={category.id} />
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
<Button type="submit" variant="destructive" disabled={category.projectCount > 0}>
<Trash2 className="h-4 w-4" />
{copy.delete}
</Button>
</form>
<Button type="submit" form={`portfolio-category-form-${category.id}`}>
{copy.save}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function PortfolioCategoriesManager({
categories,
activeCount,
assignedProjects,
saveCategoryAction,
removeCategoryAction,
}: PortfolioCategoriesManagerProps) {
const searchParams = useSearchParams();
const [createOpen, setCreateOpen] = useState(false);
const [editingCategoryId, setEditingCategoryId] = useState<string | null>(null);
useEffect(() => {
if (searchParams.has("success")) {
setCreateOpen(false);
setEditingCategoryId(null);
}
}, [searchParams]);
return (
<div className="space-y-6">
<AppCard level={3} className="bg-secondary">
<CardContent className="flex flex-col gap-6 p-6 lg:flex-row lg:items-end lg:justify-between lg:p-8">
<div className="grid gap-4 sm:grid-cols-3">
<div className="rounded-lg border border-input bg-background px-4 py-4">
<p className="text-xs uppercase tracking-[0.12em] text-muted-foreground">Total</p>
<p className="mt-2 text-3xl font-semibold text-foreground">{categories.length}</p>
</div>
<div className="rounded-lg border border-input bg-background px-4 py-4">
<p className="text-xs uppercase tracking-[0.12em] text-muted-foreground">Active</p>
<p className="mt-2 text-3xl font-semibold text-foreground">{activeCount}</p>
</div>
<div className="rounded-lg border border-input bg-background px-4 py-4">
<p className="text-xs uppercase tracking-[0.12em] text-muted-foreground">Assigned</p>
<p className="mt-2 text-3xl font-semibold text-foreground">{assignedProjects}</p>
</div>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button>
<FolderPlus className="h-4 w-4" />
{copy.addCategory}
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{copy.addCategory}</DialogTitle>
<DialogDescription>{copy.modalDescription}</DialogDescription>
</DialogHeader>
<form id="portfolio-category-create-form" action={saveCategoryAction} className="space-y-6">
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
<div className="grid gap-4 lg:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="create-slug">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>
<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 className="pl-9" />
</div>
</div>
<label className="flex items-center gap-3 rounded-md border border-input bg-card px-4 py-3 text-sm">
<Checkbox name="isActive" defaultChecked />
{copy.active}
</label>
</div>
<CategoryLocaleFields idPrefix="create" />
<DialogFooter>
<Button type="submit">{copy.saveCategory}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</CardContent>
</AppCard>
<AppCard className="bg-secondary">
<CardContent className="space-y-4 p-6">
<div className="flex items-center gap-3">
<Layers3 className="h-5 w-5 text-brand-primary" />
<h2 className="text-lg font-semibold text-foreground">{copy.currentCategories}</h2>
</div>
{categories.length === 0 ? (
<p className="text-sm text-muted-foreground">{copy.empty}</p>
) : (
<Accordion type="single" collapsible className="space-y-3">
{categories.map((category) => (
<AccordionItem key={category.id} value={category.id}>
<AccordionTrigger className="bg-secondary hover:no-underline">
<div className="flex min-w-0 flex-1 flex-col gap-2 text-left lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-base font-semibold text-foreground">
{category.name.de || category.name.en || category.name.ar}
</span>
<Badge variant={category.isActive ? "success" : "warning"}>
{category.isActive ? "Aktiv" : "Inaktiv"}
</Badge>
</div>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>{category.slug}</span>
<span></span>
<span>{copy.sortOrder} {category.sortOrder}</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{category.projectCount} {copy.projects}</Badge>
<Button
type="button"
variant="outline"
size="sm"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setEditingCategoryId(category.id);
}}
>
<Pencil className="h-3.5 w-3.5" />
{copy.editCategory}
</Button>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="bg-background">
<div className="space-y-3">
<div className="grid gap-3 md:grid-cols-3">
{locales.map((locale) => (
<div key={`${category.id}-${locale.key}`} className="rounded-lg border border-input bg-card p-4">
<p className="text-sm font-medium text-foreground">{locale.label}</p>
<p className="mt-3 text-sm text-foreground">{category.name[locale.lowerKey]}</p>
<p className="mt-2 text-sm text-muted-foreground">{category.description[locale.lowerKey]}</p>
</div>
))}
</div>
</div>
</AccordionContent>
<EditCategoryDialog
category={category}
open={editingCategoryId === category.id}
onOpenChange={(open) => setEditingCategoryId(open ? category.id : null)}
saveCategoryAction={saveCategoryAction}
removeCategoryAction={removeCategoryAction}
/>
</AccordionItem>
))}
</Accordion>
)}
</CardContent>
</AppCard>
</div>
);
}
File diff suppressed because it is too large Load Diff
+57 -178
View File
@@ -1,8 +1,7 @@
import { Boxes, ExternalLink, Filter, FolderKanban, Layers3, Plus, Tags } from "lucide-react";
import { ExternalLink, Filter, FolderKanban, 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";
@@ -26,76 +25,45 @@ type PortfolioProjectsOverviewProps = {
};
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",
filter: "Filtern",
newProject: "Neues Projekt",
newCategory: "Neues Kategorie",
openProject: "Ansehen",
editProject: "Bearbeiten",
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",
empty: "Noch keine Projekte vorhanden.",
};
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 className="grid gap-4 sm:grid-cols-3">
<div className="rounded-lg border border-input bg-background px-4 py-4">
<p className="text-xs uppercase tracking-[0.12em] text-muted-foreground">Projects</p>
<p className="mt-2 text-3xl font-semibold text-foreground">{projects.length}</p>
</div>
<div className="rounded-lg border border-input bg-background px-4 py-4">
<p className="text-xs uppercase tracking-[0.12em] text-muted-foreground">Categories</p>
<p className="mt-2 text-3xl font-semibold text-foreground">{categories.length}</p>
</div>
<div className="rounded-lg border border-input bg-background px-4 py-4">
<p className="text-xs uppercase tracking-[0.12em] text-muted-foreground">Published</p>
<p className="mt-2 text-3xl font-semibold text-foreground">
{projects.filter((project) => project.isPublished).length}
</p>
</div>
</div>
@@ -117,45 +85,7 @@ export function PortfolioProjectsOverview({
</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}>
<MotionFade delay={0.1}>
<AppCard>
<CardContent className="p-6">
<form className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
@@ -211,104 +141,53 @@ export function PortfolioProjectsOverview({
</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}
{projects.map((project, index) => (
<MotionFade key={project.id} delay={0.14 + index * 0.03}>
<AppCard interactive>
<CardHeader className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1">
<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>
</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 className="text-sm text-muted-foreground">
{project.category.name.de}
</p>
</div>
<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}
<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 variant="outline">
<Link href="/root/portfolio/categories">
<Tags className="h-4 w-4" />
{copy.newCategory}
<Button asChild>
<Link href={`/root/portfolio/projects/${project.id}`}>
<FolderKanban className="h-4 w-4" />
{copy.editProject}
</Link>
</Button>
</div>
</CardContent>
</CardHeader>
</AppCard>
</MotionFade>
))}
{projects.length === 0 ? (
<AppCard>
<CardContent className="p-6 text-sm text-muted-foreground">
{copy.empty}
</CardContent>
</AppCard>
) : null}
</div>
</div>
@@ -34,7 +34,7 @@ export function SidebarMaintenanceControl({
<label
htmlFor="sidebar-maintenance-enabled"
className={cn(
"flex cursor-pointer items-center justify-between gap-3 rounded-md border px-3 py-2 transition-colors",
"flex cursor-pointer items-center justify-between gap-3 rounded-nested border px-3 py-2 transition-colors",
enabled
? "border-status-warning/40 bg-status-warning-soft/80"
: "border-input bg-card hover:bg-accent/20",
@@ -43,7 +43,7 @@ export function SidebarMaintenanceControl({
<span className="flex min-w-0 items-center gap-3">
<span
className={cn(
"flex h-9 w-9 items-center justify-center rounded-full border",
"flex h-9 w-9 items-center justify-center rounded-pill border",
enabled
? "border-status-warning/40 bg-status-warning-soft text-status-warning"
: "border-border bg-background text-muted-foreground",
+107 -2
View File
@@ -117,6 +117,20 @@ export function SiteSettingsForm({
mediaOptions,
}: SiteSettingsFormProps) {
const [settings, setSettings] = useState(initialSettings);
const [siteLogoLight, setSiteLogoLight] = useState<MediaFieldState>(
createImageFieldState(
initialBindings.siteLogoLight?.assetId,
initialBindings.siteLogoLight?.url,
"Site Logo Light",
),
);
const [siteLogoDark, setSiteLogoDark] = useState<MediaFieldState>(
createImageFieldState(
initialBindings.siteLogoDark?.assetId,
initialBindings.siteLogoDark?.url,
"Site Logo Dark",
),
);
const [favicon, setFavicon] = useState<MediaFieldState>(
createImageFieldState(
initialBindings.favicon?.assetId,
@@ -131,6 +145,16 @@ export function SiteSettingsForm({
"Default OG Image",
),
);
const siteLogoLightPreviewUrl = getMediaPreviewUrl(
siteLogoLight,
mediaOptions,
initialBindings.siteLogoLight?.url,
);
const siteLogoDarkPreviewUrl = getMediaPreviewUrl(
siteLogoDark,
mediaOptions,
initialBindings.siteLogoDark?.url,
);
const faviconPreviewUrl = getMediaPreviewUrl(favicon, mediaOptions, initialBindings.favicon?.url);
const defaultOgImagePreviewUrl = getMediaPreviewUrl(
defaultOgImage,
@@ -295,12 +319,54 @@ export function SiteSettingsForm({
<AppCard>
<CardHeader>
<CardTitle>Preview Images</CardTitle>
<CardTitle>Brand And Preview Images</CardTitle>
<CardDescription>
Favicon erscheint im Browser. Das Default OG Bild wird fuer Social Sharing genutzt, wenn eine Seite kein eigenes Bild liefert.
Logo erscheint im Header. Favicon erscheint im Browser. Das Default OG Bild wird fuer Social Sharing genutzt, wenn eine Seite kein eigenes Bild liefert.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-6 xl:grid-cols-2">
<MediaFieldPicker
title="Site Logo Light"
value={siteLogoLight}
onChange={setSiteLogoLight}
options={mediaOptions}
hasInitialValue={Boolean(initialBindings.siteLogoLight?.assetId || initialBindings.siteLogoLight?.url)}
inputName="siteLogoLightMedia"
fileFieldName="siteLogoLightFile"
fileLabel="Upload Light Logo"
libraryLabel="Light Logo Library"
accept="image/*,.svg"
allowExternal={false}
allowClear
clearLabel="Remove Light Logo"
emptyValue={{
mode: "upload",
assetId: "",
url: "",
}}
/>
<MediaFieldPicker
title="Site Logo Dark"
value={siteLogoDark}
onChange={setSiteLogoDark}
options={mediaOptions}
hasInitialValue={Boolean(initialBindings.siteLogoDark?.assetId || initialBindings.siteLogoDark?.url)}
inputName="siteLogoDarkMedia"
fileFieldName="siteLogoDarkFile"
fileLabel="Upload Dark Logo"
libraryLabel="Dark Logo Library"
accept="image/*,.svg"
allowExternal={false}
allowClear
clearLabel="Remove Dark Logo"
emptyValue={{
mode: "upload",
assetId: "",
url: "",
}}
/>
<MediaFieldPicker
title="Favicon"
value={favicon}
@@ -380,6 +446,45 @@ export function SiteSettingsForm({
})}
</div>
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<ImageIcon className="h-4 w-4 text-brand-primary" />
Header Logo Preview
</div>
<div className="grid gap-3">
<div className="rounded-md border bg-card p-4">
<p className="mb-3 text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
Light
</p>
{siteLogoLightPreviewUrl ? (
<img
src={siteLogoLightPreviewUrl}
alt="Site Logo Light"
className="h-10 w-auto max-w-full object-contain"
/>
) : (
<div className="flex h-10 items-center text-sm text-muted-foreground">
Default light logo will be used
</div>
)}
</div>
<div className="rounded-md border border-border/70 bg-slate-950 p-4">
<p className="mb-3 text-xs font-medium uppercase tracking-[0.2em] text-white/55">
Dark
</p>
{siteLogoDarkPreviewUrl ? (
<img
src={siteLogoDarkPreviewUrl}
alt="Site Logo Dark"
className="h-10 w-auto max-w-full object-contain"
/>
) : (
<div className="flex h-10 items-center text-sm text-white/60">
Default dark logo will be used
</div>
)}
</div>
</div>
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<Search className="h-4 w-4 text-brand-primary" />
Search Preview
+123
View File
@@ -0,0 +1,123 @@
import type { ReactNode } from "react";
import { AppCard } from "@/components/ui/app-card";
import { Badge } from "@/components/ui/badge";
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
export function WorkspaceHero({
eyebrow,
title,
description,
aside,
}: {
eyebrow: string;
title: string;
description: string;
aside?: ReactNode;
}) {
return (
<AppCard level={3}>
<CardContent className="flex flex-col gap-4 p-6 lg:flex-row lg:items-end lg:justify-between lg:p-8">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
{eyebrow}
</p>
<h2 className="text-2xl font-semibold text-foreground">{title}</h2>
<p className="max-w-3xl text-sm text-muted-foreground">{description}</p>
</div>
{aside ? <div className="flex flex-wrap gap-2">{aside}</div> : null}
</CardContent>
</AppCard>
);
}
export function WorkspaceSidebarPanel({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
return (
<AppCard level={2}>
<CardHeader className="pb-4">
<CardTitle className="text-base">{title}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">{children}</CardContent>
</AppCard>
);
}
export function WorkspaceStepButton({
active,
completed,
index,
label,
eyebrow,
onClick,
completeIcon,
}: {
active: boolean;
completed: boolean;
index: number;
label: string;
eyebrow: string;
onClick: () => void;
completeIcon?: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex w-full items-start gap-3 rounded-surface border px-4 py-3 text-left transition-colors",
active
? "border-input bg-accent/40"
: "border-input bg-background hover:bg-accent/20",
)}
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-pill border border-input bg-background text-sm font-semibold text-foreground">
{completed ? completeIcon ?? "OK" : index + 1}
</div>
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{eyebrow}
</p>
<p className="mt-1 text-sm font-medium text-foreground">{label}</p>
</div>
</button>
);
}
export function WorkspaceLocaleCard({
title,
hint,
children,
}: {
title: string;
hint: string;
children: ReactNode;
}) {
return (
<AppCard level={2}>
<CardHeader className="pb-4">
<CardTitle className="text-lg">{title}</CardTitle>
<CardDescription>{hint}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">{children}</CardContent>
</AppCard>
);
}
export function WorkspaceStatusBadge({
done,
doneLabel = "Ready",
openLabel = "Open",
}: {
done: boolean;
doneLabel?: string;
openLabel?: string;
}) {
return <Badge variant={done ? "success" : "warning"}>{done ? doneLabel : openLabel}</Badge>;
}
+4 -2
View File
@@ -12,6 +12,7 @@ type ThemeToggleProps = {
label?: string;
variant?: ButtonProps["variant"];
className?: string;
iconClassName?: string;
};
export function ThemeToggle({
@@ -19,6 +20,7 @@ export function ThemeToggle({
label,
variant = "outline",
className,
iconClassName,
}: ThemeToggleProps) {
const { setTheme, theme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
@@ -36,7 +38,7 @@ export function ThemeToggle({
aria-label={ariaLabel}
className={cn(label ? "justify-start" : undefined, className)}
>
<Moon className="h-4 w-4" />
<Moon className={cn("h-4 w-4", iconClassName)} />
{label ? <span>{label}</span> : null}
</Button>
);
@@ -54,7 +56,7 @@ export function ThemeToggle({
aria-label={ariaLabel}
className={cn(label ? "justify-start" : undefined, className)}
>
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
{isDark ? <Sun className={cn("h-4 w-4", iconClassName)} /> : <Moon className={cn("h-4 w-4", iconClassName)} />}
{label ? <span>{label}</span> : null}
</Button>
);
+63
View File
@@ -0,0 +1,63 @@
"use client";
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("rounded-surface border border-input bg-card", className)}
{...props}
/>
));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between gap-4 px-5 py-4 text-left text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className={cn(
"overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
className,
)}
{...props}
>
<div className="border-t border-border px-5 py-5">{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
+5 -5
View File
@@ -5,14 +5,14 @@ import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils";
const appCardVariants = cva(
"rounded-lg transition-colors",
"rounded-surface transition-colors",
{
variants: {
level: {
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",
1: "border border-border/80 bg-card text-card-foreground shadow-card",
2: "border border-border/80 bg-card text-card-foreground shadow-card",
3: "border border-border/80 bg-card text-card-foreground shadow-panel",
inverse: "border-transparent bg-foreground text-background shadow-card",
},
padding: {
none: "",
+4 -4
View File
@@ -5,13 +5,13 @@ 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-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",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-nested 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 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",
default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/92",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/90",
outline: "border border-input bg-background hover:border-border-strong 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 hover:bg-destructive/90",
+1 -1
View File
@@ -7,7 +7,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
"rounded-surface border border-border/80 bg-card text-card-foreground shadow-card",
className,
)}
{...props}
+104
View File
@@ -0,0 +1,104 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-foreground/30 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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 sm:w-full sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.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>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
);
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props} />
);
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
};
+6 -6
View File
@@ -22,7 +22,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
<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",
"flex cursor-default select-none items-center rounded-nested px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className,
)}
@@ -42,7 +42,7 @@ const DropdownMenuSubContent = React.forwardRef<
<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",
"z-50 min-w-[8rem] overflow-hidden rounded-surface border border-border/80 bg-popover p-1 text-popover-foreground shadow-panel",
className,
)}
{...props}
@@ -60,7 +60,7 @@ const DropdownMenuContent = React.forwardRef<
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",
"z-50 min-w-[12rem] overflow-hidden rounded-surface border border-border/80 bg-popover p-1 text-popover-foreground shadow-panel",
className,
)}
{...props}
@@ -79,7 +79,7 @@ const DropdownMenuItem = React.forwardRef<
<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",
"relative flex cursor-default select-none items-center rounded-nested 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,
)}
@@ -96,7 +96,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<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",
"relative flex cursor-default select-none items-center rounded-nested 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}
@@ -121,7 +121,7 @@ const DropdownMenuRadioItem = React.forwardRef<
<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",
"relative flex cursor-default select-none items-center rounded-nested 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}
+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-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",
"flex h-10 w-full rounded-nested border border-input bg-background px-3 py-2 text-base text-foreground shadow-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
+6 -6
View File
@@ -55,7 +55,7 @@ const SelectTrigger = React.forwardRef<
<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",
"flex h-10 w-full items-center justify-between rounded-nested border border-input bg-background px-3 py-2 text-sm shadow-xs ring-offset-background placeholder:text-muted-foreground focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/30 focus:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
@@ -105,9 +105,9 @@ const SelectContent = React.forwardRef<
>(({ 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",
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[10rem] overflow-y-auto overflow-x-hidden rounded-surface border border-input bg-background text-foreground shadow-panel 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,
@@ -151,8 +151,8 @@ const SelectItem = React.forwardRef<
>(({ 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={cn(
"relative flex w-full cursor-default select-none items-center rounded-nested py-2 pl-8 pr-3 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
+2 -2
View File
@@ -44,7 +44,7 @@ function TabsList({
return (
<div
className={cn(
"inline-flex h-auto flex-wrap items-center gap-2 rounded-lg border bg-muted/40 p-1.5",
"inline-flex h-auto flex-wrap items-center gap-2 rounded-surface border border-border/80 bg-muted/40 p-1.5",
className,
)}
{...props}
@@ -69,7 +69,7 @@ function TabsTrigger({
<button
type="button"
className={cn(
"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",
"inline-flex items-center justify-center rounded-nested 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-0 disabled:pointer-events-none disabled:opacity-50",
isActive && "bg-background text-foreground shadow-sm",
className,
)}
+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-[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",
"flex min-h-[80px] w-full rounded-nested border border-input bg-background px-3 py-2 text-base text-foreground shadow-xs ring-offset-background placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
+57 -2
View File
@@ -53,6 +53,7 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
surfaces: "Surfaces",
typography: "Typography",
headers: "Headers",
workspace: "Workspace",
cardsDesc:
"Aussenflaechen, innere Karten und verschachtelte Ebenen mit einheitlicher Hierarchie.",
buttonsDesc:
@@ -67,6 +68,8 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
"Typografische Grundstufen fuer Seiten, Sektionen und Metainformationen.",
headersDesc:
"Header Muster fuer Seiten und Sektionen innerhalb des Systems.",
workspaceDesc:
"Editor Muster mit breitem Inhaltsbereich und schmaler Kontextspalte fuer Admin Oberflaechen.",
tabsDesc:
"Tabs bleiben in einer Route und verwenden dieselbe verschachtelte Steuerlogik.",
outerCard: "Aeussere Card",
@@ -128,7 +131,12 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
outerSurfaceHeaderText: "Dieses Muster wird fuer Seitensektionen mit staerkerer Hierarchie genutzt.",
sectionHeader: "Abschnittsheader",
nestedBlockHeading: "Ueberschrift des verschachtelten Blocks",
nestedBlockHeadingText: "Innerhalb einer aeusseren Card verwenden, wenn Inhalte bereits gruppiert sind."
nestedBlockHeadingText: "Innerhalb einer aeusseren Card verwenden, wenn Inhalte bereits gruppiert sind.",
workspaceHeader: "Editor Workspace",
workspaceMain: "Breiter Inhaltsbereich",
workspaceMainText: "Hier liegen Formschritte, Listen und eigentliche Bearbeitung.",
workspaceSide: "Kontextspalte",
workspaceSideText: "Status, Checklisten und Fokusdetails bleiben immer sichtbar."
}
: {
title: "UI Kit",
@@ -142,6 +150,7 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
surfaces: "Surfaces",
typography: "Typography",
headers: "Headers",
workspace: "Workspace",
cardsDesc:
"Outer surfaces, inner cards, and nested levels with one consistent hierarchy.",
buttonsDesc:
@@ -156,6 +165,8 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
"Typography foundations for pages, sections, and supporting copy.",
headersDesc:
"Header patterns for pages and sections inside the system.",
workspaceDesc:
"Editor pattern with a wide content area and a narrow context rail for admin workflows.",
tabsDesc:
"Tabs stay within one route and use the same nested control logic.",
outerCard: "Outer card",
@@ -217,7 +228,12 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
outerSurfaceHeaderText: "This pattern is used for page-level sections that need stronger hierarchy.",
sectionHeader: "Section header",
nestedBlockHeading: "Nested block heading",
nestedBlockHeadingText: "Use inside an outer card when content is already grouped."
nestedBlockHeadingText: "Use inside an outer card when content is already grouped.",
workspaceHeader: "Editor workspace",
workspaceMain: "Wide content area",
workspaceMainText: "This area carries form steps, lists, and direct editing work.",
workspaceSide: "Context rail",
workspaceSideText: "Status, checklists, and focus details stay visible at all times."
};
return (
@@ -249,6 +265,7 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
<TabsTrigger value="surfaces">{copy.surfaces}</TabsTrigger>
<TabsTrigger value="typography">{copy.typography}</TabsTrigger>
<TabsTrigger value="headers">{copy.headers}</TabsTrigger>
<TabsTrigger value="workspace">{copy.workspace}</TabsTrigger>
</TabsList>
<TabsContent value="cards">
@@ -516,6 +533,44 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
</div>
</UiKitSection>
</TabsContent>
<TabsContent value="workspace">
<UiKitSection title={copy.workspace} description={copy.workspaceDesc}>
<div className="grid gap-6 xl:grid-cols-3">
<AppCard level={2} className="xl:col-span-2">
<CardContent className="space-y-4 p-6">
<div>
<p className="text-sm font-medium text-muted-foreground">{copy.workspaceMain}</p>
<h3 className="mt-2 text-xl font-semibold text-foreground">{copy.workspaceHeader}</h3>
</div>
<p className="text-sm text-muted-foreground">{copy.workspaceMainText}</p>
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-lg border border-input bg-background p-4 text-sm text-muted-foreground">
Step 1
</div>
<div className="rounded-lg border border-input bg-background p-4 text-sm text-muted-foreground">
Step 2
</div>
</div>
</CardContent>
</AppCard>
<AppCard level={2}>
<CardContent className="space-y-4 p-6">
<p className="text-sm font-medium text-muted-foreground">{copy.workspaceSide}</p>
<p className="text-sm text-muted-foreground">{copy.workspaceSideText}</p>
<div className="space-y-3">
<Badge variant="success">Ready</Badge>
<Badge variant="warning">Open</Badge>
<div className="rounded-lg border border-input bg-background p-4 text-sm text-muted-foreground">
Checklist / status / focus details
</div>
</div>
</CardContent>
</AppCard>
</div>
</UiKitSection>
</TabsContent>
</Tabs>
</div>
);