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
+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>
);
}