Finalize multilingual routing and translations
CI / quality (push) Waiting to run

This commit is contained in:
MOH
2026-03-07 14:10:30 +01:00
parent 1ad9ae9629
commit 7f4277d1d7
53 changed files with 2805 additions and 1382 deletions
-44
View File
@@ -1,44 +0,0 @@
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
const navItems = [
{ key: "home", path: "" },
{ key: "portfolio", path: "/portfolio" },
{ key: "products", path: "/products" },
{ key: "about", path: "/about" },
{ key: "contact", path: "/contact" },
];
type FooterProps = {
isAdmin?: boolean;
};
export function Footer({ isAdmin = false }: FooterProps) {
const locale = useLocale();
const tNav = useTranslations("navigation");
const tFooter = useTranslations("footer");
return (
<footer className="border-t border-default bg-surface-soft">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-4 py-8 sm:px-6 lg:px-8">
<nav className="flex flex-wrap gap-4 text-sm">
{navItems.map((item) => (
<Link
key={item.key}
href={`/${locale}${item.path}`}
className="text-muted transition hover:text-fg"
>
{tNav(item.key)}
</Link>
))}
{isAdmin ? (
<a href="/root" className="text-muted transition hover:text-fg">
{tNav("root")}
</a>
) : null}
</nav>
<p className="text-xs text-subtle">{tFooter("copyright", { year: new Date().getFullYear() })}</p>
</div>
</footer>
);
}
+30
View File
@@ -0,0 +1,30 @@
import type { ReactNode } from "react";
import { AppCard } from "@/components/ui/app-card";
import { CardContent } from "@/components/ui/card";
type AppHeaderProps = {
title: string;
description?: string;
actions?: ReactNode;
};
export function AppHeader({ title, description, actions }: AppHeaderProps) {
return (
<AppCard level={3}>
<CardContent className="flex flex-wrap items-start justify-between gap-5 p-6 lg:p-8">
<div className="space-y-2">
<h1 className="text-3xl font-semibold tracking-tight text-foreground sm:text-4xl">
{title}
</h1>
{description ? (
<p className="max-w-3xl text-sm text-muted-foreground sm:text-base">
{description}
</p>
) : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
</CardContent>
</AppCard>
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { ReactNode } from "react";
import { Container } from "@/components/layout/container";
import { cn } from "@/lib/utils";
type AppShellProps = {
sidebar: ReactNode;
header: ReactNode;
children: ReactNode;
className?: string;
};
export function AppShell({ sidebar, header, children, className }: AppShellProps) {
return (
<div className="min-h-screen bg-background py-6 lg:py-8">
<Container size="admin">
<div className="flex gap-6">
<aside className="hidden w-72 shrink-0 md:block">{sidebar}</aside>
<div className={cn("flex min-w-0 flex-1 flex-col gap-6", className)}>
{header}
<main>{children}</main>
</div>
</div>
</Container>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
import Link from "next/link";
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { AppCard } from "@/components/ui/app-card";
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
type SidebarItem = {
label: string;
href: string;
icon: LucideIcon;
active?: boolean;
};
type AppSidebarProps = {
title: string;
description: string;
items: SidebarItem[];
footer?: ReactNode;
};
export function AppSidebar({
title,
description,
items,
footer,
}: AppSidebarProps) {
return (
<AppCard className="sticky top-6 overflow-hidden bg-sidebar text-sidebar-foreground shadow-sidebar">
<CardHeader className="pb-4">
<CardTitle className="text-base font-semibold">{title}</CardTitle>
<p className="text-sm text-sidebar-foreground/72">{description}</p>
</CardHeader>
<CardContent className="space-y-1 px-3 pb-3 pt-0">
{items.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.label}
href={item.href}
className={cn(
"flex items-center gap-2 rounded-nested px-3 py-2 text-sm transition-colors",
item.active
? "bg-sidebar-primary text-sidebar-primary-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
)}
>
<Icon className="h-4 w-4" />
<span>{item.label}</span>
</Link>
);
})}
</CardContent>
{footer ? <div className="border-t border-sidebar-border px-4 py-3">{footer}</div> : null}
</AppCard>
);
}
+43
View File
@@ -0,0 +1,43 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const containerVariants = cva("mx-auto w-full px-4 sm:px-6 lg:px-8", {
variants: {
size: {
default: "max-w-layout",
narrow: "max-w-narrow",
wide: "max-w-wide",
admin: "max-w-admin",
},
},
defaultVariants: {
size: "default",
},
});
export interface ContainerProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof containerVariants> {
asChild?: boolean;
}
const Container = React.forwardRef<HTMLDivElement, ContainerProps>(
({ className, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div";
return (
<Comp
ref={ref}
className={cn(containerVariants({ size }), className)}
{...props}
/>
);
},
);
Container.displayName = "Container";
export { Container, containerVariants };
+37
View File
@@ -0,0 +1,37 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Button } from "@/components/ui/button";
import { AppLocale, getLocalizedPath, stripLocalePrefix } from "@/lib/locale";
type LocaleToggleProps = {
locale: string;
};
export function LocaleToggle({ locale }: LocaleToggleProps) {
const pathname = usePathname();
const locales: AppLocale[] = ["de", "en", "ar"];
const currentPath = stripLocalePrefix(pathname);
const currentLocale = (["de", "en", "ar"].includes(locale) ? locale : "de") as AppLocale;
return (
<div className="flex items-center gap-2">
{locales.map((targetLocale) => (
<Button
key={targetLocale}
asChild
type="button"
variant={targetLocale === currentLocale ? "secondary" : "outline"}
size="sm"
className="text-xs"
>
<Link href={getLocalizedPath(targetLocale, currentPath)}>
{targetLocale.toUpperCase()}
</Link>
</Button>
))}
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { Container } from "@/components/layout/container";
import { getLocalizedPath } from "@/lib/locale";
const navItems = [
{ key: "home", path: "" },
{ key: "portfolio", path: "/portfolio" },
{ key: "products", path: "/products" },
{ key: "about", path: "/about" },
{ key: "contact", path: "/contact" },
];
type SiteFooterProps = {
isAdmin?: boolean;
};
export function SiteFooter({ isAdmin = false }: SiteFooterProps) {
const locale = useLocale();
const tNav = useTranslations("navigation");
const tFooter = useTranslations("footer");
return (
<footer className="border-t border-border bg-surface-2">
<Container className="flex flex-col gap-4 py-8">
<nav className="flex flex-wrap gap-x-5 gap-y-3 text-sm">
{navItems.map((item) => (
<Link
key={item.key}
href={getLocalizedPath(locale, item.path || "/")}
className="text-muted-foreground hover:text-foreground"
>
{tNav(item.key)}
</Link>
))}
{isAdmin ? (
<a href="/root" className="text-muted-foreground hover:text-foreground">
{tNav("root")}
</a>
) : null}
</nav>
<p className="text-xs text-muted-foreground/80">
{tFooter("copyright", { year: new Date().getFullYear() })}
</p>
</Container>
</footer>
);
}
+132
View File
@@ -0,0 +1,132 @@
"use client";
import { Menu, X } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { useState } from "react";
import { Container } from "@/components/layout/container";
import { LocaleToggle } from "@/components/layout/locale-toggle";
import { ThemeToggle } from "@/components/theme-toggle";
import { Button } from "@/components/ui/button";
import { getLocalizedPath } from "@/lib/locale";
const navItems = [
{ key: "home", path: "" },
{ key: "portfolio", path: "/portfolio" },
{ key: "products", path: "/products" },
{ key: "about", path: "/about" },
{ key: "contact", path: "/contact" },
];
type SiteHeaderProps = {
isAdmin?: boolean;
};
function NavLinks({
isAdmin,
onNavigate,
}: {
isAdmin: boolean;
onNavigate?: () => void;
}) {
const locale = useLocale();
const t = useTranslations("navigation");
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>
))}
{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) {
const [isOpen, setIsOpen] = useState(false);
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">
<Link
href={getLocalizedPath(locale)}
className="inline-flex items-center rounded-nested border border-border bg-surface-1 px-3 py-2 shadow-xs"
>
<Image
src="/logos/light-primary.svg"
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>
<Button
type="button"
onClick={() => setIsOpen((open) => !open)}
variant="outline"
size="icon"
className="md:hidden"
aria-label={isOpen ? t("closeMenu") : t("openMenu")}
>
{isOpen ? <X className="h-4 w-4" /> : <Menu className="h-4 w-4" />}
</Button>
</div>
</Container>
{isOpen ? (
<div className="border-t border-border/80 bg-surface-1 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}
</header>
);
}
-120
View File
@@ -1,120 +0,0 @@
"use client";
import { Menu, X } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { useState } from "react";
import { ThemeToggle } from "@/components/theme-toggle";
import { Button } from "@/src/components/ui/button";
const navItems = [
{ key: "home", path: "" },
{ key: "portfolio", path: "/portfolio" },
{ key: "products", path: "/products" },
{ key: "about", path: "/about" },
{ key: "contact", path: "/contact" },
];
type NavbarProps = {
isAdmin?: boolean;
};
export function Navbar({ isAdmin = false }: NavbarProps) {
const [isOpen, setIsOpen] = useState(false);
const locale = useLocale();
const t = useTranslations("navigation");
return (
<header className="sticky top-0 z-40 border-b border-default bg-surface backdrop-blur">
<div className="mx-auto flex w-full max-w-6xl items-center justify-between gap-4 px-4 py-3 sm:px-6 lg:px-8">
<div className="flex items-center gap-3">
<Link
href={`/${locale}`}
className="inline-flex items-center rounded-md border border-input bg-card px-3 py-2 shadow-xs"
>
<Image
src="/logos/light-primary.svg"
alt="mohfarawati logo"
width={140}
height={24}
className="block h-6 w-auto dark:hidden"
priority
/>
<Image
src="/logos/dark-primary.svg"
alt="mohfarawati logo"
width={140}
height={24}
className="hidden h-6 w-auto dark:block"
priority
/>
</Link>
<nav className="hidden items-center gap-5 md:flex">
{navItems.map((item) => (
<Link
key={item.key}
href={`/${locale}${item.path}`}
className="text-sm text-muted transition hover:text-fg"
>
{t(item.key)}
</Link>
))}
{isAdmin ? (
<a href="/root" className="text-sm text-muted transition hover:text-fg">
{t("root")}
</a>
) : null}
</nav>
</div>
<div className="hidden items-center gap-2 md:flex">
<ThemeToggle />
<Button type="button" variant="outline" size="sm" className="text-xs">
{t("languagePlaceholder")}
</Button>
</div>
<Button
type="button"
onClick={() => setIsOpen((open) => !open)}
variant="outline"
size="icon"
className="md:hidden"
aria-label={isOpen ? t("closeMenu") : t("openMenu")}
>
{isOpen ? <X className="h-4 w-4" /> : <Menu className="h-4 w-4" />}
</Button>
</div>
{isOpen ? (
<div className="border-t border-default px-4 py-4 md:hidden">
<nav className="flex flex-col gap-3">
{navItems.map((item) => (
<Link
key={item.key}
href={`/${locale}${item.path}`}
className="text-sm text-muted"
onClick={() => setIsOpen(false)}
>
{t(item.key)}
</Link>
))}
{isAdmin ? (
<a href="/root" className="text-sm text-muted" onClick={() => setIsOpen(false)}>
{t("root")}
</a>
) : null}
</nav>
<div className="mt-4 flex items-center gap-2">
<ThemeToggle />
<Button type="button" variant="outline" size="sm" className="text-xs">
{t("languagePlaceholder")}
</Button>
</div>
</div>
) : null}
</header>
);
}
+9 -4
View File
@@ -3,9 +3,14 @@
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { Button } from "@/src/components/ui/button";
export function ThemeToggle() {
import { Button } from "@/components/ui/button";
type ThemeToggleProps = {
ariaLabel?: string;
};
export function ThemeToggle({ ariaLabel = "Toggle theme" }: ThemeToggleProps) {
const { setTheme, theme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
@@ -15,7 +20,7 @@ export function ThemeToggle() {
if (!mounted) {
return (
<Button type="button" variant="outline" size="icon" aria-label="Toggle theme">
<Button type="button" variant="outline" size="icon" aria-label={ariaLabel}>
<Moon className="h-4 w-4" />
</Button>
);
@@ -25,7 +30,7 @@ export function ThemeToggle() {
const isDark = activeTheme === "dark";
return (
<Button type="button" variant="outline" size="icon" onClick={() => setTheme(isDark ? "light" : "dark")} aria-label="Toggle theme">
<Button type="button" variant="outline" size="icon" onClick={() => setTheme(isDark ? "light" : "dark")} aria-label={ariaLabel}>
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
);
+59
View File
@@ -0,0 +1,59 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils";
const appCardVariants = cva(
"rounded-surface border transition-all duration-200",
{
variants: {
level: {
1: "border-border bg-surface-1 text-foreground shadow-card",
2: "border-border bg-surface-2 text-foreground shadow-sm",
3: "border-border/90 bg-surface-3 text-foreground shadow-panel",
inverse: "border-transparent bg-surface-inverse text-surface-inverse-foreground shadow-lg",
},
padding: {
none: "",
sm: "p-4",
md: "p-6",
lg: "p-8",
},
interactive: {
true: "hover:-translate-y-0.5 hover:border-border-strong hover:shadow-md",
false: "",
},
},
defaultVariants: {
level: 1,
padding: "none",
interactive: false,
},
},
);
export interface AppCardProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof appCardVariants> {}
const AppCard = React.forwardRef<HTMLDivElement, AppCardProps>(
({ className, level, padding, interactive, ...props }, ref) => (
<Card
ref={ref}
className={cn(
appCardVariants({
level,
padding,
interactive,
}),
className,
)}
{...props}
/>
),
);
AppCard.displayName = "AppCard";
export { AppCard, appCardVariants };
+32
View File
@@ -0,0 +1,32 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-pill border px-2.5 py-1 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
outline: "border-border bg-surface-1 text-foreground",
success: "border-transparent bg-status-success-soft text-status-success",
warning: "border-transparent bg-status-warning-soft text-status-warning",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+55
View File
@@ -0,0 +1,55 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-nested text-sm font-medium ring-offset-background transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-xs hover:brightness-95",
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
outline: "border border-border bg-surface-1 text-foreground shadow-xs hover:border-border-strong hover:bg-surface-2",
ghost: "text-muted-foreground hover:bg-muted hover:text-foreground",
link: "rounded-none text-primary underline-offset-4 hover:underline",
destructive: "bg-destructive text-destructive-foreground shadow-xs hover:brightness-95",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 px-3",
lg: "h-11 px-6",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
+65
View File
@@ -0,0 +1,65 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-surface border border-border bg-surface-1 text-card-foreground shadow-card",
className,
)}
{...props}
/>
),
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col gap-1.5 p-6", className)} {...props} />
),
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
{...props}
/>
),
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
),
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
),
);
CardFooter.displayName = "CardFooter";
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-nested border border-input bg-surface-1 px-3 py-2 text-sm text-foreground ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
),
);
Input.displayName = "Input";
export { Input };
+17
View File
@@ -0,0 +1,17 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Label({
className,
...props
}: React.LabelHTMLAttributes<HTMLLabelElement>) {
return (
<label
className={cn("text-sm font-medium text-foreground/90", className)}
{...props}
/>
);
}
export { Label };
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react";
import { cn } from "@/lib/utils";
type SeparatorProps = React.HTMLAttributes<HTMLDivElement> & {
orientation?: "horizontal" | "vertical";
};
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorProps) {
return (
<div
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
className,
)}
{...props}
/>
);
}
export { Separator };
+114
View File
@@ -0,0 +1,114 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
type TabsContextValue = {
value: string;
setValue: (value: string) => void;
};
const TabsContext = React.createContext<TabsContextValue | null>(null);
function useTabsContext() {
const context = React.useContext(TabsContext);
if (!context) {
throw new Error("Tabs components must be used within Tabs.");
}
return context;
}
type TabsProps = {
defaultValue: string;
children: React.ReactNode;
className?: string;
};
function Tabs({ defaultValue, children, className }: TabsProps) {
const [value, setValue] = React.useState(defaultValue);
return (
<TabsContext.Provider value={{ value, setValue }}>
<div className={cn("w-full", className)}>{children}</div>
</TabsContext.Provider>
);
}
function TabsList({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"inline-flex h-auto flex-wrap items-center gap-2 rounded-surface border border-border bg-surface-2 p-1.5",
className,
)}
{...props}
/>
);
}
type TabsTriggerProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
value: string;
};
function TabsTrigger({
className,
value,
onClick,
...props
}: TabsTriggerProps) {
const { value: activeValue, setValue } = useTabsContext();
const isActive = activeValue === value;
return (
<button
type="button"
className={cn(
"inline-flex items-center justify-center rounded-nested px-3 py-2 text-sm font-medium text-muted-foreground transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
isActive && "bg-surface-1 text-foreground shadow-xs",
className,
)}
onClick={(event) => {
setValue(value);
onClick?.(event);
}}
{...props}
/>
);
}
type TabsContentProps = React.HTMLAttributes<HTMLDivElement> & {
value: string;
};
function TabsContent({
className,
value,
children,
...props
}: TabsContentProps) {
const { value: activeValue } = useTabsContext();
if (activeValue !== value) {
return null;
}
return (
<div
className={cn(
"mt-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
>
{children}
</div>
);
}
export { Tabs, TabsContent, TabsList, TabsTrigger };
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
({ className, ...props }, ref) => (
<textarea
className={cn(
"flex min-h-[112px] w-full rounded-nested border border-input bg-surface-1 px-3 py-2 text-sm text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
),
);
Textarea.displayName = "Textarea";
export { Textarea };
+522
View File
@@ -0,0 +1,522 @@
import { Layers3, Type } from "lucide-react";
import { AppCard } from "@/components/ui/app-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
type UiKitShowcaseProps = {
localeKey: "de" | "en";
};
function UiKitSection({
title,
description,
children,
}: {
title: string;
description: string;
children: React.ReactNode;
}) {
return (
<AppCard>
<CardHeader>
<CardTitle className="text-xl">{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>{children}</CardContent>
</AppCard>
);
}
export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
const copy =
localeKey === "de"
? {
title: "UI Kit",
intro:
"Zentrale Referenz fuer globale Oberflaechen, Steuerelemente und verschachtelte Ebenen.",
cards: "Cards",
buttons: "Buttons",
inputs: "Inputs",
badges: "Badges",
tabs: "Tabs",
surfaces: "Surfaces",
typography: "Typography",
headers: "Headers",
cardsDesc:
"Aussenflaechen, innere Karten und verschachtelte Ebenen mit einheitlicher Hierarchie.",
buttonsDesc:
"Globale Button Varianten und Groessen aus einer zentralen Quelle.",
inputsDesc:
"Gemeinsame Feldzustande mit derselben inneren Radius- und Oberflaechenlogik.",
badgesDesc:
"Kleine Statusmarken mit minimaler, konsistenter Formensprache.",
surfacesDesc:
"Die gesamte Oberflaechenhierarchie basiert auf Level 1 bis 3 und einer inversen Ebene.",
typographyDesc:
"Typografische Grundstufen fuer Seiten, Sektionen und Metainformationen.",
headersDesc:
"Header Muster fuer Seiten und Sektionen innerhalb des Systems.",
tabsDesc:
"Tabs bleiben in einer Route und verwenden dieselbe verschachtelte Steuerlogik.",
outerCard: "Aeussere Card",
surfaceLevel: "Surface Level 1",
innerCard: "Innere Card",
nestedContent: "Verschachtelte Inhalte nutzen den zweiten Radius und eine weichere Oberflaeche.",
nestedSurface: "Verschachtelte Ebene",
nestedSurfaceText: "Mehr Tiefe ohne einen neuen Component Stil zu erfinden.",
interactiveOuterCard: "Interaktive aeussere Card",
hoverText: "Das Hover Verhalten bleibt an dieselbe Oberflaechenebene gebunden.",
inverseOuterCard: "Inverse aeussere Card",
inverseOuterCardText: "Nur verwenden, wenn ein starker Kontrastblock wirklich noetig ist.",
default: "Standard",
secondary: "Sekundaer",
outline: "Outline",
ghost: "Ghost",
destructive: "Destruktiv",
linkButton: "Link Button",
small: "Klein",
large: "Gross",
iconButton: "Icon Button",
disabled: "Deaktiviert",
defaultInput: "Standard Input",
placeholderText: "Platzhaltertext",
filledInput: "Gefuellter Input",
filledValue: "Gefuellter Wert",
disabledInput: "Deaktivierter Input",
disabledState: "Deaktivierter Zustand",
focusPreview: "Focus Vorschau",
focusedLook: "Fokussierte Ansicht",
errorPreview: "Fehler Vorschau",
invalidValue: "Ungueltiger Wert",
textarea: "Textarea",
textareaValue: "Laengerer Feldzustand fuer mehrzeilige Inhalte.",
success: "Erfolg",
warning: "Warnung",
first: "Erste",
second: "Zweite",
third: "Dritte",
firstTabText: "Inhalt des ersten Tabs innerhalb einer Level 1 Oberflaeche.",
secondTabText: "Inhalt des zweiten Tabs mit derselben globalen Hierarchie.",
thirdTabText: "Inhalt des dritten Tabs ohne lokale Sonderstile.",
surfaceOne: "Surface 1",
surfaceOneText: "Aeussere und primaere Container.",
surfaceTwo: "Surface 2",
surfaceTwoText: "Innere und unterstuetzende Panels.",
surfaceThree: "Surface 3",
surfaceThreeText: "Mehr Betonung ohne neue Styling-Zweige.",
inverse: "Inverse",
inverseText: "Reserviert fuer bewusst eingesetzte Kontrastbloecke.",
eyebrow: "Eyebrow",
pageHeading: "Seitenueberschrift",
pageHeadingText: "Begleitender Absatz fuer primaere Seiteneinfuehrungen und Zusammenfassungen.",
sectionHeading: "Abschnittsueberschrift",
sectionHeadingText: "Fuer gruppierte Bereiche innerhalb einer aeusseren Seitenoberflaeche.",
strongSupportingText: "Betonter Begleittext",
pageHeader: "Seitenheader",
outerSurfaceHeader: "Header der aeusseren Oberflaeche",
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."
}
: {
title: "UI Kit",
intro:
"Central reference for global surfaces, controls, and nested interface levels.",
cards: "Cards",
buttons: "Buttons",
inputs: "Inputs",
badges: "Badges",
tabs: "Tabs",
surfaces: "Surfaces",
typography: "Typography",
headers: "Headers",
cardsDesc:
"Outer surfaces, inner cards, and nested levels with one consistent hierarchy.",
buttonsDesc:
"Global button variants and sizes from one shared source of truth.",
inputsDesc:
"Shared field states using the same inner radius and surface logic.",
badgesDesc:
"Small status markers with a minimal and consistent shape language.",
surfacesDesc:
"The full surface hierarchy is built on level 1 through 3 and one inverse layer.",
typographyDesc:
"Typography foundations for pages, sections, and supporting copy.",
headersDesc:
"Header patterns for pages and sections inside the system.",
tabsDesc:
"Tabs stay within one route and use the same nested control logic.",
outerCard: "Outer card",
surfaceLevel: "Surface level 1",
innerCard: "Inner card",
nestedContent: "Nested content uses the secondary radius and a softer surface.",
nestedSurface: "Nested surface",
nestedSurfaceText: "Deeper emphasis without inventing a new component style.",
interactiveOuterCard: "Interactive outer card",
hoverText: "Hover behavior stays attached to the same surface level.",
inverseOuterCard: "Inverse outer card",
inverseOuterCardText: "Use only when a strong contrast block is truly needed.",
default: "Default",
secondary: "Secondary",
outline: "Outline",
ghost: "Ghost",
destructive: "Destructive",
linkButton: "Link button",
small: "Small",
large: "Large",
iconButton: "Icon button",
disabled: "Disabled",
defaultInput: "Default input",
placeholderText: "Placeholder text",
filledInput: "Filled input",
filledValue: "Filled value",
disabledInput: "Disabled input",
disabledState: "Disabled state",
focusPreview: "Focus-style preview",
focusedLook: "Focused look",
errorPreview: "Error-style preview",
invalidValue: "Invalid value",
textarea: "Textarea",
textareaValue: "Longer field state for multiline content.",
success: "Success",
warning: "Warning",
first: "First",
second: "Second",
third: "Third",
firstTabText: "First tab content inside a level 1 surface.",
secondTabText: "Second tab content using the same global hierarchy.",
thirdTabText: "Third tab content without local custom styling.",
surfaceOne: "Surface 1",
surfaceOneText: "Outer and primary containers.",
surfaceTwo: "Surface 2",
surfaceTwoText: "Inner and supporting panels.",
surfaceThree: "Surface 3",
surfaceThreeText: "Raised emphasis without new styling branches.",
inverse: "Inverse",
inverseText: "Reserved for deliberate high-contrast blocks.",
eyebrow: "Eyebrow",
pageHeading: "Page heading",
pageHeadingText: "Supporting paragraph text for primary page introductions and summaries.",
sectionHeading: "Section heading",
sectionHeadingText: "Use this for grouped blocks inside an outer page surface.",
strongSupportingText: "Strong supporting text",
pageHeader: "Page header",
outerSurfaceHeader: "Outer surface header",
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."
};
return (
<div className="flex flex-col gap-section">
<AppCard level={3}>
<CardContent className="flex flex-col gap-4 p-6 lg:p-10">
<div className="inline-flex w-fit items-center gap-2 rounded-pill border border-border bg-surface-1 px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-foreground/80">
<Layers3 className="h-3.5 w-3.5 text-brand-secondary" />
{copy.title}
</div>
<div className="space-y-3">
<h1 className="text-3xl font-semibold tracking-tight text-foreground sm:text-5xl">
{copy.title}
</h1>
<p className="max-w-3xl text-base text-muted-foreground sm:text-lg">
{copy.intro}
</p>
</div>
</CardContent>
</AppCard>
<Tabs defaultValue="cards">
<TabsList>
<TabsTrigger value="cards">{copy.cards}</TabsTrigger>
<TabsTrigger value="buttons">{copy.buttons}</TabsTrigger>
<TabsTrigger value="inputs">{copy.inputs}</TabsTrigger>
<TabsTrigger value="badges">{copy.badges}</TabsTrigger>
<TabsTrigger value="tabs">{copy.tabs}</TabsTrigger>
<TabsTrigger value="surfaces">{copy.surfaces}</TabsTrigger>
<TabsTrigger value="typography">{copy.typography}</TabsTrigger>
<TabsTrigger value="headers">{copy.headers}</TabsTrigger>
</TabsList>
<TabsContent value="cards">
<UiKitSection title={copy.cards} description={copy.cardsDesc}>
<div className="grid gap-6 lg:grid-cols-2">
<AppCard level={1}>
<CardContent className="space-y-4 p-6">
<div>
<p className="text-sm font-medium text-muted-foreground">{copy.outerCard}</p>
<h3 className="mt-1 text-lg font-semibold text-foreground">{copy.surfaceLevel}</h3>
</div>
<AppCard level={2}>
<CardContent className="space-y-3 p-5">
<div>
<p className="text-sm font-medium text-muted-foreground">{copy.innerCard}</p>
<p className="mt-1 text-sm text-muted-foreground">
{copy.nestedContent}
</p>
</div>
<AppCard level={3}>
<CardContent className="space-y-2 p-4">
<p className="text-sm font-medium text-foreground">{copy.nestedSurface}</p>
<p className="text-sm text-muted-foreground">
{copy.nestedSurfaceText}
</p>
</CardContent>
</AppCard>
</CardContent>
</AppCard>
</CardContent>
</AppCard>
<div className="grid gap-4">
<AppCard level={1} interactive>
<CardContent className="p-5">
<p className="text-sm font-medium text-muted-foreground">{copy.interactiveOuterCard}</p>
<p className="mt-2 text-sm text-muted-foreground">
{copy.hoverText}
</p>
</CardContent>
</AppCard>
<AppCard level="inverse">
<CardContent className="p-5">
<p className="text-sm font-medium text-surface-inverse-foreground/80">
{copy.inverseOuterCard}
</p>
<p className="mt-2 text-sm text-surface-inverse-foreground/88">
{copy.inverseOuterCardText}
</p>
</CardContent>
</AppCard>
</div>
</div>
</UiKitSection>
</TabsContent>
<TabsContent value="buttons">
<UiKitSection title={copy.buttons} description={copy.buttonsDesc}>
<div className="grid gap-6 lg:grid-cols-2">
<AppCard level={2}>
<CardContent className="flex flex-wrap gap-3 p-5">
<Button>{copy.default}</Button>
<Button variant="secondary">{copy.secondary}</Button>
<Button variant="outline">{copy.outline}</Button>
<Button variant="ghost">{copy.ghost}</Button>
<Button variant="destructive">{copy.destructive}</Button>
<Button variant="link">{copy.linkButton}</Button>
</CardContent>
</AppCard>
<AppCard level={2}>
<CardContent className="flex flex-wrap items-center gap-3 p-5">
<Button size="sm">{copy.small}</Button>
<Button>{copy.default}</Button>
<Button size="lg">{copy.large}</Button>
<Button size="icon" aria-label={copy.iconButton}>
<Layers3 className="h-4 w-4" />
</Button>
<Button disabled>{copy.disabled}</Button>
</CardContent>
</AppCard>
</div>
</UiKitSection>
</TabsContent>
<TabsContent value="inputs">
<UiKitSection title={copy.inputs} description={copy.inputsDesc}>
<div className="grid gap-6 lg:grid-cols-2">
<AppCard level={2}>
<CardContent className="grid gap-4 p-5">
<Label className="grid gap-2">
{copy.defaultInput}
<Input placeholder={copy.placeholderText} />
</Label>
<Label className="grid gap-2">
{copy.filledInput}
<Input defaultValue={copy.filledValue} />
</Label>
<Label className="grid gap-2">
{copy.disabledInput}
<Input disabled defaultValue={copy.disabledState} />
</Label>
</CardContent>
</AppCard>
<AppCard level={2}>
<CardContent className="grid gap-4 p-5">
<Label className="grid gap-2">
{copy.focusPreview}
<Input
defaultValue={copy.focusedLook}
className="ring-2 ring-ring ring-offset-2"
readOnly
/>
</Label>
<Label className="grid gap-2">
{copy.errorPreview}
<Input
defaultValue={copy.invalidValue}
className="border-destructive text-destructive focus-visible:ring-destructive"
readOnly
/>
</Label>
<Label className="grid gap-2">
{copy.textarea}
<Textarea defaultValue={copy.textareaValue} />
</Label>
</CardContent>
</AppCard>
</div>
</UiKitSection>
</TabsContent>
<TabsContent value="badges">
<UiKitSection title={copy.badges} description={copy.badgesDesc}>
<div className="flex flex-wrap gap-3">
<Badge>{copy.default}</Badge>
<Badge variant="secondary">{copy.secondary}</Badge>
<Badge variant="outline">{copy.outline}</Badge>
<Badge variant="success">{copy.success}</Badge>
<Badge variant="warning">{copy.warning}</Badge>
</div>
</UiKitSection>
</TabsContent>
<TabsContent value="tabs">
<UiKitSection title={copy.tabs} description={copy.tabsDesc}>
<AppCard level={2}>
<CardContent className="space-y-4 p-5">
<Tabs defaultValue="first">
<TabsList>
<TabsTrigger value="first">{copy.first}</TabsTrigger>
<TabsTrigger value="second">{copy.second}</TabsTrigger>
<TabsTrigger value="third">{copy.third}</TabsTrigger>
</TabsList>
<TabsContent value="first">
<AppCard level={1}>
<CardContent className="p-4 text-sm text-muted-foreground">
{copy.firstTabText}
</CardContent>
</AppCard>
</TabsContent>
<TabsContent value="second">
<AppCard level={1}>
<CardContent className="p-4 text-sm text-muted-foreground">
{copy.secondTabText}
</CardContent>
</AppCard>
</TabsContent>
<TabsContent value="third">
<AppCard level={1}>
<CardContent className="p-4 text-sm text-muted-foreground">
{copy.thirdTabText}
</CardContent>
</AppCard>
</TabsContent>
</Tabs>
</CardContent>
</AppCard>
</UiKitSection>
</TabsContent>
<TabsContent value="surfaces">
<UiKitSection title={copy.surfaces} description={copy.surfacesDesc}>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<AppCard level={1}>
<CardContent className="p-5">
<p className="font-medium">{copy.surfaceOne}</p>
<p className="mt-2 text-sm text-muted-foreground">{copy.surfaceOneText}</p>
</CardContent>
</AppCard>
<AppCard level={2}>
<CardContent className="p-5">
<p className="font-medium">{copy.surfaceTwo}</p>
<p className="mt-2 text-sm text-muted-foreground">{copy.surfaceTwoText}</p>
</CardContent>
</AppCard>
<AppCard level={3}>
<CardContent className="p-5">
<p className="font-medium">{copy.surfaceThree}</p>
<p className="mt-2 text-sm text-muted-foreground">{copy.surfaceThreeText}</p>
</CardContent>
</AppCard>
<AppCard level="inverse">
<CardContent className="p-5">
<p className="font-medium text-surface-inverse-foreground">{copy.inverse}</p>
<p className="mt-2 text-sm text-surface-inverse-foreground/88">{copy.inverseText}</p>
</CardContent>
</AppCard>
</div>
</UiKitSection>
</TabsContent>
<TabsContent value="typography">
<UiKitSection title={copy.typography} description={copy.typographyDesc}>
<div className="space-y-6">
<div className="space-y-2">
<p className="text-sm font-medium uppercase tracking-[0.14em] text-muted-foreground">
{copy.eyebrow}
</p>
<h1 className="text-4xl font-semibold tracking-tight text-foreground">
{copy.pageHeading}
</h1>
<p className="max-w-2xl text-base text-muted-foreground">
{copy.pageHeadingText}
</p>
</div>
<AppCard level={2}>
<CardContent className="space-y-3 p-5">
<h2 className="text-2xl font-semibold text-foreground">{copy.sectionHeading}</h2>
<p className="text-sm text-muted-foreground">
{copy.sectionHeadingText}
</p>
<p className="text-sm font-medium text-foreground/82">{copy.strongSupportingText}</p>
</CardContent>
</AppCard>
</div>
</UiKitSection>
</TabsContent>
<TabsContent value="headers">
<UiKitSection title={copy.headers} description={copy.headersDesc}>
<div className="grid gap-6 lg:grid-cols-2">
<AppCard level={3}>
<CardContent className="space-y-3 p-6">
<div className="inline-flex w-fit items-center gap-2 rounded-pill border border-border bg-surface-1 px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-foreground/80">
<Type className="h-3.5 w-3.5 text-brand-primary" />
{copy.pageHeader}
</div>
<h2 className="text-3xl font-semibold tracking-tight text-foreground">
{copy.outerSurfaceHeader}
</h2>
<p className="text-base text-muted-foreground">
{copy.outerSurfaceHeaderText}
</p>
</CardContent>
</AppCard>
<AppCard level={2}>
<CardContent className="space-y-3 p-5">
<p className="text-sm font-medium text-muted-foreground">{copy.sectionHeader}</p>
<h3 className="text-xl font-semibold text-foreground">{copy.nestedBlockHeading}</h3>
<p className="text-sm text-muted-foreground">
{copy.nestedBlockHeadingText}
</p>
</CardContent>
</AppCard>
</div>
</UiKitSection>
</TabsContent>
</Tabs>
</div>
);
}