Refactor site header, heroes, and design system
This commit is contained in:
@@ -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"}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,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: "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user