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
+10 -18
View File
@@ -3,6 +3,7 @@ import { Compass, Layers3, Users } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { Container } from "@/components/layout/container";
import { PageHero } from "@/components/layout/page-hero";
import { MotionFade } from "@/components/motion-fade";
import { buildLocalizedMetadata } from "@/lib/metadata";
import { resolveLocale } from "@/lib/locale";
@@ -34,21 +35,11 @@ export default async function AboutPage({ params: { locale } }: AboutPageProps)
const t = await getTranslations({ locale: localeKey, namespace: "aboutPage" });
return (
<Container className="flex flex-col gap-section py-10 lg:py-14">
<MotionFade>
<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>
<>
<PageHero title={t("title")} description={t("intro")} />
<MotionFade delay={0.05}>
<Container className="flex flex-col gap-section pb-12 lg:pb-16">
<MotionFade delay={0.05}>
<AppCard>
<CardContent className="p-6 lg:p-8">
<h2 className="text-2xl font-semibold text-foreground">
@@ -89,9 +80,9 @@ export default async function AboutPage({ params: { locale } }: AboutPageProps)
</div>
</CardContent>
</AppCard>
</MotionFade>
</MotionFade>
<MotionFade delay={0.1}>
<MotionFade delay={0.1}>
<AppCard>
<CardContent className="p-6 lg:p-8">
<h2 className="text-2xl font-semibold text-foreground">
@@ -111,7 +102,8 @@ export default async function AboutPage({ params: { locale } }: AboutPageProps)
</ol>
</CardContent>
</AppCard>
</MotionFade>
</Container>
</MotionFade>
</Container>
</>
);
}
+11 -6
View File
@@ -4,6 +4,7 @@ import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Container } from "@/components/layout/container";
import { PageHero } from "@/components/layout/page-hero";
import { MotionFade } from "@/components/motion-fade";
import { buildLocalizedMetadata } from "@/lib/metadata";
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
@@ -39,8 +40,11 @@ export default async function ContactPage({ params: { locale } }: ContactPagePro
const t = await getTranslations({ locale: localeKey, namespace: "contactPage" });
return (
<Container className="grid gap-6 py-10 lg:grid-cols-2 lg:py-14">
<MotionFade>
<>
<PageHero title={t("title")} description={t("intro")} />
<Container className="grid gap-6 pb-12 lg:grid-cols-2 lg:pb-16">
<MotionFade>
<AppCard level={3}>
<CardHeader>
<CardTitle className="text-3xl sm:text-4xl">{t("title")}</CardTitle>
@@ -63,9 +67,9 @@ export default async function ContactPage({ params: { locale } }: ContactPagePro
</ul>
</CardContent>
</AppCard>
</MotionFade>
</MotionFade>
<MotionFade delay={0.05}>
<MotionFade delay={0.05}>
<AppCard>
<CardContent className="pt-6">
<form className="grid gap-4">
@@ -93,7 +97,8 @@ export default async function ContactPage({ params: { locale } }: ContactPagePro
</form>
</CardContent>
</AppCard>
</MotionFade>
</Container>
</MotionFade>
</Container>
</>
);
}
+13 -4
View File
@@ -2,10 +2,11 @@ import type { ReactNode } from "react";
import { unstable_noStore as noStore } from "next/cache";
import { redirect } from "next/navigation";
import { SiteAmbientBackdrop } from "@/components/layout/site-ambient-backdrop";
import { SiteFooter } from "@/components/layout/site-footer";
import { SiteHeader } from "@/components/layout/site-header";
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";
type SiteLayoutProps = {
@@ -22,7 +23,10 @@ export default async function SiteLayout({ children, params: { locale } }: SiteL
noStore();
const localeKey = resolveLocale(locale);
const maintenanceEnabled = await getMaintenanceMode();
const [maintenanceEnabled, mediaBindings] = await Promise.all([
getMaintenanceMode(),
getSiteSettingsMediaBindings(),
]);
const authenticated = isAdminAuthenticated();
if (maintenanceEnabled && !authenticated) {
@@ -30,8 +34,13 @@ export default async function SiteLayout({ children, params: { locale } }: SiteL
}
return (
<div className="flex min-h-screen flex-col">
<SiteHeader isAdmin={authenticated} />
<div className="relative flex min-h-screen flex-col overflow-hidden">
<SiteAmbientBackdrop />
<SiteHeader
isAdmin={authenticated}
lightLogoUrl={mediaBindings.siteLogoLight?.url}
darkLogoUrl={mediaBindings.siteLogoDark?.url}
/>
<main className="flex-1">{children}</main>
<SiteFooter isAdmin={authenticated} />
</div>
+19 -48
View File
@@ -4,12 +4,12 @@ import {
BriefcaseBusiness,
Boxes,
Mail,
Sparkles,
} from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Container } from "@/components/layout/container";
import { HomeHero } from "@/components/layout/home-hero";
import { MotionFade } from "@/components/motion-fade";
import { getSiteSettings } from "@/lib/app-config";
import { buildLocalizedMetadata } from "@/lib/metadata";
@@ -56,48 +56,18 @@ export default async function HomePage({ params: { locale } }: HomePageProps) {
const t = await getTranslations({ locale: localeKey, namespace: "homepage" });
return (
<Container className="flex flex-col gap-section py-10 lg:py-14">
<MotionFade>
<AppCard level={3}>
<CardContent className="grid gap-6 p-6 lg:grid-cols-[1.35fr_0.65fr] lg:p-10">
<div className="space-y-5">
<p className="inline-flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Sparkles className="h-4 w-4 text-brand-secondary" />
{t("heroKicker")}
</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>
<>
<HomeHero
locale={localeKey}
kicker={t("heroKicker")}
title={t("heroTitle")}
description={t("heroText")}
portfolioLabel={t("toPortfolio")}
contactLabel={t("toContact")}
/>
<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>
<MotionFade delay={0.05}>
<Container className="flex flex-col gap-section pb-12 lg:pb-16">
<MotionFade delay={0.05}>
<AppCard>
<CardContent className="p-6 lg:p-8">
<div className="mb-6 flex items-center justify-between gap-4">
@@ -139,9 +109,9 @@ export default async function HomePage({ params: { locale } }: HomePageProps) {
</div>
</CardContent>
</AppCard>
</MotionFade>
</MotionFade>
<MotionFade delay={0.1}>
<MotionFade delay={0.1}>
<AppCard>
<CardContent className="p-6 lg:p-8">
<div className="mb-6 flex items-center justify-between gap-4">
@@ -182,9 +152,9 @@ export default async function HomePage({ params: { locale } }: HomePageProps) {
</div>
</CardContent>
</AppCard>
</MotionFade>
</MotionFade>
<MotionFade delay={0.15}>
<MotionFade delay={0.15}>
<AppCard level="inverse">
<CardContent className="p-6 lg:p-8">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
@@ -206,7 +176,8 @@ export default async function HomePage({ params: { locale } }: HomePageProps) {
</div>
</CardContent>
</AppCard>
</MotionFade>
</Container>
</MotionFade>
</Container>
</>
);
}
+86 -33
View File
@@ -6,6 +6,7 @@ import { getTranslations } from "next-intl/server";
import { notFound } from "next/navigation";
import { Container } from "@/components/layout/container";
import { PageHero } from "@/components/layout/page-hero";
import { MotionFade } from "@/components/motion-fade";
import { buildLocalizedMetadata } from "@/lib/metadata";
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({
params: { locale, slug },
}: PortfolioItemPageProps): Promise<Metadata> {
@@ -87,8 +155,14 @@ export default async function PortfolioItemPage({
const t = await getTranslations({ locale: localeKey, namespace: "portfolioDetail" });
return (
<Container size="wide" className="flex flex-col gap-section py-10 lg:py-14">
<MotionFade>
<>
<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>
<AppCard level={3}>
<CardContent className="p-6 lg:p-10">
<Button asChild variant="ghost" className="h-auto px-0 py-0 text-sm">
@@ -161,44 +235,22 @@ export default async function PortfolioItemPage({
) : null}
</CardContent>
</AppCard>
</MotionFade>
</MotionFade>
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-4 md:grid-cols-3">
{item.sections.map((section, index) => (
<MotionFade key={section.id} delay={0.05 * (index + 1)}>
<AppCard>
<CardContent className="p-5">
<h2 className="text-lg font-semibold text-foreground">
{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}
{renderSectionContent(section, localeKey, t)}
</CardContent>
</AppCard>
</MotionFade>
))}
</div>
</div>
{item.assets.length > 0 ? (
<MotionFade delay={0.1}>
{item.assets.length > 0 ? (
<MotionFade delay={0.1}>
<AppCard>
<CardContent className="p-6 lg:p-8">
<h2 className="text-xl font-semibold text-foreground">{t("gallery")}</h2>
@@ -230,8 +282,9 @@ export default async function PortfolioItemPage({
</div>
</CardContent>
</AppCard>
</MotionFade>
) : null}
</Container>
</MotionFade>
) : null}
</Container>
</>
);
}
+11 -22
View File
@@ -1,9 +1,10 @@
import type { Metadata } from "next";
import { ArrowUpRight, CalendarDays, FolderKanban } from "lucide-react";
import { ArrowUpRight, CalendarDays } from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Container } from "@/components/layout/container";
import { PageHero } from "@/components/layout/page-hero";
import { MotionFade } from "@/components/motion-fade";
import { buildLocalizedMetadata } from "@/lib/metadata";
import { AppCard } from "@/components/ui/app-card";
@@ -55,24 +56,11 @@ export default async function PortfolioPage({
]);
return (
<Container className="flex flex-col gap-section py-10 lg:py-14">
<MotionFade>
<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>
<>
<PageHero title={t("title")} description={t("intro")} />
<section className="flex flex-wrap gap-3">
<Container className="flex flex-col gap-section pb-12 lg:pb-16">
<section className="flex flex-wrap gap-3">
<Link
href={getLocalizedPath(localeKey, "/portfolio")}
className={`rounded-md border px-4 py-2 text-sm transition-colors ${
@@ -96,9 +84,9 @@ export default async function PortfolioPage({
{getLocalizedValue(category.name, localeKey)}
</Link>
))}
</section>
</section>
<section className="grid gap-4 md:grid-cols-2">
<section className="grid gap-4 md:grid-cols-2">
{projects.map((item, index) => (
<MotionFade key={item.slug} delay={index * 0.05}>
<AppCard interactive>
@@ -139,7 +127,8 @@ export default async function PortfolioPage({
</CardContent>
</AppCard>
) : null}
</section>
</Container>
</section>
</Container>
</>
);
}
+14 -6
View File
@@ -5,6 +5,7 @@ import { getTranslations } from "next-intl/server";
import { notFound } from "next/navigation";
import { Container } from "@/components/layout/container";
import { PageHero } from "@/components/layout/page-hero";
import { MotionFade } from "@/components/motion-fade";
import { routing } from "@/i18n/routing";
import { buildLocalizedMetadata } from "@/lib/metadata";
@@ -64,8 +65,14 @@ export default async function ProductPage({ params: { locale, slug } }: ProductP
const t = await getTranslations({ locale: localeKey, namespace: "productDetail" });
return (
<Container size="wide" className="flex flex-col gap-section py-10 lg:py-14">
<MotionFade>
<>
<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>
<AppCard level={3}>
<CardContent className="p-6 lg:p-10">
<Button asChild variant="ghost" className="h-auto px-0 py-0 text-sm">
@@ -111,9 +118,9 @@ export default async function ProductPage({ params: { locale, slug } }: ProductP
</div>
</CardContent>
</AppCard>
</MotionFade>
</MotionFade>
<MotionFade delay={0.05}>
<MotionFade delay={0.05}>
<AppCard>
<CardContent className="p-6 lg:p-8">
<h2 className="text-xl font-semibold text-foreground">{t("included")}</h2>
@@ -134,7 +141,8 @@ export default async function ProductPage({ params: { locale, slug } }: ProductP
</Button>
</CardContent>
</AppCard>
</MotionFade>
</Container>
</MotionFade>
</Container>
</>
);
}
+9 -20
View File
@@ -1,9 +1,10 @@
import type { Metadata } from "next";
import { ArrowUpRight, Boxes, CircleDollarSign } from "lucide-react";
import { ArrowUpRight, CircleDollarSign } from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Container } from "@/components/layout/container";
import { PageHero } from "@/components/layout/page-hero";
import { MotionFade } from "@/components/motion-fade";
import { buildLocalizedMetadata } from "@/lib/metadata";
import { AppCard } from "@/components/ui/app-card";
@@ -36,24 +37,11 @@ export default async function ProductsPage({ params: { locale } }: ProductsPageP
const t = await getTranslations({ locale: localeKey, namespace: "productsPage" });
return (
<Container className="flex flex-col gap-section py-10 lg:py-14">
<MotionFade>
<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>
<>
<PageHero title={t("title")} description={t("intro")} />
<section className="grid gap-4 md:grid-cols-3">
<Container className="flex flex-col gap-section pb-12 lg:pb-16">
<section className="grid gap-4 md:grid-cols-3">
{productItems.map((item, index) => (
<MotionFade key={item.slug} delay={index * 0.05}>
<AppCard interactive>
@@ -84,7 +72,8 @@ export default async function ProductsPage({ params: { locale } }: ProductsPageP
</AppCard>
</MotionFade>
))}
</section>
</Container>
</section>
</Container>
</>
);
}
+9 -4
View File
@@ -4,6 +4,7 @@ import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Container } from "@/components/layout/container";
import { PageHero } from "@/components/layout/page-hero";
import { MotionFade } from "@/components/motion-fade";
import { buildLocalizedMetadata } from "@/lib/metadata";
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
@@ -36,8 +37,11 @@ export default async function SuccessPage({ params: { locale } }: SuccessPagePro
const t = await getTranslations({ locale: localeKey, namespace: "successPage" });
return (
<Container size="narrow" className="py-12 lg:py-16">
<MotionFade className="w-full">
<>
<PageHero title={t("title")} description={t("text")} />
<Container size="narrow" className="pb-12 lg:pb-16">
<MotionFade className="w-full">
<AppCard level={3}>
<CardContent className="p-8 text-center">
<CheckCircle2 className="mx-auto h-12 w-12 text-status-success" />
@@ -57,7 +61,8 @@ export default async function SuccessPage({ params: { locale } }: SuccessPagePro
</div>
</CardContent>
</AppCard>
</MotionFade>
</Container>
</MotionFade>
</Container>
</>
);
}
+28 -36
View File
@@ -3,14 +3,13 @@ import { Sparkles } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { AnimatedLogo } from "@/components/layout/animated-logo";
import { Container } from "@/components/layout/container";
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 { getSiteSettings } from "@/lib/app-config";
import { buildLocalizedMetadata } from "@/lib/metadata";
import { resolveLocale } from "@/lib/locale";
import { AppCard } from "@/components/ui/app-card";
import { CardContent } from "@/components/ui/card";
type ComingSoonPageProps = {
params: {
@@ -41,47 +40,40 @@ export default async function ComingSoonPage({
const t = await getTranslations({ locale: localeKey, namespace: "comingSoon" });
return (
<div className="surface-ambient relative min-h-screen overflow-hidden">
<div className="relative min-h-screen overflow-hidden">
<SiteAmbientBackdrop />
<FloatingPreferences locale={localeKey} />
<Container className="relative flex min-h-screen items-center py-24 sm:py-28">
<MotionFade className="w-full">
<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" />
<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">
<HeroAtmosphere />
<div className="relative">
<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">
<Sparkles className="h-3.5 w-3.5 text-brand-secondary" />
{t("badge")}
</p>
<MotionFade className="relative z-10 w-full">
<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" />
{t("badge")}
</p>
<div className="mt-8">
<AnimatedLogo />
</div>
<div className="mt-8">
<AnimatedLogo />
</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">
{t("titleStart")}{" "}
<span className="brand-gradient-text">{t("titleAccent")}</span>{" "}
{t("titleEnd")}
</h1>
<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")}{" "}
<span className="brand-gradient-text">{t("titleAccent")}</span>{" "}
{t("titleEnd")}
</h1>
<p className="mx-auto mt-5 max-w-2xl text-base leading-relaxed text-muted-foreground sm:text-lg">
{t("description")}
</p>
<p className="mx-auto mt-6 max-w-[34rem] text-sm leading-7 text-foreground/68 sm:text-base">
{t("description")}
</p>
<p className="mx-auto mt-4 max-w-2xl text-sm text-foreground/72 sm:mt-5 sm:text-base">
{t("note")}
</p>
</div>
</CardContent>
</AppCard>
<p className="mx-auto mt-4 max-w-[32rem] text-sm leading-6 text-foreground/54 sm:text-base">
{t("note")}
</p>
</div>
</MotionFade>
</Container>
</section>
</div>
);
}
+67 -62
View File
@@ -4,44 +4,44 @@
:root {
--background: 0 0% 100%;
--foreground: 224 71.4% 4.1%;
--foreground: 222 24% 10%;
--card: 0 0% 100%;
--card-foreground: 224 71.4% 4.1%;
--card-foreground: 222 24% 10%;
--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%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--secondary: 220 14% 96%;
--secondary-foreground: 222 22% 18%;
--muted: 220 14% 96%;
--muted-foreground: 220 10% 42%;
--accent: 220 14% 96%;
--accent-foreground: 222 22% 18%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--surface-1: 0 0% 100%;
--surface-2: 210 20% 96%;
--surface-3: 210 24% 93%;
--surface-inverse: 222 34% 14%;
--surface-2: 220 16% 97%;
--surface-3: 220 14% 94%;
--surface-inverse: 222 24% 12%;
--surface-inverse-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--border-strong: 214.3 31.8% 85%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--border: 220 13% 90%;
--border-strong: 220 11% 82%;
--input: 220 13% 90%;
--ring: 9 73% 50%;
--status-success: 145 63% 42%;
--status-success-soft: 145 58% 92%;
--status-warning: 36 92% 44%;
--status-warning-soft: 41 96% 90%;
--brand-primary: 214 84% 42%;
--brand-secondary: 191 78% 42%;
--ambient-primary-alpha: 0.08;
--ambient-secondary-alpha: 0.06;
--brand-primary: 9 73% 50%;
--brand-secondary: 12 64% 58%;
--ambient-primary-alpha: 0.06;
--ambient-secondary-alpha: 0.04;
--ambient-grid-alpha: 0;
--radius-surface: 0rem;
@@ -66,56 +66,56 @@
--section-space: 3.5rem;
--content-space: 1.5rem;
--sidebar-background: 210 22% 95%;
--sidebar-foreground: 222 24% 18%;
--sidebar-primary: 214 84% 42%;
--sidebar-background: 220 16% 97%;
--sidebar-foreground: 222 22% 16%;
--sidebar-primary: 9 73% 50%;
--sidebar-primary-foreground: 210 40% 98%;
--sidebar-accent: 210 20% 90%;
--sidebar-accent-foreground: 222 24% 18%;
--sidebar-border: 214 20% 84%;
--sidebar-ring: 214 84% 42%;
--sidebar-accent: 220 14% 94%;
--sidebar-accent-foreground: 222 22% 18%;
--sidebar-border: 220 12% 86%;
--sidebar-ring: 9 73% 50%;
}
.dark {
--background: 224 71.4% 4.1%;
--foreground: 210 20% 98%;
--background: 222 22% 8%;
--foreground: 210 20% 96%;
--card: 224 71.4% 4.1%;
--card-foreground: 210 20% 98%;
--popover: 224 71.4% 4.1%;
--popover-foreground: 210 20% 98%;
--card: 222 22% 9%;
--card-foreground: 210 20% 96%;
--popover: 222 22% 9%;
--popover-foreground: 210 20% 96%;
--primary: 210 20% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 215 27.9% 16.9%;
--secondary-foreground: 210 20% 98%;
--muted: 215 27.9% 16.9%;
--muted-foreground: 217.9 10.6% 64.9%;
--accent: 215 27.9% 16.9%;
--accent-foreground: 210 20% 98%;
--primary: 9 78% 56%;
--primary-foreground: 210 40% 98%;
--secondary: 222 16% 14%;
--secondary-foreground: 210 20% 94%;
--muted: 222 16% 14%;
--muted-foreground: 217 10% 64%;
--accent: 222 16% 14%;
--accent-foreground: 210 20% 94%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--surface-1: 222 28% 11%;
--surface-2: 222 22% 14%;
--surface-3: 222 18% 17%;
--surface-inverse: 210 40% 98%;
--surface-inverse-foreground: 222 33% 8%;
--surface-1: 222 22% 9%;
--surface-2: 222 18% 12%;
--surface-3: 222 16% 15%;
--surface-inverse: 210 20% 96%;
--surface-inverse-foreground: 222 24% 10%;
--border: 215 27.9% 16.9%;
--border-strong: 215 20.2% 25%;
--input: 215 27.9% 16.9%;
--ring: 216 12.2% 83.9%;
--border: 222 14% 18%;
--border-strong: 222 12% 26%;
--input: 222 14% 18%;
--ring: 9 78% 56%;
--status-success: 145 68% 66%;
--status-success-soft: 145 36% 18%;
--status-warning: 42 96% 66%;
--status-warning-soft: 35 40% 18%;
--brand-primary: 205 88% 64%;
--brand-secondary: 190 76% 57%;
--brand-primary: 9 78% 56%;
--brand-secondary: 12 74% 64%;
--ambient-primary-alpha: 0.08;
--ambient-secondary-alpha: 0.06;
--ambient-secondary-alpha: 0.05;
--ambient-grid-alpha: 0;
--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-sidebar: inset 0 1px 0 hsl(210 25% 92% / 0.06);
--sidebar-background: 222 25% 10%;
--sidebar-foreground: 210 18% 86%;
--sidebar-primary: 205 88% 64%;
--sidebar-primary-foreground: 222 33% 8%;
--sidebar-accent: 221 18% 18%;
--sidebar-accent-foreground: 210 25% 92%;
--sidebar-border: 221 18% 22%;
--sidebar-ring: 205 88% 64%;
--sidebar-background: 222 20% 10%;
--sidebar-foreground: 210 18% 88%;
--sidebar-primary: 9 78% 56%;
--sidebar-primary-foreground: 210 40% 98%;
--sidebar-accent: 222 16% 14%;
--sidebar-accent-foreground: 210 20% 94%;
--sidebar-border: 222 14% 20%;
--sidebar-ring: 9 78% 56%;
}
html {
@@ -167,6 +167,11 @@ html.dark {
}
@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-wrap: balance;
}
+13 -230
View File
@@ -1,18 +1,9 @@
import { redirect } from "next/navigation";
import { FileText, Hash, Text } from "lucide-react";
import { MotionFade } from "@/components/motion-fade";
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 { 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 { getAdminPortfolioCategories } from "@/lib/portfolio";
@@ -20,15 +11,9 @@ import { deleteCategoryAction, upsertCategoryAction } from "../actions";
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 = {
title: "Portfolio Kategorien",
subtitle: "Kategorien fuer Portfolio Projekte verwalten.",
subtitle: "Kategorien schnell anlegen, oeffnen und direkt im Modal bearbeiten.",
overview: "Uebersicht",
maintenance: "Wartungsmodus",
uiKit: "UI Kit",
@@ -37,13 +22,6 @@ const copy = {
portfolio: "Portfolio",
logout: "Ausloggen",
backToSite: "Zur Website",
sortOrder: "Sortierung",
active: "Aktiv",
saveCategory: "Kategorie speichern",
save: "Speichern",
delete: "Loeschen",
projects: "Projekte",
description: "Beschreibung",
};
type RootPortfolioCategoriesPageProps = {
@@ -68,6 +46,8 @@ export default async function RootPortfolioCategoriesPage({
}
const categories = await getAdminPortfolioCategories();
const activeCount = categories.filter((category) => category.isActive).length;
const assignedProjects = categories.reduce((sum, category) => sum + category.projectCount, 0);
return (
<RootDashboardShell
@@ -77,224 +57,27 @@ export default async function RootPortfolioCategoriesPage({
logoutAction={logoutAction}
headerTitle={copy.title}
headerDescription={copy.subtitle}
saveFormSelector="[data-topbar-save-form='category']"
toolbar={<PortfolioSubnav active="categories" />}
>
<div className="space-y-6">
{searchParams?.success ? (
<MotionFade delay={0.1}>
<MotionFade delay={0.08}>
<FlashMessage type="success" message={searchParams.success} />
</MotionFade>
) : null}
{searchParams?.error ? (
<MotionFade delay={0.12}>
<MotionFade delay={0.1}>
<FlashMessage type="error" message={searchParams.error} />
</MotionFade>
) : null}
<MotionFade delay={0.15}>
<AppCard>
<CardHeader>
<CardTitle>Neue Kategorie</CardTitle>
<CardDescription>Eine Kategorie wird genau einem oder mehreren Projekten zugeordnet.</CardDescription>
</CardHeader>
<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>
</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>
<PortfolioCategoriesManager
categories={categories}
activeCount={activeCount}
assignedProjects={assignedProjects}
saveCategoryAction={upsertCategoryAction}
removeCategoryAction={deleteCategoryAction}
/>
</div>
</RootDashboardShell>
);
@@ -78,7 +78,6 @@ export default async function RootPortfolioProjectPage({
logoutAction={logoutAction}
headerTitle={copy.title}
headerDescription={copy.subtitle}
saveFormId="portfolio-project-form"
>
<div className="space-y-6">
{searchParams?.success ? (
-1
View File
@@ -58,7 +58,6 @@ export default async function RootNewPortfolioProjectPage({
logoutAction={logoutAction}
headerTitle={copy.title}
headerDescription={copy.subtitle}
saveFormId="portfolio-project-form"
>
<div className="space-y-6">
{searchParams?.error ? (
+68
View File
@@ -10,6 +10,8 @@ import {
SITE_SETTINGS_ENTITY_ID,
SITE_SETTINGS_ENTITY_TYPE,
SITE_SETTINGS_FAVICON_FIELD_KEY,
SITE_SETTINGS_LOGO_DARK_FIELD_KEY,
SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY,
updateSiteSettings,
} from "@/lib/app-config";
import { PAGE_TITLE_TOKEN, type SiteSettings } from "@/lib/site-settings";
@@ -94,6 +96,8 @@ export async function saveSiteSettingsAction(formData: FormData) {
const uploadedPaths: string[] = [];
try {
const siteLogoLightMedia = parseJsonObject(formData.get("siteLogoLightMedia"), "siteLogoLightMedia");
const siteLogoDarkMedia = parseJsonObject(formData.get("siteLogoDarkMedia"), "siteLogoDarkMedia");
const faviconMedia = parseJsonObject(formData.get("faviconMedia"), "faviconMedia");
const defaultOgImageMedia = parseJsonObject(
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
? await resolveMediaSelection({
media: mediaFieldInputSchema.parse(faviconMedia),
@@ -166,6 +200,22 @@ export async function saveSiteSettingsAction(formData: FormData) {
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) {
createdMediaAssetIds.push(faviconSelection.createdAssetId);
}
@@ -187,6 +237,24 @@ export async function saveSiteSettingsAction(formData: FormData) {
entityType: SITE_SETTINGS_ENTITY_TYPE,
entityId: SITE_SETTINGS_ENTITY_ID,
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
? [
{