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