Refactor site header, heroes, and design system

This commit is contained in:
MOH
2026-03-08 00:29:51 +01:00
parent a18370d003
commit 0c2b67e378
56 changed files with 2714 additions and 1318 deletions
+5 -13
View File
@@ -3,6 +3,7 @@ import { Compass, Layers3, Users } from "lucide-react";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { Container } from "@/components/layout/container"; import { Container } from "@/components/layout/container";
import { PageHero } from "@/components/layout/page-hero";
import { MotionFade } from "@/components/motion-fade"; import { MotionFade } from "@/components/motion-fade";
import { buildLocalizedMetadata } from "@/lib/metadata"; import { buildLocalizedMetadata } from "@/lib/metadata";
import { resolveLocale } from "@/lib/locale"; import { resolveLocale } from "@/lib/locale";
@@ -34,20 +35,10 @@ export default async function AboutPage({ params: { locale } }: AboutPageProps)
const t = await getTranslations({ locale: localeKey, namespace: "aboutPage" }); const t = await getTranslations({ locale: localeKey, namespace: "aboutPage" });
return ( return (
<Container className="flex flex-col gap-section py-10 lg:py-14"> <>
<MotionFade> <PageHero title={t("title")} description={t("intro")} />
<AppCard level={3}>
<CardContent className="p-6 lg:p-10">
<h1 className="text-3xl font-semibold text-foreground sm:text-4xl">
{t("title")}
</h1>
<p className="mt-4 max-w-3xl text-base text-muted-foreground sm:text-lg">
{t("intro")}
</p>
</CardContent>
</AppCard>
</MotionFade>
<Container className="flex flex-col gap-section pb-12 lg:pb-16">
<MotionFade delay={0.05}> <MotionFade delay={0.05}>
<AppCard> <AppCard>
<CardContent className="p-6 lg:p-8"> <CardContent className="p-6 lg:p-8">
@@ -113,5 +104,6 @@ export default async function AboutPage({ params: { locale } }: AboutPageProps)
</AppCard> </AppCard>
</MotionFade> </MotionFade>
</Container> </Container>
</>
); );
} }
+6 -1
View File
@@ -4,6 +4,7 @@ import Link from "next/link";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { Container } from "@/components/layout/container"; import { Container } from "@/components/layout/container";
import { PageHero } from "@/components/layout/page-hero";
import { MotionFade } from "@/components/motion-fade"; import { MotionFade } from "@/components/motion-fade";
import { buildLocalizedMetadata } from "@/lib/metadata"; import { buildLocalizedMetadata } from "@/lib/metadata";
import { getLocalizedPath, resolveLocale } from "@/lib/locale"; import { getLocalizedPath, resolveLocale } from "@/lib/locale";
@@ -39,7 +40,10 @@ export default async function ContactPage({ params: { locale } }: ContactPagePro
const t = await getTranslations({ locale: localeKey, namespace: "contactPage" }); const t = await getTranslations({ locale: localeKey, namespace: "contactPage" });
return ( return (
<Container className="grid gap-6 py-10 lg:grid-cols-2 lg:py-14"> <>
<PageHero title={t("title")} description={t("intro")} />
<Container className="grid gap-6 pb-12 lg:grid-cols-2 lg:pb-16">
<MotionFade> <MotionFade>
<AppCard level={3}> <AppCard level={3}>
<CardHeader> <CardHeader>
@@ -95,5 +99,6 @@ export default async function ContactPage({ params: { locale } }: ContactPagePro
</AppCard> </AppCard>
</MotionFade> </MotionFade>
</Container> </Container>
</>
); );
} }
+13 -4
View File
@@ -2,10 +2,11 @@ import type { ReactNode } from "react";
import { unstable_noStore as noStore } from "next/cache"; import { unstable_noStore as noStore } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { SiteAmbientBackdrop } from "@/components/layout/site-ambient-backdrop";
import { SiteFooter } from "@/components/layout/site-footer"; import { SiteFooter } from "@/components/layout/site-footer";
import { SiteHeader } from "@/components/layout/site-header"; import { SiteHeader } from "@/components/layout/site-header";
import { isAdminAuthenticated } from "@/lib/admin-auth"; import { isAdminAuthenticated } from "@/lib/admin-auth";
import { getMaintenanceMode } from "@/lib/app-config"; import { getMaintenanceMode, getSiteSettingsMediaBindings } from "@/lib/app-config";
import { getLocalizedPath, resolveLocale } from "@/lib/locale"; import { getLocalizedPath, resolveLocale } from "@/lib/locale";
type SiteLayoutProps = { type SiteLayoutProps = {
@@ -22,7 +23,10 @@ export default async function SiteLayout({ children, params: { locale } }: SiteL
noStore(); noStore();
const localeKey = resolveLocale(locale); const localeKey = resolveLocale(locale);
const maintenanceEnabled = await getMaintenanceMode(); const [maintenanceEnabled, mediaBindings] = await Promise.all([
getMaintenanceMode(),
getSiteSettingsMediaBindings(),
]);
const authenticated = isAdminAuthenticated(); const authenticated = isAdminAuthenticated();
if (maintenanceEnabled && !authenticated) { if (maintenanceEnabled && !authenticated) {
@@ -30,8 +34,13 @@ export default async function SiteLayout({ children, params: { locale } }: SiteL
} }
return ( return (
<div className="flex min-h-screen flex-col"> <div className="relative flex min-h-screen flex-col overflow-hidden">
<SiteHeader isAdmin={authenticated} /> <SiteAmbientBackdrop />
<SiteHeader
isAdmin={authenticated}
lightLogoUrl={mediaBindings.siteLogoLight?.url}
darkLogoUrl={mediaBindings.siteLogoDark?.url}
/>
<main className="flex-1">{children}</main> <main className="flex-1">{children}</main>
<SiteFooter isAdmin={authenticated} /> <SiteFooter isAdmin={authenticated} />
</div> </div>
+12 -41
View File
@@ -4,12 +4,12 @@ import {
BriefcaseBusiness, BriefcaseBusiness,
Boxes, Boxes,
Mail, Mail,
Sparkles,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { Container } from "@/components/layout/container"; import { Container } from "@/components/layout/container";
import { HomeHero } from "@/components/layout/home-hero";
import { MotionFade } from "@/components/motion-fade"; import { MotionFade } from "@/components/motion-fade";
import { getSiteSettings } from "@/lib/app-config"; import { getSiteSettings } from "@/lib/app-config";
import { buildLocalizedMetadata } from "@/lib/metadata"; import { buildLocalizedMetadata } from "@/lib/metadata";
@@ -56,47 +56,17 @@ export default async function HomePage({ params: { locale } }: HomePageProps) {
const t = await getTranslations({ locale: localeKey, namespace: "homepage" }); const t = await getTranslations({ locale: localeKey, namespace: "homepage" });
return ( return (
<Container className="flex flex-col gap-section py-10 lg:py-14"> <>
<MotionFade> <HomeHero
<AppCard level={3}> locale={localeKey}
<CardContent className="grid gap-6 p-6 lg:grid-cols-[1.35fr_0.65fr] lg:p-10"> kicker={t("heroKicker")}
<div className="space-y-5"> title={t("heroTitle")}
<p className="inline-flex items-center gap-2 text-sm font-medium text-muted-foreground"> description={t("heroText")}
<Sparkles className="h-4 w-4 text-brand-secondary" /> portfolioLabel={t("toPortfolio")}
{t("heroKicker")} contactLabel={t("toContact")}
</p> />
<h1 className="text-balance text-3xl font-semibold tracking-tight text-foreground sm:text-5xl">
{t("heroTitle")}
</h1>
<p className="max-w-2xl text-base text-muted-foreground sm:text-lg">
{t("heroText")}
</p>
<div className="flex flex-wrap gap-3">
<Button asChild>
<Link href={getLocalizedPath(localeKey, "/portfolio")}>
{t("toPortfolio")}
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
<Button asChild variant="outline">
<Link href={getLocalizedPath(localeKey, "/products")}>{t("toProducts")}</Link>
</Button>
</div>
</div>
<AppCard level={2} padding="md" className="self-start">
<p className="inline-flex items-center gap-2 text-sm font-medium text-muted-foreground">
<BriefcaseBusiness className="h-4 w-4 text-brand-primary" />
{t("heroCardTitle")}
</p>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
{t("heroCardText")}
</p>
</AppCard>
</CardContent>
</AppCard>
</MotionFade>
<Container className="flex flex-col gap-section pb-12 lg:pb-16">
<MotionFade delay={0.05}> <MotionFade delay={0.05}>
<AppCard> <AppCard>
<CardContent className="p-6 lg:p-8"> <CardContent className="p-6 lg:p-8">
@@ -208,5 +178,6 @@ export default async function HomePage({ params: { locale } }: HomePageProps) {
</AppCard> </AppCard>
</MotionFade> </MotionFade>
</Container> </Container>
</>
); );
} }
+77 -24
View File
@@ -6,6 +6,7 @@ import { getTranslations } from "next-intl/server";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Container } from "@/components/layout/container"; import { Container } from "@/components/layout/container";
import { PageHero } from "@/components/layout/page-hero";
import { MotionFade } from "@/components/motion-fade"; import { MotionFade } from "@/components/motion-fade";
import { buildLocalizedMetadata } from "@/lib/metadata"; import { buildLocalizedMetadata } from "@/lib/metadata";
import { getLocalizedPath, resolveLocale } from "@/lib/locale"; import { getLocalizedPath, resolveLocale } from "@/lib/locale";
@@ -51,6 +52,73 @@ function PortfolioImage({
); );
} }
function renderSectionContent(
section: NonNullable<Awaited<ReturnType<typeof getPublishedPortfolioProjectBySlug>>>["sections"][number],
localeKey: ReturnType<typeof resolveLocale>,
t: Awaited<ReturnType<typeof getTranslations>>,
) {
const title = getLocalizedValue(section.title, localeKey);
const body = getLocalizedValue(section.body, localeKey);
if (section.type === "GALLERY") {
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
{section.imagePath ? (
<PortfolioImage
src={section.imagePath}
alt={title}
width={1200}
height={720}
className="h-56 w-full rounded-md object-cover"
/>
) : (
<div className="rounded-md border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground">
No image configured.
</div>
)}
</div>
);
}
if (section.type === "LINK") {
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
{body ? (
<p className="whitespace-pre-line text-sm text-muted-foreground">{body}</p>
) : null}
{section.linkUrl ? (
<Button asChild variant="outline">
<Link href={section.linkUrl} target="_blank" rel="noreferrer">
{t("openLink")}
<ArrowUpRight className="h-4 w-4" />
</Link>
</Button>
) : null}
</div>
);
}
if (section.type === "STATS" || section.type === "DELIVERABLES") {
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
<div className="rounded-md border border-input bg-background p-4">
<p className="whitespace-pre-line text-sm text-muted-foreground">{body}</p>
</div>
</div>
);
}
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
<p className="whitespace-pre-line text-sm text-muted-foreground">{body}</p>
</div>
);
}
export async function generateMetadata({ export async function generateMetadata({
params: { locale, slug }, params: { locale, slug },
}: PortfolioItemPageProps): Promise<Metadata> { }: PortfolioItemPageProps): Promise<Metadata> {
@@ -87,7 +155,13 @@ export default async function PortfolioItemPage({
const t = await getTranslations({ locale: localeKey, namespace: "portfolioDetail" }); const t = await getTranslations({ locale: localeKey, namespace: "portfolioDetail" });
return ( return (
<Container size="wide" className="flex flex-col gap-section py-10 lg:py-14"> <>
<PageHero
title={getLocalizedValue(item.title, localeKey)}
description={getLocalizedValue(item.summary, localeKey)}
/>
<Container size="wide" className="flex flex-col gap-section pb-12 lg:pb-16">
<MotionFade> <MotionFade>
<AppCard level={3}> <AppCard level={3}>
<CardContent className="p-6 lg:p-10"> <CardContent className="p-6 lg:p-10">
@@ -168,29 +242,7 @@ export default async function PortfolioItemPage({
<MotionFade key={section.id} delay={0.05 * (index + 1)}> <MotionFade key={section.id} delay={0.05 * (index + 1)}>
<AppCard> <AppCard>
<CardContent className="p-5"> <CardContent className="p-5">
<h2 className="text-lg font-semibold text-foreground"> {renderSectionContent(section, localeKey, t)}
{getLocalizedValue(section.title, localeKey)}
</h2>
<p className="mt-2 whitespace-pre-line text-sm text-muted-foreground">
{getLocalizedValue(section.body, localeKey)}
</p>
{section.imagePath ? (
<PortfolioImage
src={section.imagePath}
alt={getLocalizedValue(section.title, localeKey)}
width={1200}
height={720}
className="mt-4 h-48 w-full rounded-md object-cover"
/>
) : null}
{section.linkUrl ? (
<Button asChild variant="outline" className="mt-4">
<Link href={section.linkUrl} target="_blank" rel="noreferrer">
{t("openLink")}
<ArrowUpRight className="h-4 w-4" />
</Link>
</Button>
) : null}
</CardContent> </CardContent>
</AppCard> </AppCard>
</MotionFade> </MotionFade>
@@ -233,5 +285,6 @@ export default async function PortfolioItemPage({
</MotionFade> </MotionFade>
) : null} ) : null}
</Container> </Container>
</>
); );
} }
+6 -17
View File
@@ -1,9 +1,10 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { ArrowUpRight, CalendarDays, FolderKanban } from "lucide-react"; import { ArrowUpRight, CalendarDays } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { Container } from "@/components/layout/container"; import { Container } from "@/components/layout/container";
import { PageHero } from "@/components/layout/page-hero";
import { MotionFade } from "@/components/motion-fade"; import { MotionFade } from "@/components/motion-fade";
import { buildLocalizedMetadata } from "@/lib/metadata"; import { buildLocalizedMetadata } from "@/lib/metadata";
import { AppCard } from "@/components/ui/app-card"; import { AppCard } from "@/components/ui/app-card";
@@ -55,23 +56,10 @@ export default async function PortfolioPage({
]); ]);
return ( return (
<Container className="flex flex-col gap-section py-10 lg:py-14"> <>
<MotionFade> <PageHero title={t("title")} description={t("intro")} />
<AppCard level={3}>
<CardContent className="p-6 lg:p-10">
<div className="flex items-center gap-3">
<FolderKanban className="h-5 w-5 text-brand-primary" />
<h1 className="text-3xl font-semibold text-foreground sm:text-4xl">
{t("title")}
</h1>
</div>
<p className="mt-4 max-w-3xl text-base text-muted-foreground sm:text-lg">
{t("intro")}
</p>
</CardContent>
</AppCard>
</MotionFade>
<Container className="flex flex-col gap-section pb-12 lg:pb-16">
<section className="flex flex-wrap gap-3"> <section className="flex flex-wrap gap-3">
<Link <Link
href={getLocalizedPath(localeKey, "/portfolio")} href={getLocalizedPath(localeKey, "/portfolio")}
@@ -141,5 +129,6 @@ export default async function PortfolioPage({
) : null} ) : null}
</section> </section>
</Container> </Container>
</>
); );
} }
+9 -1
View File
@@ -5,6 +5,7 @@ import { getTranslations } from "next-intl/server";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Container } from "@/components/layout/container"; import { Container } from "@/components/layout/container";
import { PageHero } from "@/components/layout/page-hero";
import { MotionFade } from "@/components/motion-fade"; import { MotionFade } from "@/components/motion-fade";
import { routing } from "@/i18n/routing"; import { routing } from "@/i18n/routing";
import { buildLocalizedMetadata } from "@/lib/metadata"; import { buildLocalizedMetadata } from "@/lib/metadata";
@@ -64,7 +65,13 @@ export default async function ProductPage({ params: { locale, slug } }: ProductP
const t = await getTranslations({ locale: localeKey, namespace: "productDetail" }); const t = await getTranslations({ locale: localeKey, namespace: "productDetail" });
return ( return (
<Container size="wide" className="flex flex-col gap-section py-10 lg:py-14"> <>
<PageHero
title={pickText(item.name, localeKey)}
description={pickText(item.summary, localeKey)}
/>
<Container size="wide" className="flex flex-col gap-section pb-12 lg:pb-16">
<MotionFade> <MotionFade>
<AppCard level={3}> <AppCard level={3}>
<CardContent className="p-6 lg:p-10"> <CardContent className="p-6 lg:p-10">
@@ -136,5 +143,6 @@ export default async function ProductPage({ params: { locale, slug } }: ProductP
</AppCard> </AppCard>
</MotionFade> </MotionFade>
</Container> </Container>
</>
); );
} }
+6 -17
View File
@@ -1,9 +1,10 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { ArrowUpRight, Boxes, CircleDollarSign } from "lucide-react"; import { ArrowUpRight, CircleDollarSign } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { Container } from "@/components/layout/container"; import { Container } from "@/components/layout/container";
import { PageHero } from "@/components/layout/page-hero";
import { MotionFade } from "@/components/motion-fade"; import { MotionFade } from "@/components/motion-fade";
import { buildLocalizedMetadata } from "@/lib/metadata"; import { buildLocalizedMetadata } from "@/lib/metadata";
import { AppCard } from "@/components/ui/app-card"; import { AppCard } from "@/components/ui/app-card";
@@ -36,23 +37,10 @@ export default async function ProductsPage({ params: { locale } }: ProductsPageP
const t = await getTranslations({ locale: localeKey, namespace: "productsPage" }); const t = await getTranslations({ locale: localeKey, namespace: "productsPage" });
return ( return (
<Container className="flex flex-col gap-section py-10 lg:py-14"> <>
<MotionFade> <PageHero title={t("title")} description={t("intro")} />
<AppCard level={3}>
<CardContent className="p-6 lg:p-10">
<div className="flex items-center gap-3">
<Boxes className="h-5 w-5 text-brand-primary" />
<h1 className="text-3xl font-semibold text-foreground sm:text-4xl">
{t("title")}
</h1>
</div>
<p className="mt-4 max-w-3xl text-base text-muted-foreground sm:text-lg">
{t("intro")}
</p>
</CardContent>
</AppCard>
</MotionFade>
<Container className="flex flex-col gap-section pb-12 lg:pb-16">
<section className="grid gap-4 md:grid-cols-3"> <section className="grid gap-4 md:grid-cols-3">
{productItems.map((item, index) => ( {productItems.map((item, index) => (
<MotionFade key={item.slug} delay={index * 0.05}> <MotionFade key={item.slug} delay={index * 0.05}>
@@ -86,5 +74,6 @@ export default async function ProductsPage({ params: { locale } }: ProductsPageP
))} ))}
</section> </section>
</Container> </Container>
</>
); );
} }
+6 -1
View File
@@ -4,6 +4,7 @@ import Link from "next/link";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { Container } from "@/components/layout/container"; import { Container } from "@/components/layout/container";
import { PageHero } from "@/components/layout/page-hero";
import { MotionFade } from "@/components/motion-fade"; import { MotionFade } from "@/components/motion-fade";
import { buildLocalizedMetadata } from "@/lib/metadata"; import { buildLocalizedMetadata } from "@/lib/metadata";
import { getLocalizedPath, resolveLocale } from "@/lib/locale"; import { getLocalizedPath, resolveLocale } from "@/lib/locale";
@@ -36,7 +37,10 @@ export default async function SuccessPage({ params: { locale } }: SuccessPagePro
const t = await getTranslations({ locale: localeKey, namespace: "successPage" }); const t = await getTranslations({ locale: localeKey, namespace: "successPage" });
return ( return (
<Container size="narrow" className="py-12 lg:py-16"> <>
<PageHero title={t("title")} description={t("text")} />
<Container size="narrow" className="pb-12 lg:pb-16">
<MotionFade className="w-full"> <MotionFade className="w-full">
<AppCard level={3}> <AppCard level={3}>
<CardContent className="p-8 text-center"> <CardContent className="p-8 text-center">
@@ -59,5 +63,6 @@ export default async function SuccessPage({ params: { locale } }: SuccessPagePro
</AppCard> </AppCard>
</MotionFade> </MotionFade>
</Container> </Container>
</>
); );
} }
+13 -21
View File
@@ -3,14 +3,13 @@ import { Sparkles } from "lucide-react";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { AnimatedLogo } from "@/components/layout/animated-logo"; import { AnimatedLogo } from "@/components/layout/animated-logo";
import { Container } from "@/components/layout/container";
import { FloatingPreferences } from "@/components/layout/floating-preferences"; import { FloatingPreferences } from "@/components/layout/floating-preferences";
import { HeroAtmosphere } from "@/components/layout/hero-atmosphere";
import { SiteAmbientBackdrop } from "@/components/layout/site-ambient-backdrop";
import { MotionFade } from "@/components/motion-fade"; import { MotionFade } from "@/components/motion-fade";
import { getSiteSettings } from "@/lib/app-config"; import { getSiteSettings } from "@/lib/app-config";
import { buildLocalizedMetadata } from "@/lib/metadata"; import { buildLocalizedMetadata } from "@/lib/metadata";
import { resolveLocale } from "@/lib/locale"; import { resolveLocale } from "@/lib/locale";
import { AppCard } from "@/components/ui/app-card";
import { CardContent } from "@/components/ui/card";
type ComingSoonPageProps = { type ComingSoonPageProps = {
params: { params: {
@@ -41,21 +40,16 @@ export default async function ComingSoonPage({
const t = await getTranslations({ locale: localeKey, namespace: "comingSoon" }); const t = await getTranslations({ locale: localeKey, namespace: "comingSoon" });
return ( return (
<div className="surface-ambient relative min-h-screen overflow-hidden"> <div className="relative min-h-screen overflow-hidden">
<SiteAmbientBackdrop />
<FloatingPreferences locale={localeKey} /> <FloatingPreferences locale={localeKey} />
<Container className="relative flex min-h-screen items-center py-24 sm:py-28"> <section className="relative isolate flex min-h-screen items-center justify-center overflow-hidden px-4 pb-10 pt-24 sm:px-6 sm:pt-28 lg:px-8">
<MotionFade className="w-full"> <HeroAtmosphere />
<AppCard
level={3}
className="mx-auto w-full max-w-4xl overflow-hidden border-border/70 bg-card/95 shadow-sm"
>
<CardContent className="relative p-6 text-center sm:p-10 lg:p-14">
<div className="pointer-events-none absolute -right-20 -top-20 h-48 w-48 rounded-full bg-brand-secondary/15 blur-3xl sm:h-64 sm:w-64" />
<div className="pointer-events-none absolute -bottom-24 -left-16 h-56 w-56 rounded-full bg-brand-primary/15 blur-3xl sm:h-72 sm:w-72" />
<div className="relative"> <MotionFade className="relative z-10 w-full">
<p className="inline-flex items-center gap-2 rounded-full border border-input bg-background px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-foreground/80"> <div className="mx-auto max-w-[56rem] text-center">
<p className="inline-flex items-center gap-2 border border-white/24 bg-[linear-gradient(180deg,rgba(255,255,255,0.2),rgba(255,255,255,0.08))] px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-foreground/78 shadow-[inset_0_1px_0_rgba(255,255,255,0.42),0_18px_44px_-28px_rgba(15,23,42,0.22)] backdrop-blur-[24px] dark:border-white/10 dark:bg-[linear-gradient(180deg,rgba(255,255,255,0.08),rgba(255,255,255,0.03))]">
<Sparkles className="h-3.5 w-3.5 text-brand-secondary" /> <Sparkles className="h-3.5 w-3.5 text-brand-secondary" />
{t("badge")} {t("badge")}
</p> </p>
@@ -64,24 +58,22 @@ export default async function ComingSoonPage({
<AnimatedLogo /> <AnimatedLogo />
</div> </div>
<h1 className="text-balance mx-auto mt-8 max-w-3xl text-4xl font-semibold tracking-tight text-foreground sm:text-5xl lg:text-6xl"> <h1 className="text-balance mx-auto mt-10 max-w-4xl text-4xl font-semibold leading-[0.95] tracking-[-0.05em] text-foreground sm:text-5xl lg:text-6xl">
{t("titleStart")}{" "} {t("titleStart")}{" "}
<span className="brand-gradient-text">{t("titleAccent")}</span>{" "} <span className="brand-gradient-text">{t("titleAccent")}</span>{" "}
{t("titleEnd")} {t("titleEnd")}
</h1> </h1>
<p className="mx-auto mt-5 max-w-2xl text-base leading-relaxed text-muted-foreground sm:text-lg"> <p className="mx-auto mt-6 max-w-[34rem] text-sm leading-7 text-foreground/68 sm:text-base">
{t("description")} {t("description")}
</p> </p>
<p className="mx-auto mt-4 max-w-2xl text-sm text-foreground/72 sm:mt-5 sm:text-base"> <p className="mx-auto mt-4 max-w-[32rem] text-sm leading-6 text-foreground/54 sm:text-base">
{t("note")} {t("note")}
</p> </p>
</div> </div>
</CardContent>
</AppCard>
</MotionFade> </MotionFade>
</Container> </section>
</div> </div>
); );
} }
+67 -62
View File
@@ -4,44 +4,44 @@
:root { :root {
--background: 0 0% 100%; --background: 0 0% 100%;
--foreground: 224 71.4% 4.1%; --foreground: 222 24% 10%;
--card: 0 0% 100%; --card: 0 0% 100%;
--card-foreground: 224 71.4% 4.1%; --card-foreground: 222 24% 10%;
--popover: 0 0% 100%; --popover: 0 0% 100%;
--popover-foreground: 224 71.4% 4.1%; --popover-foreground: 222 24% 10%;
--primary: 222.2 47.4% 11.2%; --primary: 9 73% 50%;
--primary-foreground: 210 40% 98%; --primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%; --secondary: 220 14% 96%;
--secondary-foreground: 222.2 47.4% 11.2%; --secondary-foreground: 222 22% 18%;
--muted: 210 40% 96.1%; --muted: 220 14% 96%;
--muted-foreground: 215.4 16.3% 46.9%; --muted-foreground: 220 10% 42%;
--accent: 210 40% 96.1%; --accent: 220 14% 96%;
--accent-foreground: 222.2 47.4% 11.2%; --accent-foreground: 222 22% 18%;
--destructive: 0 84.2% 60.2%; --destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 210 40% 98%;
--surface-1: 0 0% 100%; --surface-1: 0 0% 100%;
--surface-2: 210 20% 96%; --surface-2: 220 16% 97%;
--surface-3: 210 24% 93%; --surface-3: 220 14% 94%;
--surface-inverse: 222 34% 14%; --surface-inverse: 222 24% 12%;
--surface-inverse-foreground: 210 40% 98%; --surface-inverse-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%; --border: 220 13% 90%;
--border-strong: 214.3 31.8% 85%; --border-strong: 220 11% 82%;
--input: 214.3 31.8% 91.4%; --input: 220 13% 90%;
--ring: 222.2 84% 4.9%; --ring: 9 73% 50%;
--status-success: 145 63% 42%; --status-success: 145 63% 42%;
--status-success-soft: 145 58% 92%; --status-success-soft: 145 58% 92%;
--status-warning: 36 92% 44%; --status-warning: 36 92% 44%;
--status-warning-soft: 41 96% 90%; --status-warning-soft: 41 96% 90%;
--brand-primary: 214 84% 42%; --brand-primary: 9 73% 50%;
--brand-secondary: 191 78% 42%; --brand-secondary: 12 64% 58%;
--ambient-primary-alpha: 0.08; --ambient-primary-alpha: 0.06;
--ambient-secondary-alpha: 0.06; --ambient-secondary-alpha: 0.04;
--ambient-grid-alpha: 0; --ambient-grid-alpha: 0;
--radius-surface: 0rem; --radius-surface: 0rem;
@@ -66,56 +66,56 @@
--section-space: 3.5rem; --section-space: 3.5rem;
--content-space: 1.5rem; --content-space: 1.5rem;
--sidebar-background: 210 22% 95%; --sidebar-background: 220 16% 97%;
--sidebar-foreground: 222 24% 18%; --sidebar-foreground: 222 22% 16%;
--sidebar-primary: 214 84% 42%; --sidebar-primary: 9 73% 50%;
--sidebar-primary-foreground: 210 40% 98%; --sidebar-primary-foreground: 210 40% 98%;
--sidebar-accent: 210 20% 90%; --sidebar-accent: 220 14% 94%;
--sidebar-accent-foreground: 222 24% 18%; --sidebar-accent-foreground: 222 22% 18%;
--sidebar-border: 214 20% 84%; --sidebar-border: 220 12% 86%;
--sidebar-ring: 214 84% 42%; --sidebar-ring: 9 73% 50%;
} }
.dark { .dark {
--background: 224 71.4% 4.1%; --background: 222 22% 8%;
--foreground: 210 20% 98%; --foreground: 210 20% 96%;
--card: 224 71.4% 4.1%; --card: 222 22% 9%;
--card-foreground: 210 20% 98%; --card-foreground: 210 20% 96%;
--popover: 224 71.4% 4.1%; --popover: 222 22% 9%;
--popover-foreground: 210 20% 98%; --popover-foreground: 210 20% 96%;
--primary: 210 20% 98%; --primary: 9 78% 56%;
--primary-foreground: 222.2 47.4% 11.2%; --primary-foreground: 210 40% 98%;
--secondary: 215 27.9% 16.9%; --secondary: 222 16% 14%;
--secondary-foreground: 210 20% 98%; --secondary-foreground: 210 20% 94%;
--muted: 215 27.9% 16.9%; --muted: 222 16% 14%;
--muted-foreground: 217.9 10.6% 64.9%; --muted-foreground: 217 10% 64%;
--accent: 215 27.9% 16.9%; --accent: 222 16% 14%;
--accent-foreground: 210 20% 98%; --accent-foreground: 210 20% 94%;
--destructive: 0 62.8% 30.6%; --destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 210 40% 98%;
--surface-1: 222 28% 11%; --surface-1: 222 22% 9%;
--surface-2: 222 22% 14%; --surface-2: 222 18% 12%;
--surface-3: 222 18% 17%; --surface-3: 222 16% 15%;
--surface-inverse: 210 40% 98%; --surface-inverse: 210 20% 96%;
--surface-inverse-foreground: 222 33% 8%; --surface-inverse-foreground: 222 24% 10%;
--border: 215 27.9% 16.9%; --border: 222 14% 18%;
--border-strong: 215 20.2% 25%; --border-strong: 222 12% 26%;
--input: 215 27.9% 16.9%; --input: 222 14% 18%;
--ring: 216 12.2% 83.9%; --ring: 9 78% 56%;
--status-success: 145 68% 66%; --status-success: 145 68% 66%;
--status-success-soft: 145 36% 18%; --status-success-soft: 145 36% 18%;
--status-warning: 42 96% 66%; --status-warning: 42 96% 66%;
--status-warning-soft: 35 40% 18%; --status-warning-soft: 35 40% 18%;
--brand-primary: 205 88% 64%; --brand-primary: 9 78% 56%;
--brand-secondary: 190 76% 57%; --brand-secondary: 12 74% 64%;
--ambient-primary-alpha: 0.08; --ambient-primary-alpha: 0.08;
--ambient-secondary-alpha: 0.06; --ambient-secondary-alpha: 0.05;
--ambient-grid-alpha: 0; --ambient-grid-alpha: 0;
--shadow-xs: 0 1px 2px hsl(220 50% 2% / 0.32); --shadow-xs: 0 1px 2px hsl(220 50% 2% / 0.32);
@@ -126,14 +126,14 @@
--shadow-panel: 0 4px 6px -1px hsl(220 50% 2% / 0.32), 0 2px 4px -2px hsl(220 50% 2% / 0.32); --shadow-panel: 0 4px 6px -1px hsl(220 50% 2% / 0.32), 0 2px 4px -2px hsl(220 50% 2% / 0.32);
--shadow-sidebar: inset 0 1px 0 hsl(210 25% 92% / 0.06); --shadow-sidebar: inset 0 1px 0 hsl(210 25% 92% / 0.06);
--sidebar-background: 222 25% 10%; --sidebar-background: 222 20% 10%;
--sidebar-foreground: 210 18% 86%; --sidebar-foreground: 210 18% 88%;
--sidebar-primary: 205 88% 64%; --sidebar-primary: 9 78% 56%;
--sidebar-primary-foreground: 222 33% 8%; --sidebar-primary-foreground: 210 40% 98%;
--sidebar-accent: 221 18% 18%; --sidebar-accent: 222 16% 14%;
--sidebar-accent-foreground: 210 25% 92%; --sidebar-accent-foreground: 210 20% 94%;
--sidebar-border: 221 18% 22%; --sidebar-border: 222 14% 20%;
--sidebar-ring: 205 88% 64%; --sidebar-ring: 9 78% 56%;
} }
html { html {
@@ -167,6 +167,11 @@ html.dark {
} }
@layer utilities { @layer utilities {
.hero-noise {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180' viewBox='0 0 180 180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.1' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)' opacity='0.28'/%3E%3C/svg%3E");
background-size: 180px 180px;
}
.text-balance { .text-balance {
text-wrap: balance; text-wrap: balance;
} }
+12 -229
View File
@@ -1,18 +1,9 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { FileText, Hash, Text } from "lucide-react";
import { MotionFade } from "@/components/motion-fade"; import { MotionFade } from "@/components/motion-fade";
import { FlashMessage } from "@/components/root/flash-message"; import { FlashMessage } from "@/components/root/flash-message";
import { PortfolioSubnav } from "@/components/root/portfolio-subnav"; import { PortfolioCategoriesManager } from "@/components/root/portfolio-categories-manager";
import { RootDashboardShell } from "@/components/root/root-dashboard-shell"; import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
import { AppCard } from "@/components/ui/app-card";
import { Button } from "@/components/ui/button";
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
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";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import { getAdminPortfolioCategories } from "@/lib/portfolio"; import { getAdminPortfolioCategories } from "@/lib/portfolio";
@@ -20,15 +11,9 @@ import { deleteCategoryAction, upsertCategoryAction } from "../actions";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const locales = [
{ key: "Ar", label: "Arabisch", hint: "الواجهة العربية" },
{ key: "En", label: "Englisch", hint: "English website" },
{ key: "De", label: "Deutsch", hint: "Deutsche Website" },
] as const;
const copy = { const copy = {
title: "Portfolio Kategorien", title: "Portfolio Kategorien",
subtitle: "Kategorien fuer Portfolio Projekte verwalten.", subtitle: "Kategorien schnell anlegen, oeffnen und direkt im Modal bearbeiten.",
overview: "Uebersicht", overview: "Uebersicht",
maintenance: "Wartungsmodus", maintenance: "Wartungsmodus",
uiKit: "UI Kit", uiKit: "UI Kit",
@@ -37,13 +22,6 @@ const copy = {
portfolio: "Portfolio", portfolio: "Portfolio",
logout: "Ausloggen", logout: "Ausloggen",
backToSite: "Zur Website", backToSite: "Zur Website",
sortOrder: "Sortierung",
active: "Aktiv",
saveCategory: "Kategorie speichern",
save: "Speichern",
delete: "Loeschen",
projects: "Projekte",
description: "Beschreibung",
}; };
type RootPortfolioCategoriesPageProps = { type RootPortfolioCategoriesPageProps = {
@@ -68,6 +46,8 @@ export default async function RootPortfolioCategoriesPage({
} }
const categories = await getAdminPortfolioCategories(); const categories = await getAdminPortfolioCategories();
const activeCount = categories.filter((category) => category.isActive).length;
const assignedProjects = categories.reduce((sum, category) => sum + category.projectCount, 0);
return ( return (
<RootDashboardShell <RootDashboardShell
@@ -77,225 +57,28 @@ export default async function RootPortfolioCategoriesPage({
logoutAction={logoutAction} logoutAction={logoutAction}
headerTitle={copy.title} headerTitle={copy.title}
headerDescription={copy.subtitle} headerDescription={copy.subtitle}
saveFormSelector="[data-topbar-save-form='category']"
toolbar={<PortfolioSubnav active="categories" />}
> >
<div className="space-y-6"> <div className="space-y-6">
{searchParams?.success ? ( {searchParams?.success ? (
<MotionFade delay={0.1}> <MotionFade delay={0.08}>
<FlashMessage type="success" message={searchParams.success} /> <FlashMessage type="success" message={searchParams.success} />
</MotionFade> </MotionFade>
) : null} ) : null}
{searchParams?.error ? ( {searchParams?.error ? (
<MotionFade delay={0.12}> <MotionFade delay={0.1}>
<FlashMessage type="error" message={searchParams.error} /> <FlashMessage type="error" message={searchParams.error} />
</MotionFade> </MotionFade>
) : null} ) : null}
<MotionFade delay={0.15}> <PortfolioCategoriesManager
<AppCard> categories={categories}
<CardHeader> activeCount={activeCount}
<CardTitle>Neue Kategorie</CardTitle> assignedProjects={assignedProjects}
<CardDescription>Eine Kategorie wird genau einem oder mehreren Projekten zugeordnet.</CardDescription> saveCategoryAction={upsertCategoryAction}
</CardHeader> removeCategoryAction={deleteCategoryAction}
<CardContent>
<form
id="portfolio-category-create-form"
action={upsertCategoryAction}
className="grid gap-4 md:grid-cols-2"
data-topbar-save-form="category"
>
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
<div className="space-y-2">
<Label htmlFor="create-slug" className="sr-only">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" className="sr-only">{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 placeholder={copy.sortOrder} className="pl-9" />
</div>
</div>
<div className="md:col-span-2">
<Tabs defaultValue="Ar">
<TabsList>
{locales.map((locale) => (
<TabsTrigger key={locale.key} value={locale.key}>
{locale.label}
</TabsTrigger>
))}
</TabsList>
{locales.map((locale) => (
<TabsContent key={locale.key} value={locale.key}>
<div className="grid gap-4 rounded-lg border bg-card p-4 shadow-sm md:grid-cols-2">
<div className="md:col-span-2 text-sm text-muted-foreground">{locale.hint}</div>
<div className="space-y-2">
<Label htmlFor={`create-name-${locale.key}`} 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={`create-name-${locale.key}`} name={`name${locale.key}`} required placeholder={`Name ${locale.label}`} className="pl-9" />
</div>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`create-description-${locale.key}`} 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={`create-description-${locale.key}`}
name={`description${locale.key}`}
rows={4}
required
placeholder={`${copy.description} ${locale.label}`}
className="pl-9"
/> />
</div> </div>
</div>
</div>
</TabsContent>
))}
</Tabs>
</div>
<label className="flex items-center gap-3 rounded-md border border-input bg-card px-4 py-3 text-sm md:col-span-2">
<Checkbox name="isActive" defaultChecked />
{copy.active}
</label>
<div className="md:col-span-2">
<Button type="submit">{copy.saveCategory}</Button>
</div>
</form>
</CardContent>
</AppCard>
</MotionFade>
<div className="grid gap-4">
{categories.map((category, index) => (
<MotionFade key={category.id} delay={0.18 + index * 0.03}>
<AppCard>
<CardContent className="p-6">
<form
id={`portfolio-category-form-${category.id}`}
action={upsertCategoryAction}
className="grid gap-4 md:grid-cols-2"
data-topbar-save-form="category"
>
<input type="hidden" name="id" value={category.id} />
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
<div className="space-y-2">
<Label htmlFor={`slug-${category.id}`} className="sr-only">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 placeholder="Slug" className="pl-9" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`sortOrder-${category.id}`} className="sr-only">{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
placeholder={copy.sortOrder}
className="pl-9"
/>
</div>
</div>
<div className="md:col-span-2">
<Tabs defaultValue="Ar">
<TabsList>
{locales.map((locale) => (
<TabsTrigger key={`${category.id}-${locale.key}`} value={locale.key}>
{locale.label}
</TabsTrigger>
))}
</TabsList>
{locales.map((locale) => {
const lowerLocale = locale.key.toLowerCase() as "ar" | "en" | "de";
const nameKey = `name${locale.key}` as const;
const descriptionKey = `description${locale.key}` as const;
return (
<TabsContent key={`${category.id}-content-${locale.key}`} value={locale.key}>
<div className="grid gap-4 rounded-lg border bg-card p-4 shadow-sm md:grid-cols-2">
<div className="md:col-span-2 text-sm text-muted-foreground">{locale.hint}</div>
<div className="space-y-2">
<Label htmlFor={`${nameKey}-${category.id}`} 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={`${nameKey}-${category.id}`}
name={nameKey}
defaultValue={category.name[lowerLocale]}
required
placeholder={`Name ${locale.label}`}
className="pl-9"
/>
</div>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`${descriptionKey}-${category.id}`} 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={`${descriptionKey}-${category.id}`}
name={descriptionKey}
rows={4}
defaultValue={category.description[lowerLocale]}
required
placeholder={`${copy.description} ${locale.label}`}
className="pl-9"
/>
</div>
</div>
</div>
</TabsContent>
);
})}
</Tabs>
</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 className="flex items-end justify-between gap-3">
<p className="text-sm text-muted-foreground">{category.projectCount} {copy.projects}</p>
<div className="flex gap-3">
<Button type="submit">{copy.save}</Button>
</div>
</div>
</form>
<form action={deleteCategoryAction} className="mt-4">
<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}>
{copy.delete}
</Button>
</form>
</CardContent>
</AppCard>
</MotionFade>
))}
</div>
</div>
</RootDashboardShell> </RootDashboardShell>
); );
} }
@@ -78,7 +78,6 @@ export default async function RootPortfolioProjectPage({
logoutAction={logoutAction} logoutAction={logoutAction}
headerTitle={copy.title} headerTitle={copy.title}
headerDescription={copy.subtitle} headerDescription={copy.subtitle}
saveFormId="portfolio-project-form"
> >
<div className="space-y-6"> <div className="space-y-6">
{searchParams?.success ? ( {searchParams?.success ? (
-1
View File
@@ -58,7 +58,6 @@ export default async function RootNewPortfolioProjectPage({
logoutAction={logoutAction} logoutAction={logoutAction}
headerTitle={copy.title} headerTitle={copy.title}
headerDescription={copy.subtitle} headerDescription={copy.subtitle}
saveFormId="portfolio-project-form"
> >
<div className="space-y-6"> <div className="space-y-6">
{searchParams?.error ? ( {searchParams?.error ? (
+68
View File
@@ -10,6 +10,8 @@ import {
SITE_SETTINGS_ENTITY_ID, SITE_SETTINGS_ENTITY_ID,
SITE_SETTINGS_ENTITY_TYPE, SITE_SETTINGS_ENTITY_TYPE,
SITE_SETTINGS_FAVICON_FIELD_KEY, SITE_SETTINGS_FAVICON_FIELD_KEY,
SITE_SETTINGS_LOGO_DARK_FIELD_KEY,
SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY,
updateSiteSettings, updateSiteSettings,
} from "@/lib/app-config"; } from "@/lib/app-config";
import { PAGE_TITLE_TOKEN, type SiteSettings } from "@/lib/site-settings"; import { PAGE_TITLE_TOKEN, type SiteSettings } from "@/lib/site-settings";
@@ -94,6 +96,8 @@ export async function saveSiteSettingsAction(formData: FormData) {
const uploadedPaths: string[] = []; const uploadedPaths: string[] = [];
try { try {
const siteLogoLightMedia = parseJsonObject(formData.get("siteLogoLightMedia"), "siteLogoLightMedia");
const siteLogoDarkMedia = parseJsonObject(formData.get("siteLogoDarkMedia"), "siteLogoDarkMedia");
const faviconMedia = parseJsonObject(formData.get("faviconMedia"), "faviconMedia"); const faviconMedia = parseJsonObject(formData.get("faviconMedia"), "faviconMedia");
const defaultOgImageMedia = parseJsonObject( const defaultOgImageMedia = parseJsonObject(
formData.get("defaultOgImageMedia"), formData.get("defaultOgImageMedia"),
@@ -136,6 +140,36 @@ export async function saveSiteSettingsAction(formData: FormData) {
} }
} }
const siteLogoLightSelection = siteLogoLightMedia
? await resolveMediaSelection({
media: mediaFieldInputSchema.parse(siteLogoLightMedia),
uploadFile: formData.get("siteLogoLightFile"),
folder: "site-settings",
fallbackLabel: `${parsedSettings.locales.de.siteName || parsedSettings.locales.en.siteName} logo light`,
required: false,
})
: {
assetId: null,
url: "",
createdAssetId: null,
uploadedUrl: null,
};
const siteLogoDarkSelection = siteLogoDarkMedia
? await resolveMediaSelection({
media: mediaFieldInputSchema.parse(siteLogoDarkMedia),
uploadFile: formData.get("siteLogoDarkFile"),
folder: "site-settings",
fallbackLabel: `${parsedSettings.locales.de.siteName || parsedSettings.locales.en.siteName} logo dark`,
required: false,
})
: {
assetId: null,
url: "",
createdAssetId: null,
uploadedUrl: null,
};
const faviconSelection = faviconMedia const faviconSelection = faviconMedia
? await resolveMediaSelection({ ? await resolveMediaSelection({
media: mediaFieldInputSchema.parse(faviconMedia), media: mediaFieldInputSchema.parse(faviconMedia),
@@ -166,6 +200,22 @@ export async function saveSiteSettingsAction(formData: FormData) {
uploadedUrl: null, uploadedUrl: null,
}; };
if (siteLogoLightSelection.createdAssetId) {
createdMediaAssetIds.push(siteLogoLightSelection.createdAssetId);
}
if (siteLogoLightSelection.uploadedUrl) {
uploadedPaths.push(siteLogoLightSelection.uploadedUrl);
}
if (siteLogoDarkSelection.createdAssetId) {
createdMediaAssetIds.push(siteLogoDarkSelection.createdAssetId);
}
if (siteLogoDarkSelection.uploadedUrl) {
uploadedPaths.push(siteLogoDarkSelection.uploadedUrl);
}
if (faviconSelection.createdAssetId) { if (faviconSelection.createdAssetId) {
createdMediaAssetIds.push(faviconSelection.createdAssetId); createdMediaAssetIds.push(faviconSelection.createdAssetId);
} }
@@ -187,6 +237,24 @@ export async function saveSiteSettingsAction(formData: FormData) {
entityType: SITE_SETTINGS_ENTITY_TYPE, entityType: SITE_SETTINGS_ENTITY_TYPE,
entityId: SITE_SETTINGS_ENTITY_ID, entityId: SITE_SETTINGS_ENTITY_ID,
usages: [ usages: [
...(siteLogoLightSelection.assetId
? [
{
assetId: siteLogoLightSelection.assetId,
usageType: MediaUsageType.GENERIC,
fieldKey: SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY,
},
]
: []),
...(siteLogoDarkSelection.assetId
? [
{
assetId: siteLogoDarkSelection.assetId,
usageType: MediaUsageType.GENERIC,
fieldKey: SITE_SETTINGS_LOGO_DARK_FIELD_KEY,
},
]
: []),
...(faviconSelection.assetId ...(faviconSelection.assetId
? [ ? [
{ {
+2 -2
View File
@@ -32,7 +32,7 @@ export function DashboardSidebar({ items, iconSrc, top, footer }: DashboardSideb
<Link <Link
href={item.href} href={item.href}
className={cn( 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 item.active
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground", : "text-muted-foreground hover:bg-muted hover:text-foreground",
@@ -51,7 +51,7 @@ export function DashboardSidebar({ items, iconSrc, top, footer }: DashboardSideb
return ( return (
<div className="flex h-full flex-col bg-sidebar/40"> <div className="flex h-full flex-col bg-sidebar/40">
<div className="flex items-center gap-3 px-4 py-5"> <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 */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={iconSrc || "/favicon.ico"} src={iconSrc || "/favicon.ico"}
+1 -1
View File
@@ -41,7 +41,7 @@ export function AppSidebar({
<Link <Link
href={item.href} href={item.href}
className={cn( 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 item.active
? "bg-sidebar-primary text-sidebar-primary-foreground" ? "bg-sidebar-primary text-sidebar-primary-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", : "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
+70
View File
@@ -0,0 +1,70 @@
"use client";
import { motion } from "framer-motion";
type HeroAtmosphereProps = {
compact?: boolean;
};
export function HeroAtmosphere({ compact = false }: HeroAtmosphereProps) {
return (
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<motion.div
animate={{
x: [0, 90, -48, 0],
y: [0, -70, 32, 0],
scale: [1, 1.12, 0.96, 1],
}}
transition={{
duration: 20,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
className="absolute left-[-10%] top-[4%] h-[24rem] w-[24rem] rounded-full bg-brand-primary/22 blur-3xl sm:h-[34rem] sm:w-[34rem] lg:h-[42rem] lg:w-[42rem]"
/>
<motion.div
animate={{
x: [0, -110, 40, 0],
y: [0, 48, -26, 0],
scale: [1, 0.92, 1.08, 1],
}}
transition={{
duration: 24,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
className="absolute right-[-14%] top-[2%] h-[22rem] w-[22rem] rounded-full bg-brand-secondary/18 blur-3xl sm:h-[30rem] sm:w-[30rem] lg:h-[38rem] lg:w-[38rem]"
/>
<motion.div
animate={{
x: [0, 42, -24, 0],
y: [0, -28, 30, 0],
opacity: [0.2, 0.34, 0.18, 0.2],
}}
transition={{
duration: 18,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
className="absolute bottom-[-18%] left-[18%] h-[18rem] w-[18rem] rounded-full bg-brand-primary/14 blur-3xl sm:h-[24rem] sm:w-[24rem] lg:h-[30rem] lg:w-[30rem]"
/>
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.58),rgba(255,255,255,0.32)_32%,rgba(255,255,255,0.08)_68%,rgba(255,255,255,0.02)),radial-gradient(circle_at_18%_24%,rgba(221,65,36,0.16),transparent_30%),radial-gradient(circle_at_78%_20%,rgba(235,143,117,0.18),transparent_34%),radial-gradient(circle_at_50%_100%,rgba(174,196,255,0.18),transparent_38%)] dark:bg-[linear-gradient(180deg,rgba(10,14,22,0.3),rgba(10,14,22,0.42)_34%,rgba(10,14,22,0.7)_100%),radial-gradient(circle_at_18%_24%,rgba(221,65,36,0.2),transparent_30%),radial-gradient(circle_at_78%_20%,rgba(235,143,117,0.12),transparent_34%),radial-gradient(circle_at_50%_100%,rgba(91,117,214,0.12),transparent_38%)]" />
<motion.div
animate={{
x: [0, compact ? 18 : 26, compact ? -12 : -18, 0],
y: [0, compact ? -14 : -22, compact ? 10 : 16, 0],
}}
transition={{
duration: compact ? 8 : 10,
repeat: Number.POSITIVE_INFINITY,
ease: "linear",
}}
className="hero-noise absolute inset-[-8%] opacity-55 mix-blend-soft-light dark:opacity-22"
/>
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { ArrowRight } from "lucide-react";
import Link from "next/link";
import { HeroAtmosphere } from "@/components/layout/hero-atmosphere";
import { MotionFade } from "@/components/motion-fade";
import { Button } from "@/components/ui/button";
import { getLocalizedPath } from "@/lib/locale";
type HomeHeroProps = {
locale: string;
kicker: string;
title: string;
description: string;
portfolioLabel: string;
contactLabel: string;
};
export function HomeHero({
locale,
kicker,
title,
description,
portfolioLabel,
contactLabel,
}: HomeHeroProps) {
return (
<section className="relative isolate flex min-h-[85vh] items-center overflow-hidden">
<HeroAtmosphere />
<div className="mx-auto flex w-full max-w-[72rem] flex-col items-center justify-center px-4 pb-12 pt-24 text-center sm:px-6 lg:px-8 lg:pb-16 lg:pt-28">
<MotionFade className="w-full">
<div className="mx-auto max-w-[54rem]">
<p className="text-sm font-medium tracking-[0.08em] text-foreground/62">
{kicker}
</p>
<h1 className="mt-6 whitespace-pre-line text-balance text-[3rem] font-semibold leading-[0.92] tracking-[-0.06em] text-foreground sm:text-[4.5rem] lg:text-[6.75rem]">
{title}
</h1>
<p className="mx-auto mt-6 max-w-[30rem] text-sm leading-6 text-foreground/66 sm:text-base">
{description}
</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
<Button asChild size="lg" className="px-6">
<Link href={getLocalizedPath(locale, "/portfolio")}>
{portfolioLabel}
<ArrowRight className="h-4 w-4" />
</Link>
</Button>
<Button asChild variant="outline" size="lg" className="px-6">
<Link href={getLocalizedPath(locale, "/contact")}>{contactLabel}</Link>
</Button>
</div>
</div>
</MotionFade>
</div>
</section>
);
}
+12 -3
View File
@@ -11,12 +11,15 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { AppLocale, getLocalizedPath, stripLocalePrefix } from "@/lib/locale"; import { AppLocale, getLocalizedPath, stripLocalePrefix } from "@/lib/locale";
import { cn } from "@/lib/utils";
type LocaleToggleProps = { type LocaleToggleProps = {
locale: string; locale: string;
className?: string;
showLabel?: boolean;
}; };
export function LocaleToggle({ locale }: LocaleToggleProps) { export function LocaleToggle({ locale, className, showLabel = false }: LocaleToggleProps) {
const pathname = usePathname(); const pathname = usePathname();
const locales: AppLocale[] = ["de", "en", "ar"]; const locales: AppLocale[] = ["de", "en", "ar"];
const currentPath = stripLocalePrefix(pathname); const currentPath = stripLocalePrefix(pathname);
@@ -27,11 +30,17 @@ export function LocaleToggle({ locale }: LocaleToggleProps) {
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
type="button" type="button"
variant="outline" variant="ghost"
size="icon" size={showLabel ? "sm" : "icon"}
aria-label={`Locale: ${currentLocale.toUpperCase()}`} 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" /> <Languages className="h-4 w-4" />
{showLabel ? <span className="text-xs font-semibold uppercase tracking-[0.22em]">{currentLocale}</span> : null}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[8rem]"> <DropdownMenuContent align="end" className="min-w-[8rem]">
+30
View File
@@ -0,0 +1,30 @@
import { HeroAtmosphere } from "@/components/layout/hero-atmosphere";
import { MotionFade } from "@/components/motion-fade";
type PageHeroProps = {
title: string;
description?: string;
};
export function PageHero({ title, description }: PageHeroProps) {
return (
<section className="relative isolate flex min-h-[42vh] items-end overflow-hidden">
<HeroAtmosphere compact />
<div className="mx-auto w-full max-w-[72rem] px-4 pb-10 pt-24 sm:px-6 lg:px-8 lg:pb-12 lg:pt-28">
<MotionFade className="max-w-[44rem]">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-foreground/48">
{title}
</p>
<h1 className="mt-4 text-balance text-4xl font-semibold leading-[0.95] tracking-[-0.05em] text-foreground sm:text-5xl lg:text-6xl">
{title}
</h1>
{description ? (
<p className="mt-5 max-w-[36rem] text-sm leading-6 text-foreground/66 sm:text-base">
{description}
</p>
) : null}
</MotionFade>
</div>
</section>
);
}
@@ -0,0 +1,62 @@
"use client";
import { motion } from "framer-motion";
export function SiteAmbientBackdrop() {
return (
<div className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
<motion.div
animate={{
x: [0, 72, -36, 0],
y: [0, -54, 28, 0],
scale: [1, 1.1, 0.95, 1],
}}
transition={{
duration: 18,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
className="absolute left-[-10%] top-[4%] h-[30rem] w-[30rem] rounded-full bg-brand-primary/18 blur-3xl sm:h-[38rem] sm:w-[38rem] lg:h-[46rem] lg:w-[46rem]"
/>
<motion.div
animate={{
x: [0, -84, 26, 0],
y: [0, 44, -22, 0],
scale: [1, 0.9, 1.08, 1],
}}
transition={{
duration: 22,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
className="absolute right-[-12%] top-[8%] h-[26rem] w-[26rem] rounded-full bg-brand-secondary/16 blur-3xl sm:h-[34rem] sm:w-[34rem] lg:h-[42rem] lg:w-[42rem]"
/>
<motion.div
animate={{
x: [0, 34, -20, 0],
y: [0, -24, 34, 0],
opacity: [0.22, 0.38, 0.16, 0.22],
}}
transition={{
duration: 16,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
className="absolute bottom-[-12%] left-[22%] h-[20rem] w-[20rem] rounded-full bg-brand-primary/14 blur-3xl sm:h-[28rem] sm:w-[28rem] lg:h-[34rem] lg:w-[34rem]"
/>
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.64),rgba(255,255,255,0.42)_24%,rgba(255,255,255,0.14)_52%,rgba(255,255,255,0.02)_100%),radial-gradient(circle_at_16%_18%,rgba(221,65,36,0.12),transparent_30%),radial-gradient(circle_at_78%_14%,rgba(235,143,117,0.16),transparent_34%),radial-gradient(circle_at_50%_100%,rgba(160,182,255,0.16),transparent_40%)] dark:bg-[linear-gradient(180deg,rgba(11,15,24,0.24),rgba(11,15,24,0.36)_26%,rgba(11,15,24,0.72)_100%),radial-gradient(circle_at_16%_18%,rgba(221,65,36,0.16),transparent_30%),radial-gradient(circle_at_78%_14%,rgba(235,143,117,0.1),transparent_34%),radial-gradient(circle_at_50%_100%,rgba(109,130,214,0.12),transparent_40%)]" />
<motion.div
animate={{
x: [0, 20, -14, 0],
y: [0, -16, 12, 0],
}}
transition={{
duration: 9,
repeat: Number.POSITIVE_INFINITY,
ease: "linear",
}}
className="hero-noise absolute inset-[-6%] opacity-48 mix-blend-soft-light dark:opacity-18"
/>
</div>
);
}
+200 -55
View File
@@ -1,16 +1,19 @@
"use client"; "use client";
import { Menu, X } from "lucide-react"; import { AnimatePresence, motion } from "framer-motion";
import Image from "next/image"; import { LayoutDashboard, Menu, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
import { Container } from "@/components/layout/container"; import { Container } from "@/components/layout/container";
import { LocaleToggle } from "@/components/layout/locale-toggle"; import { LocaleToggle } from "@/components/layout/locale-toggle";
import { SiteLogo } from "@/components/layout/site-logo";
import { ThemeToggle } from "@/components/theme-toggle"; import { ThemeToggle } from "@/components/theme-toggle";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { getLocalizedPath } from "@/lib/locale"; import { getLocalizedPath, stripLocalePrefix } from "@/lib/locale";
import { cn } from "@/lib/utils";
const navItems = [ const navItems = [
{ key: "home", path: "" }, { key: "home", path: "" },
@@ -22,111 +25,253 @@ const navItems = [
type SiteHeaderProps = { type SiteHeaderProps = {
isAdmin?: boolean; 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({ function NavLinks({
isAdmin,
onNavigate, onNavigate,
mobile = false,
}: { }: {
isAdmin: boolean;
onNavigate?: () => void; onNavigate?: () => void;
mobile?: boolean;
}) { }) {
const locale = useLocale(); const locale = useLocale();
const pathname = usePathname();
const t = useTranslations("navigation"); const t = useTranslations("navigation");
const currentPath = stripLocalePrefix(pathname);
return ( return (
<> <>
{navItems.map((item) => ( {navItems.map((item) => (
(() => {
const itemPath = item.path || "/";
const isActive = isNavItemActive(currentPath, itemPath);
return (
<Link <Link
key={item.key} key={item.key}
href={getLocalizedPath(locale, item.path || "/")} href={getLocalizedPath(locale, itemPath)}
className="text-sm text-muted-foreground hover:text-foreground" 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} 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)} {t(item.key)}
</Link> </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 [isOpen, setIsOpen] = useState(false);
const pathname = usePathname();
const locale = useLocale(); const locale = useLocale();
const t = useTranslations("navigation"); const t = useTranslations("navigation");
return ( return (
<header className="sticky top-0 z-40 border-b border-border/80 bg-background/88 backdrop-blur-chrome"> <header className="sticky top-3 z-40 bg-transparent">
<Container> <Container className="max-w-[88rem]">
<div className="flex min-h-header items-center justify-between gap-4"> <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 gap-4"> <div className="flex items-center">
<Link <Link
href={getLocalizedPath(locale)} 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 <SiteLogo
src="/logos/light-primary.svg" lightLogoUrl={lightLogoUrl}
darkLogoUrl={darkLogoUrl}
alt="mohfarawati" 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 priority
/> />
</Link> </Link>
<nav className="hidden items-center gap-5 md:flex">
<NavLinks isAdmin={isAdmin} />
</nav>
</div> </div>
<div className="hidden items-center gap-2 md:flex"> <div className="pointer-events-none absolute left-1/2 top-1/2 hidden -translate-x-1/2 -translate-y-1/2 lg:flex">
<ThemeToggle ariaLabel={t("themeToggle")} /> <motion.nav
<LocaleToggle locale={locale} /> 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> </div>
<Button <Button
type="button" type="button"
onClick={() => setIsOpen((open) => !open)} onClick={() => setIsOpen((open) => !open)}
variant="outline" variant="ghost"
size="icon" 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")} aria-label={isOpen ? t("closeMenu") : t("openMenu")}
>
<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" />} {isOpen ? <X className="h-4 w-4" /> : <Menu className="h-4 w-4" />}
</motion.span>
</Button> </Button>
</div> </div>
</Container> </Container>
<AnimatePresence initial={false}>
{isOpen ? ( {isOpen ? (
<div className="border-t border-border/80 bg-background md:hidden"> <motion.div
<Container className="py-4"> initial={{ opacity: 0, y: -14 }}
<nav className="flex flex-col gap-3"> animate={{ opacity: 1, y: 0 }}
<NavLinks isAdmin={isAdmin} onNavigate={() => setIsOpen(false)} /> exit={{ opacity: 0, y: -10 }}
</nav> transition={{ duration: 0.22, ease: "easeOut" }}
<div className="mt-4 flex items-center gap-2"> className="bg-transparent lg:hidden"
<ThemeToggle ariaLabel={t("themeToggle")} /> >
<LocaleToggle locale={locale} /> <Container className="pb-2 pt-3">
</div> <motion.div
</Container> initial={{ opacity: 0, scale: 0.98 }}
</div> 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} ) : 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> </header>
); );
} }
+45
View File
@@ -0,0 +1,45 @@
"use client";
import Image from "next/image";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
type SiteLogoProps = {
lightLogoUrl?: string | null;
darkLogoUrl?: string | null;
alt: string;
priority?: boolean;
};
export function SiteLogo({
lightLogoUrl,
darkLogoUrl,
alt,
priority = false,
}: SiteLogoProps) {
const { theme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const activeTheme = theme === "system" ? resolvedTheme : theme;
const isDark = mounted && activeTheme === "dark";
const fallbackSrc = isDark ? "/logos/dark-primary.svg" : "/logos/light-primary.svg";
const src = isDark ? darkLogoUrl || lightLogoUrl || fallbackSrc : lightLogoUrl || darkLogoUrl || fallbackSrc;
return (
<span className="block">
<Image
src={src}
alt={alt}
width={360}
height={92}
sizes="(min-width: 1280px) 360px, (min-width: 1024px) 320px, (min-width: 640px) 280px, 240px"
className="h-9 w-auto object-contain object-left sm:h-10 lg:h-11 xl:h-12"
priority={priority}
/>
</span>
);
}
+4 -4
View File
@@ -110,7 +110,7 @@ export function MediaFieldPicker({
}, [serializedValue]); }, [serializedValue]);
return ( 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"> <div className="space-y-2">
<Label className="sr-only">{title}</Label> <Label className="sr-only">{title}</Label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -128,7 +128,7 @@ export function MediaFieldPicker({
}) })
} }
className={cn( 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 value.mode === mode
? "border-input bg-primary text-primary-foreground" ? "border-input bg-primary text-primary-foreground"
: "border-input bg-background text-foreground/75 hover:bg-accent hover:text-accent-foreground", : "border-input bg-background text-foreground/75 hover:bg-accent hover:text-accent-foreground",
@@ -150,7 +150,7 @@ export function MediaFieldPicker({
isCleared: true, 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} {clearLabel}
</button> </button>
@@ -208,7 +208,7 @@ export function MediaFieldPicker({
{value.mode === "library" ? ( {value.mode === "library" ? (
<div className="space-y-3"> <div className="space-y-3">
<Label className="sr-only">{libraryLabel ?? "Media Library"}</Label> <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"> <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" /> <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input <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
+35 -156
View File
@@ -1,8 +1,7 @@
import { Boxes, ExternalLink, Filter, FolderKanban, Layers3, Plus, Tags } from "lucide-react"; import { ExternalLink, Filter, FolderKanban, Plus, Tags } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { MotionFade } from "@/components/motion-fade"; import { MotionFade } from "@/components/motion-fade";
import { PortfolioSubnav } from "@/components/root/portfolio-subnav";
import { AppCard } from "@/components/ui/app-card"; import { AppCard } from "@/components/ui/app-card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -26,76 +25,45 @@ type PortfolioProjectsOverviewProps = {
}; };
const copy = { 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", category: "Kategorie",
all: "Alle", all: "Alle",
status: "Status", status: "Status",
draft: "Entwurf", draft: "Entwurf",
published: "Veroeffentlicht", published: "Veroeffentlicht",
filter: "Filter anwenden", filter: "Filtern",
empty: "Noch keine Projekte vorhanden. Lege das erste Projekt an und beginne direkt mit Inhalt, Abschnitten und Dateien.", newProject: "Neues Projekt",
openProject: "Projekt ansehen", newCategory: "Neues Kategorie",
editProject: "Projekt bearbeiten", openProject: "Ansehen",
review: "Bearbeitungsstand", editProject: "Bearbeiten",
reviewDone: "Bereit",
reviewMissing: "Fehlt etwas",
untitled: "Unbenanntes Projekt", untitled: "Unbenanntes Projekt",
noCategory: "Ohne Kategorie", empty: "Noch keine Projekte vorhanden.",
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",
}; };
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({ export function PortfolioProjectsOverview({
categories, categories,
projects, projects,
selectedCategory, selectedCategory,
selectedStatus, selectedStatus,
}: PortfolioProjectsOverviewProps) { }: PortfolioProjectsOverviewProps) {
const publishedProjects = projects.filter((project) => project.isPublished).length;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PortfolioSubnav active="projects" />
<MotionFade delay={0.05}> <MotionFade delay={0.05}>
<AppCard level={3}> <AppCard level={3}>
<CardContent className="flex flex-col gap-6 p-6 lg:flex-row lg:items-end lg:justify-between lg:p-8"> <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"> <div className="grid gap-4 sm:grid-cols-3">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground"> <div className="rounded-lg border border-input bg-background px-4 py-4">
{copy.summary} <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> </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> </div>
</div> </div>
@@ -117,45 +85,7 @@ export function PortfolioProjectsOverview({
</AppCard> </AppCard>
</MotionFade> </MotionFade>
<section className="grid gap-4 md:grid-cols-3"> <MotionFade delay={0.1}>
{[
{
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}>
<AppCard> <AppCard>
<CardContent className="p-6"> <CardContent className="p-6">
<form className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]"> <form className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
@@ -211,14 +141,11 @@ export function PortfolioProjectsOverview({
</MotionFade> </MotionFade>
<div className="grid gap-4"> <div className="grid gap-4">
{projects.map((project, index) => { {projects.map((project, index) => (
const completion = getProjectCompletion(project); <MotionFade key={project.id} delay={0.14 + index * 0.03}>
return (
<MotionFade key={project.id} delay={0.18 + index * 0.03}>
<AppCard interactive> <AppCard interactive>
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> <CardHeader className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-2"> <div className="space-y-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-xl"> <CardTitle className="text-xl">
{getLocalizedValue(project.title, "de") || copy.untitled} {getLocalizedValue(project.title, "de") || copy.untitled}
@@ -227,43 +154,9 @@ export function PortfolioProjectsOverview({
{project.isPublished ? copy.published : copy.draft} {project.isPublished ? copy.published : copy.draft}
</Badge> </Badge>
</div> </div>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
<span>{project.category.name.de || copy.noCategory}</span> {project.category.name.de}
<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}
</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> </p>
<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>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
@@ -278,37 +171,23 @@ export function PortfolioProjectsOverview({
</Link> </Link>
</Button> </Button>
<Button asChild> <Button asChild>
<Link href={`/root/portfolio/projects/${project.id}`}>{copy.editProject}</Link> <Link href={`/root/portfolio/projects/${project.id}`}>
<FolderKanban className="h-4 w-4" />
{copy.editProject}
</Link>
</Button> </Button>
</div> </div>
</CardContent> </CardHeader>
</AppCard> </AppCard>
</MotionFade> </MotionFade>
); ))}
})}
{projects.length === 0 ? ( {projects.length === 0 ? (
<MotionFade delay={0.18}>
<AppCard> <AppCard>
<CardContent className="space-y-4 p-6"> <CardContent className="p-6 text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground">{copy.empty}</p> {copy.empty}
<div className="flex flex-wrap gap-3">
<Button asChild>
<Link href="/root/portfolio/projects/new">
<Plus className="h-4 w-4" />
{copy.newProject}
</Link>
</Button>
<Button asChild variant="outline">
<Link href="/root/portfolio/categories">
<Tags className="h-4 w-4" />
{copy.newCategory}
</Link>
</Button>
</div>
</CardContent> </CardContent>
</AppCard> </AppCard>
</MotionFade>
) : null} ) : null}
</div> </div>
</div> </div>
@@ -34,7 +34,7 @@ export function SidebarMaintenanceControl({
<label <label
htmlFor="sidebar-maintenance-enabled" htmlFor="sidebar-maintenance-enabled"
className={cn( 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 enabled
? "border-status-warning/40 bg-status-warning-soft/80" ? "border-status-warning/40 bg-status-warning-soft/80"
: "border-input bg-card hover:bg-accent/20", : "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="flex min-w-0 items-center gap-3">
<span <span
className={cn( 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 enabled
? "border-status-warning/40 bg-status-warning-soft text-status-warning" ? "border-status-warning/40 bg-status-warning-soft text-status-warning"
: "border-border bg-background text-muted-foreground", : "border-border bg-background text-muted-foreground",
+107 -2
View File
@@ -117,6 +117,20 @@ export function SiteSettingsForm({
mediaOptions, mediaOptions,
}: SiteSettingsFormProps) { }: SiteSettingsFormProps) {
const [settings, setSettings] = useState(initialSettings); 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>( const [favicon, setFavicon] = useState<MediaFieldState>(
createImageFieldState( createImageFieldState(
initialBindings.favicon?.assetId, initialBindings.favicon?.assetId,
@@ -131,6 +145,16 @@ export function SiteSettingsForm({
"Default OG Image", "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 faviconPreviewUrl = getMediaPreviewUrl(favicon, mediaOptions, initialBindings.favicon?.url);
const defaultOgImagePreviewUrl = getMediaPreviewUrl( const defaultOgImagePreviewUrl = getMediaPreviewUrl(
defaultOgImage, defaultOgImage,
@@ -295,12 +319,54 @@ export function SiteSettingsForm({
<AppCard> <AppCard>
<CardHeader> <CardHeader>
<CardTitle>Preview Images</CardTitle> <CardTitle>Brand And Preview Images</CardTitle>
<CardDescription> <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> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="grid gap-6 xl:grid-cols-2"> <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 <MediaFieldPicker
title="Favicon" title="Favicon"
value={favicon} value={favicon}
@@ -380,6 +446,45 @@ export function SiteSettingsForm({
})} })}
</div> </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"> <div className="flex items-center gap-2 text-sm font-medium text-foreground">
<Search className="h-4 w-4 text-brand-primary" /> <Search className="h-4 w-4 text-brand-primary" />
Search Preview Search Preview
+123
View File
@@ -0,0 +1,123 @@
import type { ReactNode } from "react";
import { AppCard } from "@/components/ui/app-card";
import { Badge } from "@/components/ui/badge";
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
export function WorkspaceHero({
eyebrow,
title,
description,
aside,
}: {
eyebrow: string;
title: string;
description: string;
aside?: ReactNode;
}) {
return (
<AppCard level={3}>
<CardContent className="flex flex-col gap-4 p-6 lg:flex-row lg:items-end lg:justify-between lg:p-8">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
{eyebrow}
</p>
<h2 className="text-2xl font-semibold text-foreground">{title}</h2>
<p className="max-w-3xl text-sm text-muted-foreground">{description}</p>
</div>
{aside ? <div className="flex flex-wrap gap-2">{aside}</div> : null}
</CardContent>
</AppCard>
);
}
export function WorkspaceSidebarPanel({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
return (
<AppCard level={2}>
<CardHeader className="pb-4">
<CardTitle className="text-base">{title}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">{children}</CardContent>
</AppCard>
);
}
export function WorkspaceStepButton({
active,
completed,
index,
label,
eyebrow,
onClick,
completeIcon,
}: {
active: boolean;
completed: boolean;
index: number;
label: string;
eyebrow: string;
onClick: () => void;
completeIcon?: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex w-full items-start gap-3 rounded-surface border px-4 py-3 text-left transition-colors",
active
? "border-input bg-accent/40"
: "border-input bg-background hover:bg-accent/20",
)}
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-pill border border-input bg-background text-sm font-semibold text-foreground">
{completed ? completeIcon ?? "OK" : index + 1}
</div>
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{eyebrow}
</p>
<p className="mt-1 text-sm font-medium text-foreground">{label}</p>
</div>
</button>
);
}
export function WorkspaceLocaleCard({
title,
hint,
children,
}: {
title: string;
hint: string;
children: ReactNode;
}) {
return (
<AppCard level={2}>
<CardHeader className="pb-4">
<CardTitle className="text-lg">{title}</CardTitle>
<CardDescription>{hint}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">{children}</CardContent>
</AppCard>
);
}
export function WorkspaceStatusBadge({
done,
doneLabel = "Ready",
openLabel = "Open",
}: {
done: boolean;
doneLabel?: string;
openLabel?: string;
}) {
return <Badge variant={done ? "success" : "warning"}>{done ? doneLabel : openLabel}</Badge>;
}
+4 -2
View File
@@ -12,6 +12,7 @@ type ThemeToggleProps = {
label?: string; label?: string;
variant?: ButtonProps["variant"]; variant?: ButtonProps["variant"];
className?: string; className?: string;
iconClassName?: string;
}; };
export function ThemeToggle({ export function ThemeToggle({
@@ -19,6 +20,7 @@ export function ThemeToggle({
label, label,
variant = "outline", variant = "outline",
className, className,
iconClassName,
}: ThemeToggleProps) { }: ThemeToggleProps) {
const { setTheme, theme, resolvedTheme } = useTheme(); const { setTheme, theme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
@@ -36,7 +38,7 @@ export function ThemeToggle({
aria-label={ariaLabel} aria-label={ariaLabel}
className={cn(label ? "justify-start" : undefined, className)} 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} {label ? <span>{label}</span> : null}
</Button> </Button>
); );
@@ -54,7 +56,7 @@ export function ThemeToggle({
aria-label={ariaLabel} aria-label={ariaLabel}
className={cn(label ? "justify-start" : undefined, className)} 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} {label ? <span>{label}</span> : null}
</Button> </Button>
); );
+63
View File
@@ -0,0 +1,63 @@
"use client";
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("rounded-surface border border-input bg-card", className)}
{...props}
/>
));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between gap-4 px-5 py-4 text-left text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className={cn(
"overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
className,
)}
{...props}
>
<div className="border-t border-border px-5 py-5">{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
+5 -5
View File
@@ -5,14 +5,14 @@ import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const appCardVariants = cva( const appCardVariants = cva(
"rounded-lg transition-colors", "rounded-surface transition-colors",
{ {
variants: { variants: {
level: { level: {
1: "border bg-card text-card-foreground shadow-sm", 1: "border border-border/80 bg-card text-card-foreground shadow-card",
2: "border bg-card text-card-foreground shadow-sm", 2: "border border-border/80 bg-card text-card-foreground shadow-card",
3: "border bg-card text-card-foreground shadow-md", 3: "border border-border/80 bg-card text-card-foreground shadow-panel",
inverse: "border-transparent bg-foreground text-background shadow-sm", inverse: "border-transparent bg-foreground text-background shadow-card",
}, },
padding: { padding: {
none: "", none: "",
+4 -4
View File
@@ -5,13 +5,13 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const buttonVariants = cva( 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: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/92",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 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", ghost: "text-muted-foreground hover:bg-muted hover:text-foreground",
link: "rounded-none text-primary underline-offset-4 hover:underline", link: "rounded-none text-primary underline-offset-4 hover:underline",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
+1 -1
View File
@@ -7,7 +7,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
<div <div
ref={ref} ref={ref}
className={cn( 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, className,
)} )}
{...props} {...props}
+104
View File
@@ -0,0 +1,104 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-foreground/30 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:w-full sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
);
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props} />
);
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
};
+6 -6
View File
@@ -22,7 +22,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
ref={ref} ref={ref}
className={cn( 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", inset && "pl-8",
className, className,
)} )}
@@ -42,7 +42,7 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
ref={ref} ref={ref}
className={cn( 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, className,
)} )}
{...props} {...props}
@@ -60,7 +60,7 @@ const DropdownMenuContent = React.forwardRef<
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( 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, className,
)} )}
{...props} {...props}
@@ -79,7 +79,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
ref={ref} ref={ref}
className={cn( 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", inset && "pl-8",
className, className,
)} )}
@@ -96,7 +96,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem <DropdownMenuPrimitive.CheckboxItem
ref={ref} ref={ref}
className={cn( 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, className,
)} )}
checked={checked} checked={checked}
@@ -121,7 +121,7 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem <DropdownMenuPrimitive.RadioItem
ref={ref} ref={ref}
className={cn( 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, className,
)} )}
{...props} {...props}
+1 -1
View File
@@ -7,7 +7,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
<input <input
type={type} type={type}
className={cn( 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, className,
)} )}
ref={ref} ref={ref}
+3 -3
View File
@@ -55,7 +55,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
ref={ref} ref={ref}
className={cn( 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, className,
)} )}
{...props} {...props}
@@ -107,7 +107,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content <SelectPrimitive.Content
ref={ref} ref={ref}
className={cn( 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", "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" && 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", "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, className,
@@ -152,7 +152,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item <SelectPrimitive.Item
ref={ref} ref={ref}
className={cn( 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", "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, className,
)} )}
{...props} {...props}
+2 -2
View File
@@ -44,7 +44,7 @@ function TabsList({
return ( return (
<div <div
className={cn( 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, className,
)} )}
{...props} {...props}
@@ -69,7 +69,7 @@ function TabsTrigger({
<button <button
type="button" type="button"
className={cn( 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", isActive && "bg-background text-foreground shadow-sm",
className, className,
)} )}
+1 -1
View File
@@ -6,7 +6,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"tex
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<textarea <textarea
className={cn( 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, className,
)} )}
ref={ref} ref={ref}
+57 -2
View File
@@ -53,6 +53,7 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
surfaces: "Surfaces", surfaces: "Surfaces",
typography: "Typography", typography: "Typography",
headers: "Headers", headers: "Headers",
workspace: "Workspace",
cardsDesc: cardsDesc:
"Aussenflaechen, innere Karten und verschachtelte Ebenen mit einheitlicher Hierarchie.", "Aussenflaechen, innere Karten und verschachtelte Ebenen mit einheitlicher Hierarchie.",
buttonsDesc: buttonsDesc:
@@ -67,6 +68,8 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
"Typografische Grundstufen fuer Seiten, Sektionen und Metainformationen.", "Typografische Grundstufen fuer Seiten, Sektionen und Metainformationen.",
headersDesc: headersDesc:
"Header Muster fuer Seiten und Sektionen innerhalb des Systems.", "Header Muster fuer Seiten und Sektionen innerhalb des Systems.",
workspaceDesc:
"Editor Muster mit breitem Inhaltsbereich und schmaler Kontextspalte fuer Admin Oberflaechen.",
tabsDesc: tabsDesc:
"Tabs bleiben in einer Route und verwenden dieselbe verschachtelte Steuerlogik.", "Tabs bleiben in einer Route und verwenden dieselbe verschachtelte Steuerlogik.",
outerCard: "Aeussere Card", outerCard: "Aeussere Card",
@@ -128,7 +131,12 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
outerSurfaceHeaderText: "Dieses Muster wird fuer Seitensektionen mit staerkerer Hierarchie genutzt.", outerSurfaceHeaderText: "Dieses Muster wird fuer Seitensektionen mit staerkerer Hierarchie genutzt.",
sectionHeader: "Abschnittsheader", sectionHeader: "Abschnittsheader",
nestedBlockHeading: "Ueberschrift des verschachtelten Blocks", 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", title: "UI Kit",
@@ -142,6 +150,7 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
surfaces: "Surfaces", surfaces: "Surfaces",
typography: "Typography", typography: "Typography",
headers: "Headers", headers: "Headers",
workspace: "Workspace",
cardsDesc: cardsDesc:
"Outer surfaces, inner cards, and nested levels with one consistent hierarchy.", "Outer surfaces, inner cards, and nested levels with one consistent hierarchy.",
buttonsDesc: buttonsDesc:
@@ -156,6 +165,8 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
"Typography foundations for pages, sections, and supporting copy.", "Typography foundations for pages, sections, and supporting copy.",
headersDesc: headersDesc:
"Header patterns for pages and sections inside the system.", "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: tabsDesc:
"Tabs stay within one route and use the same nested control logic.", "Tabs stay within one route and use the same nested control logic.",
outerCard: "Outer card", 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.", outerSurfaceHeaderText: "This pattern is used for page-level sections that need stronger hierarchy.",
sectionHeader: "Section header", sectionHeader: "Section header",
nestedBlockHeading: "Nested block heading", 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 ( return (
@@ -249,6 +265,7 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
<TabsTrigger value="surfaces">{copy.surfaces}</TabsTrigger> <TabsTrigger value="surfaces">{copy.surfaces}</TabsTrigger>
<TabsTrigger value="typography">{copy.typography}</TabsTrigger> <TabsTrigger value="typography">{copy.typography}</TabsTrigger>
<TabsTrigger value="headers">{copy.headers}</TabsTrigger> <TabsTrigger value="headers">{copy.headers}</TabsTrigger>
<TabsTrigger value="workspace">{copy.workspace}</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="cards"> <TabsContent value="cards">
@@ -516,6 +533,44 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
</div> </div>
</UiKitSection> </UiKitSection>
</TabsContent> </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> </Tabs>
</div> </div>
); );
+20
View File
@@ -6,6 +6,8 @@ export {
SITE_SETTINGS_ENTITY_ID, SITE_SETTINGS_ENTITY_ID,
SITE_SETTINGS_ENTITY_TYPE, SITE_SETTINGS_ENTITY_TYPE,
SITE_SETTINGS_FAVICON_FIELD_KEY, SITE_SETTINGS_FAVICON_FIELD_KEY,
SITE_SETTINGS_LOGO_DARK_FIELD_KEY,
SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY,
SITE_SETTINGS_KEY, SITE_SETTINGS_KEY,
DEFAULT_SITE_NAME, DEFAULT_SITE_NAME,
buildDefaultSiteSettings, buildDefaultSiteSettings,
@@ -21,6 +23,8 @@ import {
SITE_SETTINGS_ENTITY_ID, SITE_SETTINGS_ENTITY_ID,
SITE_SETTINGS_ENTITY_TYPE, SITE_SETTINGS_ENTITY_TYPE,
SITE_SETTINGS_FAVICON_FIELD_KEY, SITE_SETTINGS_FAVICON_FIELD_KEY,
SITE_SETTINGS_LOGO_DARK_FIELD_KEY,
SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY,
SITE_SETTINGS_KEY, SITE_SETTINGS_KEY,
buildDefaultSiteSettings, buildDefaultSiteSettings,
getDefaultSiteSettingsMediaBindings, getDefaultSiteSettingsMediaBindings,
@@ -112,6 +116,22 @@ export async function getSiteSettingsMediaBindings(): Promise<SiteSettingsMediaB
return usages.reduce<SiteSettingsMediaBindings>( return usages.reduce<SiteSettingsMediaBindings>(
(result, usage) => { (result, usage) => {
if (usage.fieldKey === SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY) {
result.siteLogoLight = {
assetId: usage.asset.id,
url: usage.asset.url,
version: usage.updatedAt.toISOString(),
};
}
if (usage.fieldKey === SITE_SETTINGS_LOGO_DARK_FIELD_KEY) {
result.siteLogoDark = {
assetId: usage.asset.id,
url: usage.asset.url,
version: usage.updatedAt.toISOString(),
};
}
if (usage.fieldKey === SITE_SETTINGS_FAVICON_FIELD_KEY) { if (usage.fieldKey === SITE_SETTINGS_FAVICON_FIELD_KEY) {
result.favicon = { result.favicon = {
assetId: usage.asset.id, assetId: usage.asset.id,
+43 -5
View File
@@ -25,15 +25,16 @@ export const categoryInputSchema = z.object({
isActive: z.boolean(), isActive: z.boolean(),
}); });
export const sectionInputSchema = z.object({ export const sectionInputSchema = z
.object({
id: z.string().trim().optional(), id: z.string().trim().optional(),
type: z.nativeEnum(PortfolioSectionType), type: z.nativeEnum(PortfolioSectionType),
titleAr: requiredText("Section titleAr"), titleAr: requiredText("Section titleAr"),
titleEn: requiredText("Section titleEn"), titleEn: requiredText("Section titleEn"),
titleDe: requiredText("Section titleDe"), titleDe: requiredText("Section titleDe"),
bodyAr: requiredText("Section bodyAr"), bodyAr: optionalTrimmedText,
bodyEn: requiredText("Section bodyEn"), bodyEn: optionalTrimmedText,
bodyDe: requiredText("Section bodyDe"), bodyDe: optionalTrimmedText,
imagePath: optionalTrimmedText, imagePath: optionalTrimmedText,
media: mediaFieldInputSchema.optional(), media: mediaFieldInputSchema.optional(),
linkUrl: optionalTrimmedText.refine( linkUrl: optionalTrimmedText.refine(
@@ -41,7 +42,44 @@ export const sectionInputSchema = z.object({
"Section linkUrl must be an absolute URL or start with /.", "Section linkUrl must be an absolute URL or start with /.",
), ),
sortOrder: z.coerce.number().int().min(0).max(9999), sortOrder: z.coerce.number().int().min(0).max(9999),
}); })
.superRefine((value, context) => {
const hasBody = Boolean(value.bodyAr && value.bodyEn && value.bodyDe);
const hasImage = value.media?.mode === "library"
? Boolean(value.media.assetId)
: value.media?.mode === "upload"
? true
: Boolean(value.imagePath);
if (
(value.type === PortfolioSectionType.RICH_TEXT ||
value.type === PortfolioSectionType.STATS ||
value.type === PortfolioSectionType.DELIVERABLES) &&
!hasBody
) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["bodyAr"],
message: "This section type requires body content in all languages.",
});
}
if (value.type === PortfolioSectionType.GALLERY && !hasImage) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["media"],
message: "Single image sections require an image.",
});
}
if (value.type === PortfolioSectionType.LINK && !value.linkUrl) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["linkUrl"],
message: "Link sections require a link URL.",
});
}
});
export const assetInputSchema = z.object({ export const assetInputSchema = z.object({
id: z.string().trim().optional(), id: z.string().trim().optional(),
+6
View File
@@ -6,6 +6,8 @@ export const SITE_SETTINGS_ENTITY_TYPE = "site-settings";
export const SITE_SETTINGS_ENTITY_ID = "global"; export const SITE_SETTINGS_ENTITY_ID = "global";
export const SITE_SETTINGS_FAVICON_FIELD_KEY = "favicon"; export const SITE_SETTINGS_FAVICON_FIELD_KEY = "favicon";
export const SITE_SETTINGS_DEFAULT_OG_IMAGE_FIELD_KEY = "defaultOgImage"; export const SITE_SETTINGS_DEFAULT_OG_IMAGE_FIELD_KEY = "defaultOgImage";
export const SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY = "siteLogoLight";
export const SITE_SETTINGS_LOGO_DARK_FIELD_KEY = "siteLogoDark";
export const PAGE_TITLE_TOKEN = "{pageTitle}"; export const PAGE_TITLE_TOKEN = "{pageTitle}";
export const SITE_NAME_TOKEN = "{siteName}"; export const SITE_NAME_TOKEN = "{siteName}";
@@ -30,6 +32,8 @@ export type SiteSettingsMediaBinding = {
}; };
export type SiteSettingsMediaBindings = { export type SiteSettingsMediaBindings = {
siteLogoLight: SiteSettingsMediaBinding | null;
siteLogoDark: SiteSettingsMediaBinding | null;
favicon: SiteSettingsMediaBinding | null; favicon: SiteSettingsMediaBinding | null;
defaultOgImage: SiteSettingsMediaBinding | null; defaultOgImage: SiteSettingsMediaBinding | null;
}; };
@@ -62,6 +66,8 @@ function normalizeSiteLocaleSettings(
export function getDefaultSiteSettingsMediaBindings(): SiteSettingsMediaBindings { export function getDefaultSiteSettingsMediaBindings(): SiteSettingsMediaBindings {
return { return {
siteLogoLight: null,
siteLogoDark: null,
favicon: null, favicon: null,
defaultOgImage: null, defaultOgImage: null,
}; };
+3 -3
View File
@@ -22,9 +22,9 @@
"note": "شكراً لصبرك. النسخة الجديدة ستصل قريباً بهوية أنضج وحضور أدق." "note": "شكراً لصبرك. النسخة الجديدة ستصل قريباً بهوية أنضج وحضور أدق."
}, },
"homepage": { "homepage": {
"heroKicker": "Digital Studio", "heroKicker": "مرحباً، أنا مو",
"heroTitle": "مواقع ومنتجات تنطلق بسرعة.", "heroTitle": "frontend\n developer\n& designer",
"heroText": "هذه الصفحة الرئيسية تشكل قاعدة لانطلاقة تسويقية ومنتجية متعددة اللغات.", "heroText": "أصمم وأبني تجارب رقمية نظيفة مع اهتمام دقيق بالتفاصيل والحركة وسهولة الاستخدام.",
"portfolioTitle": "أعمال مميزة", "portfolioTitle": "أعمال مميزة",
"portfolioText": "منطقة تجريبية لعرض مشاريع مختارة.", "portfolioText": "منطقة تجريبية لعرض مشاريع مختارة.",
"productsTitle": "منتجات مميزة", "productsTitle": "منتجات مميزة",
+4 -4
View File
@@ -3,7 +3,7 @@
"home": "Start", "home": "Start",
"portfolio": "Portfolio", "portfolio": "Portfolio",
"products": "Produkte", "products": "Produkte",
"about": "Ueber mich", "about": "Über mich",
"contact": "Kontakt", "contact": "Kontakt",
"root": "Root", "root": "Root",
"openMenu": "Menue oeffnen", "openMenu": "Menue oeffnen",
@@ -22,9 +22,9 @@
"note": "Danke fuer deine Geduld. Die naechste Version geht bald mit mehr Klarheit, Tempo und Charakter live." "note": "Danke fuer deine Geduld. Die naechste Version geht bald mit mehr Klarheit, Tempo und Charakter live."
}, },
"homepage": { "homepage": {
"heroKicker": "Digital Studio", "heroKicker": "Hi, ich bin Moh",
"heroTitle": "Webseiten und Produkte, die schnell liefern.", "heroTitle": "frontend\n developer\n& designer",
"heroText": "Diese Startseite ist die Basis fuer ein mehrsprachiges Marketing- und Produkt-Setup.", "heroText": "Ich entwerfe und entwickle klare digitale Erlebnisse mit Fokus auf Details, Motion und starke Interfaces.",
"portfolioTitle": "Featured Projects", "portfolioTitle": "Featured Projects",
"portfolioText": "Platzhalter fuer ausgewaehlte Kundenprojekte.", "portfolioText": "Platzhalter fuer ausgewaehlte Kundenprojekte.",
"productsTitle": "Featured Products", "productsTitle": "Featured Products",
+3 -3
View File
@@ -22,9 +22,9 @@
"note": "Thank you for your patience. The next version is arriving soon with more clarity, speed, and intent." "note": "Thank you for your patience. The next version is arriving soon with more clarity, speed, and intent."
}, },
"homepage": { "homepage": {
"heroKicker": "Digital Studio", "heroKicker": "Hi, I am Moh",
"heroTitle": "Websites and products that ship fast.", "heroTitle": "frontend\ndelevoper\n& designer",
"heroText": "This homepage is a starter for a multilingual marketing and product setup.", "heroText": "I design and build clean digital experiences with a sharp eye for detail, motion, and usable interfaces.",
"portfolioTitle": "Featured Projects", "portfolioTitle": "Featured Projects",
"portfolioText": "Placeholder area for highlighted client projects.", "portfolioText": "Placeholder area for highlighted client projects.",
"productsTitle": "Featured Products", "productsTitle": "Featured Products",
+62
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.4.2", "@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.2", "@prisma/client": "^7.4.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -1649,6 +1650,37 @@
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@radix-ui/react-accordion": {
"version": "1.2.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz",
"integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collapsible": "1.1.12",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-arrow": { "node_modules/@radix-ui/react-arrow": {
"version": "1.1.7", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
@@ -1702,6 +1734,36 @@
} }
} }
}, },
"node_modules/@radix-ui/react-collapsible": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
"integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": { "node_modules/@radix-ui/react-collection": {
"version": "1.1.7", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
+1
View File
@@ -19,6 +19,7 @@
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.4.2", "@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.2", "@prisma/client": "^7.4.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
+57
View File
@@ -0,0 +1,57 @@
---
name: mohs-frontend-system
description: Use this skill for frontend work in Next.js, Tailwind CSS, and shadcn/ui projects, especially when editing pages, layouts, components, styling, or refactoring UI systems. Apply it when Codex needs to keep visual decisions consistent, reuse shared primitives, centralize tokens and layout patterns, and avoid mixed old and new UI approaches.
---
# Mohs Frontend System
## Overview
Follow the existing project structure.
Reuse shared components before creating new ones.
Keep visual decisions global and system-driven instead of scattering them inside page files.
## Workflow
1. Inspect the current frontend structure before editing.
2. Find existing shared primitives in `components/ui/*` and `components/layout/*`.
3. Check global tokens and style rules in `app/globals.css`.
4. Check Tailwind mapping in `tailwind.config.ts`.
5. Decide whether the change belongs in global styles, shared primitives, or page-level composition.
6. Reuse and extend shared patterns instead of introducing one-off classes or duplicated markup.
7. Finish the refactor completely. Do not leave mixed patterns behind.
## System Rules
- Follow the existing project structure.
- Reuse shared components before creating new ones.
- Keep visual decisions global, not scattered in page files.
- Prefer tokens and shared primitives over repeated raw classes.
- Prefer consistency over creativity.
- Do not invent random styles, spacing, radius, shadows, or layouts.
- Use shadcn/ui as a base only, not as the final design language.
- Keep layout primitives shared.
- Keep UI primitives shared.
- Do not leave old and new patterns mixed together.
- Do not leave partial refactors.
## File Ownership
- Global styles and tokens: `app/globals.css`
- Tailwind mapping: `tailwind.config.ts`
- Shared UI: `components/ui/*`
- Shared layout: `components/layout/*`
## Implementation Rules
- Put reusable visual decisions into shared layers first.
- Keep page files focused on composition and data flow.
- If a new pattern is needed in more than one place, extract it immediately.
- If an old pattern conflicts with the new one, replace the old pattern in the touched area instead of leaving both.
- Match the existing code style and naming conventions of the repo.
## Output Style
1. Briefly explain the change.
2. List files to edit or create.
3. Execute the full implementation.
@@ -0,0 +1,4 @@
interface:
display_name: "Moh Frontend System"
short_description: "Next.js frontend system rules"
default_prompt: "Use $mohs-frontend-system to implement or refactor frontend UI consistently in this project."
+9 -3
View File
@@ -18,6 +18,8 @@ describe("metadata helpers", () => {
it("builds root metadata with dynamic icons and social preview", () => { it("builds root metadata with dynamic icons and social preview", () => {
const settings = buildDefaultSiteSettings("Studio Moh"); const settings = buildDefaultSiteSettings("Studio Moh");
const metadata = buildAppMetadataFromConfig(settings, { const metadata = buildAppMetadataFromConfig(settings, {
siteLogoLight: null,
siteLogoDark: null,
favicon: { favicon: {
assetId: "fav", assetId: "fav",
url: "/uploads/media/site-settings/favicon.svg", url: "/uploads/media/site-settings/favicon.svg",
@@ -30,9 +32,9 @@ describe("metadata helpers", () => {
expect(metadata.title).toBe("Studio Moh"); expect(metadata.title).toBe("Studio Moh");
expect(metadata.icons).toEqual({ expect(metadata.icons).toEqual({
icon: ["/uploads/media/site-settings/favicon.svg"], icon: [{ url: "/favicon.ico?v=default" }],
shortcut: ["/uploads/media/site-settings/favicon.svg"], shortcut: [{ url: "/favicon.ico?v=default" }],
apple: ["/uploads/media/site-settings/favicon.svg"], apple: [{ url: "/apple-icon.png?v=default" }],
}); });
expect(metadata.twitter).toMatchObject({ expect(metadata.twitter).toMatchObject({
card: "summary_large_image", card: "summary_large_image",
@@ -46,6 +48,8 @@ describe("metadata helpers", () => {
const metadata = buildLocalizedMetadataFromConfig({ const metadata = buildLocalizedMetadataFromConfig({
settings, settings,
bindings: { bindings: {
siteLogoLight: null,
siteLogoDark: null,
favicon: null, favicon: null,
defaultOgImage: null, defaultOgImage: null,
}, },
@@ -70,6 +74,8 @@ describe("metadata helpers", () => {
const metadata = buildLocalizedMetadataFromConfig({ const metadata = buildLocalizedMetadataFromConfig({
settings, settings,
bindings: { bindings: {
siteLogoLight: null,
siteLogoDark: null,
favicon: null, favicon: null,
defaultOgImage: null, defaultOgImage: null,
}, },
+46
View File
@@ -81,6 +81,28 @@ describe("portfolio validation", () => {
}).type, }).type,
).toBe("RICH_TEXT"); ).toBe("RICH_TEXT");
expect(
sectionInputSchema.parse({
type: "GALLERY",
titleAr: "معرض",
titleEn: "Single Image",
titleDe: "Einzelbild",
bodyAr: "",
bodyEn: "",
bodyDe: "",
imagePath: "/uploads/media/sections/example.svg",
media: {
mode: "library",
assetId: "asset_2",
url: "",
label: "Single Image",
kind: "IMAGE",
},
linkUrl: "",
sortOrder: 1,
}).type,
).toBe("GALLERY");
expect( expect(
assetInputSchema.parse({ assetInputSchema.parse({
kind: "IMAGE", kind: "IMAGE",
@@ -121,4 +143,28 @@ describe("portfolio validation", () => {
}), }),
).toThrow(/url/i); ).toThrow(/url/i);
}); });
it("rejects link sections without a link", () => {
expect(() =>
sectionInputSchema.parse({
type: "LINK",
titleAr: "رابط",
titleEn: "Link",
titleDe: "Link",
bodyAr: "",
bodyEn: "",
bodyDe: "",
imagePath: "",
media: {
mode: "upload",
assetId: "",
url: "",
label: "",
kind: "IMAGE",
},
linkUrl: "",
sortOrder: 0,
}),
).toThrow(/link/i);
});
}); });
+17
View File
@@ -0,0 +1,17 @@
import path from "path";
import { fileURLToPath } from "url";
import { defineConfig } from "vitest/config";
const rootDir = path.dirname(fileURLToPath(new URL(import.meta.url)));
export default defineConfig({
resolve: {
alias: {
"@": rootDir,
},
},
test: {
environment: "node",
},
});