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,20 +35,10 @@ 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")} />
|
||||
|
||||
<Container className="flex flex-col gap-section pb-12 lg:pb-16">
|
||||
<MotionFade delay={0.05}>
|
||||
<AppCard>
|
||||
<CardContent className="p-6 lg:p-8">
|
||||
@@ -113,5 +104,6 @@ export default async function AboutPage({ params: { locale } }: AboutPageProps)
|
||||
</AppCard>
|
||||
</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,7 +40,10 @@ 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">
|
||||
<>
|
||||
<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>
|
||||
@@ -95,5 +99,6 @@ export default async function ContactPage({ params: { locale } }: ContactPagePro
|
||||
</AppCard>
|
||||
</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,47 +56,17 @@ 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>
|
||||
|
||||
<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>
|
||||
<>
|
||||
<HomeHero
|
||||
locale={localeKey}
|
||||
kicker={t("heroKicker")}
|
||||
title={t("heroTitle")}
|
||||
description={t("heroText")}
|
||||
portfolioLabel={t("toPortfolio")}
|
||||
contactLabel={t("toContact")}
|
||||
/>
|
||||
|
||||
<Container className="flex flex-col gap-section pb-12 lg:pb-16">
|
||||
<MotionFade delay={0.05}>
|
||||
<AppCard>
|
||||
<CardContent className="p-6 lg:p-8">
|
||||
@@ -208,5 +178,6 @@ export default async function HomePage({ params: { locale } }: HomePageProps) {
|
||||
</AppCard>
|
||||
</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,7 +155,13 @@ 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">
|
||||
<>
|
||||
<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">
|
||||
@@ -168,29 +242,7 @@ export default async function PortfolioItemPage({
|
||||
<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>
|
||||
@@ -233,5 +285,6 @@ export default async function PortfolioItemPage({
|
||||
</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,23 +56,10 @@ 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")} />
|
||||
|
||||
<Container className="flex flex-col gap-section pb-12 lg:pb-16">
|
||||
<section className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
href={getLocalizedPath(localeKey, "/portfolio")}
|
||||
@@ -141,5 +129,6 @@ export default async function PortfolioPage({
|
||||
) : null}
|
||||
</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,7 +65,13 @@ 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">
|
||||
<>
|
||||
<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">
|
||||
@@ -136,5 +143,6 @@ export default async function ProductPage({ params: { locale, slug } }: ProductP
|
||||
</AppCard>
|
||||
</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,23 +37,10 @@ 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")} />
|
||||
|
||||
<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}>
|
||||
@@ -86,5 +74,6 @@ export default async function ProductsPage({ params: { locale } }: ProductsPageP
|
||||
))}
|
||||
</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,7 +37,10 @@ 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">
|
||||
<>
|
||||
<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">
|
||||
@@ -59,5 +63,6 @@ export default async function SuccessPage({ params: { locale } }: SuccessPagePro
|
||||
</AppCard>
|
||||
</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,21 +40,16 @@ 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">
|
||||
<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>
|
||||
@@ -64,24 +58,22 @@ export default async function ComingSoonPage({
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<p className="mx-auto mt-4 max-w-[32rem] text-sm leading-6 text-foreground/54 sm:text-base">
|
||||
{t("note")}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</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,225 +57,28 @@ 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"
|
||||
<PortfolioCategoriesManager
|
||||
categories={categories}
|
||||
activeCount={activeCount}
|
||||
assignedProjects={assignedProjects}
|
||||
saveCategoryAction={upsertCategoryAction}
|
||||
removeCategoryAction={deleteCategoryAction}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 rounded-md border border-input bg-card px-4 py-3 text-sm md:col-span-2">
|
||||
<Checkbox name="isActive" defaultChecked />
|
||||
{copy.active}
|
||||
</label>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<Button type="submit">{copy.saveCategory}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</MotionFade>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{categories.map((category, index) => (
|
||||
<MotionFade key={category.id} delay={0.18 + index * 0.03}>
|
||||
<AppCard>
|
||||
<CardContent className="p-6">
|
||||
<form
|
||||
id={`portfolio-category-form-${category.id}`}
|
||||
action={upsertCategoryAction}
|
||||
className="grid gap-4 md:grid-cols-2"
|
||||
data-topbar-save-form="category"
|
||||
>
|
||||
<input type="hidden" name="id" value={category.id} />
|
||||
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`slug-${category.id}`} className="sr-only">Slug</Label>
|
||||
<div className="relative">
|
||||
<Text className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input id={`slug-${category.id}`} name="slug" defaultValue={category.slug} required placeholder="Slug" className="pl-9" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`sortOrder-${category.id}`} className="sr-only">{copy.sortOrder}</Label>
|
||||
<div className="relative">
|
||||
<Hash className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id={`sortOrder-${category.id}`}
|
||||
name="sortOrder"
|
||||
type="number"
|
||||
min="0"
|
||||
defaultValue={category.sortOrder}
|
||||
required
|
||||
placeholder={copy.sortOrder}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<Tabs defaultValue="Ar">
|
||||
<TabsList>
|
||||
{locales.map((locale) => (
|
||||
<TabsTrigger key={`${category.id}-${locale.key}`} value={locale.key}>
|
||||
{locale.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{locales.map((locale) => {
|
||||
const lowerLocale = locale.key.toLowerCase() as "ar" | "en" | "de";
|
||||
const nameKey = `name${locale.key}` as const;
|
||||
const descriptionKey = `description${locale.key}` as const;
|
||||
|
||||
return (
|
||||
<TabsContent key={`${category.id}-content-${locale.key}`} value={locale.key}>
|
||||
<div className="grid gap-4 rounded-lg border bg-card p-4 shadow-sm md:grid-cols-2">
|
||||
<div className="md:col-span-2 text-sm text-muted-foreground">{locale.hint}</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${nameKey}-${category.id}`} className="sr-only">{`Name ${locale.label}`}</Label>
|
||||
<div className="relative">
|
||||
<Text className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id={`${nameKey}-${category.id}`}
|
||||
name={nameKey}
|
||||
defaultValue={category.name[lowerLocale]}
|
||||
required
|
||||
placeholder={`Name ${locale.label}`}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor={`${descriptionKey}-${category.id}`} className="sr-only">{`${copy.description} ${locale.label}`}</Label>
|
||||
<div className="relative">
|
||||
<FileText className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Textarea
|
||||
id={`${descriptionKey}-${category.id}`}
|
||||
name={descriptionKey}
|
||||
rows={4}
|
||||
defaultValue={category.description[lowerLocale]}
|
||||
required
|
||||
placeholder={`${copy.description} ${locale.label}`}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 rounded-md border border-input bg-card px-4 py-3 text-sm">
|
||||
<Checkbox name="isActive" defaultChecked={category.isActive} />
|
||||
{copy.active}
|
||||
</label>
|
||||
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<p className="text-sm text-muted-foreground">{category.projectCount} {copy.projects}</p>
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit">{copy.save}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form action={deleteCategoryAction} className="mt-4">
|
||||
<input type="hidden" name="id" value={category.id} />
|
||||
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
|
||||
<Button type="submit" variant="destructive" disabled={category.projectCount > 0}>
|
||||
{copy.delete}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</MotionFade>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</RootDashboardShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -32,7 +32,7 @@ export function DashboardSidebar({ items, iconSrc, top, footer }: DashboardSideb
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
|
||||
"flex items-center gap-3 rounded-nested px-3 py-2 text-sm transition-colors",
|
||||
item.active
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
@@ -51,7 +51,7 @@ export function DashboardSidebar({ items, iconSrc, top, footer }: DashboardSideb
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-sidebar/40">
|
||||
<div className="flex items-center gap-3 px-4 py-5">
|
||||
<div className="overflow-hidden rounded-xl border border-border/70 bg-background shadow-sm">
|
||||
<div className="overflow-hidden rounded-surface border border-border/70 bg-background shadow-sm">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={iconSrc || "/favicon.ico"}
|
||||
|
||||
@@ -41,7 +41,7 @@ export function AppSidebar({
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors",
|
||||
"flex items-center gap-2 rounded-nested px-3 py-2 text-sm transition-colors",
|
||||
item.active
|
||||
? "bg-sidebar-primary text-sidebar-primary-foreground"
|
||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
type HeroAtmosphereProps = {
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export function HeroAtmosphere({ compact = false }: HeroAtmosphereProps) {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<motion.div
|
||||
animate={{
|
||||
x: [0, 90, -48, 0],
|
||||
y: [0, -70, 32, 0],
|
||||
scale: [1, 1.12, 0.96, 1],
|
||||
}}
|
||||
transition={{
|
||||
duration: 20,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
className="absolute left-[-10%] top-[4%] h-[24rem] w-[24rem] rounded-full bg-brand-primary/22 blur-3xl sm:h-[34rem] sm:w-[34rem] lg:h-[42rem] lg:w-[42rem]"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
animate={{
|
||||
x: [0, -110, 40, 0],
|
||||
y: [0, 48, -26, 0],
|
||||
scale: [1, 0.92, 1.08, 1],
|
||||
}}
|
||||
transition={{
|
||||
duration: 24,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
className="absolute right-[-14%] top-[2%] h-[22rem] w-[22rem] rounded-full bg-brand-secondary/18 blur-3xl sm:h-[30rem] sm:w-[30rem] lg:h-[38rem] lg:w-[38rem]"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
animate={{
|
||||
x: [0, 42, -24, 0],
|
||||
y: [0, -28, 30, 0],
|
||||
opacity: [0.2, 0.34, 0.18, 0.2],
|
||||
}}
|
||||
transition={{
|
||||
duration: 18,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
className="absolute bottom-[-18%] left-[18%] h-[18rem] w-[18rem] rounded-full bg-brand-primary/14 blur-3xl sm:h-[24rem] sm:w-[24rem] lg:h-[30rem] lg:w-[30rem]"
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.58),rgba(255,255,255,0.32)_32%,rgba(255,255,255,0.08)_68%,rgba(255,255,255,0.02)),radial-gradient(circle_at_18%_24%,rgba(221,65,36,0.16),transparent_30%),radial-gradient(circle_at_78%_20%,rgba(235,143,117,0.18),transparent_34%),radial-gradient(circle_at_50%_100%,rgba(174,196,255,0.18),transparent_38%)] dark:bg-[linear-gradient(180deg,rgba(10,14,22,0.3),rgba(10,14,22,0.42)_34%,rgba(10,14,22,0.7)_100%),radial-gradient(circle_at_18%_24%,rgba(221,65,36,0.2),transparent_30%),radial-gradient(circle_at_78%_20%,rgba(235,143,117,0.12),transparent_34%),radial-gradient(circle_at_50%_100%,rgba(91,117,214,0.12),transparent_38%)]" />
|
||||
|
||||
<motion.div
|
||||
animate={{
|
||||
x: [0, compact ? 18 : 26, compact ? -12 : -18, 0],
|
||||
y: [0, compact ? -14 : -22, compact ? 10 : 16, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: compact ? 8 : 10,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "linear",
|
||||
}}
|
||||
className="hero-noise absolute inset-[-8%] opacity-55 mix-blend-soft-light dark:opacity-22"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { HeroAtmosphere } from "@/components/layout/hero-atmosphere";
|
||||
import { MotionFade } from "@/components/motion-fade";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getLocalizedPath } from "@/lib/locale";
|
||||
|
||||
type HomeHeroProps = {
|
||||
locale: string;
|
||||
kicker: string;
|
||||
title: string;
|
||||
description: string;
|
||||
portfolioLabel: string;
|
||||
contactLabel: string;
|
||||
};
|
||||
|
||||
export function HomeHero({
|
||||
locale,
|
||||
kicker,
|
||||
title,
|
||||
description,
|
||||
portfolioLabel,
|
||||
contactLabel,
|
||||
}: HomeHeroProps) {
|
||||
return (
|
||||
<section className="relative isolate flex min-h-[85vh] items-center overflow-hidden">
|
||||
<HeroAtmosphere />
|
||||
<div className="mx-auto flex w-full max-w-[72rem] flex-col items-center justify-center px-4 pb-12 pt-24 text-center sm:px-6 lg:px-8 lg:pb-16 lg:pt-28">
|
||||
<MotionFade className="w-full">
|
||||
<div className="mx-auto max-w-[54rem]">
|
||||
<p className="text-sm font-medium tracking-[0.08em] text-foreground/62">
|
||||
{kicker}
|
||||
</p>
|
||||
<h1 className="mt-6 whitespace-pre-line text-balance text-[3rem] font-semibold leading-[0.92] tracking-[-0.06em] text-foreground sm:text-[4.5rem] lg:text-[6.75rem]">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="mx-auto mt-6 max-w-[30rem] text-sm leading-6 text-foreground/66 sm:text-base">
|
||||
{description}
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<Button asChild size="lg" className="px-6">
|
||||
<Link href={getLocalizedPath(locale, "/portfolio")}>
|
||||
{portfolioLabel}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="lg" className="px-6">
|
||||
<Link href={getLocalizedPath(locale, "/contact")}>{contactLabel}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</MotionFade>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -11,12 +11,15 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { AppLocale, getLocalizedPath, stripLocalePrefix } from "@/lib/locale";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type LocaleToggleProps = {
|
||||
locale: string;
|
||||
className?: string;
|
||||
showLabel?: boolean;
|
||||
};
|
||||
|
||||
export function LocaleToggle({ locale }: LocaleToggleProps) {
|
||||
export function LocaleToggle({ locale, className, showLabel = false }: LocaleToggleProps) {
|
||||
const pathname = usePathname();
|
||||
const locales: AppLocale[] = ["de", "en", "ar"];
|
||||
const currentPath = stripLocalePrefix(pathname);
|
||||
@@ -27,11 +30,17 @@ export function LocaleToggle({ locale }: LocaleToggleProps) {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
size={showLabel ? "sm" : "icon"}
|
||||
aria-label={`Locale: ${currentLocale.toUpperCase()}`}
|
||||
className={cn(
|
||||
"rounded-pill border border-transparent text-muted-foreground hover:border-border/70 hover:bg-background/80 hover:text-foreground",
|
||||
showLabel ? "gap-2 px-3" : undefined,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Languages className="h-4 w-4" />
|
||||
{showLabel ? <span className="text-xs font-semibold uppercase tracking-[0.22em]">{currentLocale}</span> : null}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[8rem]">
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { HeroAtmosphere } from "@/components/layout/hero-atmosphere";
|
||||
import { MotionFade } from "@/components/motion-fade";
|
||||
|
||||
type PageHeroProps = {
|
||||
title: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export function PageHero({ title, description }: PageHeroProps) {
|
||||
return (
|
||||
<section className="relative isolate flex min-h-[42vh] items-end overflow-hidden">
|
||||
<HeroAtmosphere compact />
|
||||
<div className="mx-auto w-full max-w-[72rem] px-4 pb-10 pt-24 sm:px-6 lg:px-8 lg:pb-12 lg:pt-28">
|
||||
<MotionFade className="max-w-[44rem]">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-foreground/48">
|
||||
{title}
|
||||
</p>
|
||||
<h1 className="mt-4 text-balance text-4xl font-semibold leading-[0.95] tracking-[-0.05em] text-foreground sm:text-5xl lg:text-6xl">
|
||||
{title}
|
||||
</h1>
|
||||
{description ? (
|
||||
<p className="mt-5 max-w-[36rem] text-sm leading-6 text-foreground/66 sm:text-base">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</MotionFade>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export function SiteAmbientBackdrop() {
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
|
||||
<motion.div
|
||||
animate={{
|
||||
x: [0, 72, -36, 0],
|
||||
y: [0, -54, 28, 0],
|
||||
scale: [1, 1.1, 0.95, 1],
|
||||
}}
|
||||
transition={{
|
||||
duration: 18,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
className="absolute left-[-10%] top-[4%] h-[30rem] w-[30rem] rounded-full bg-brand-primary/18 blur-3xl sm:h-[38rem] sm:w-[38rem] lg:h-[46rem] lg:w-[46rem]"
|
||||
/>
|
||||
<motion.div
|
||||
animate={{
|
||||
x: [0, -84, 26, 0],
|
||||
y: [0, 44, -22, 0],
|
||||
scale: [1, 0.9, 1.08, 1],
|
||||
}}
|
||||
transition={{
|
||||
duration: 22,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
className="absolute right-[-12%] top-[8%] h-[26rem] w-[26rem] rounded-full bg-brand-secondary/16 blur-3xl sm:h-[34rem] sm:w-[34rem] lg:h-[42rem] lg:w-[42rem]"
|
||||
/>
|
||||
<motion.div
|
||||
animate={{
|
||||
x: [0, 34, -20, 0],
|
||||
y: [0, -24, 34, 0],
|
||||
opacity: [0.22, 0.38, 0.16, 0.22],
|
||||
}}
|
||||
transition={{
|
||||
duration: 16,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
className="absolute bottom-[-12%] left-[22%] h-[20rem] w-[20rem] rounded-full bg-brand-primary/14 blur-3xl sm:h-[28rem] sm:w-[28rem] lg:h-[34rem] lg:w-[34rem]"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.64),rgba(255,255,255,0.42)_24%,rgba(255,255,255,0.14)_52%,rgba(255,255,255,0.02)_100%),radial-gradient(circle_at_16%_18%,rgba(221,65,36,0.12),transparent_30%),radial-gradient(circle_at_78%_14%,rgba(235,143,117,0.16),transparent_34%),radial-gradient(circle_at_50%_100%,rgba(160,182,255,0.16),transparent_40%)] dark:bg-[linear-gradient(180deg,rgba(11,15,24,0.24),rgba(11,15,24,0.36)_26%,rgba(11,15,24,0.72)_100%),radial-gradient(circle_at_16%_18%,rgba(221,65,36,0.16),transparent_30%),radial-gradient(circle_at_78%_14%,rgba(235,143,117,0.1),transparent_34%),radial-gradient(circle_at_50%_100%,rgba(109,130,214,0.12),transparent_40%)]" />
|
||||
<motion.div
|
||||
animate={{
|
||||
x: [0, 20, -14, 0],
|
||||
y: [0, -16, 12, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 9,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "linear",
|
||||
}}
|
||||
className="hero-noise absolute inset-[-6%] opacity-48 mix-blend-soft-light dark:opacity-18"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { Menu, X } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { LayoutDashboard, Menu, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Container } from "@/components/layout/container";
|
||||
import { LocaleToggle } from "@/components/layout/locale-toggle";
|
||||
import { SiteLogo } from "@/components/layout/site-logo";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getLocalizedPath } from "@/lib/locale";
|
||||
import { getLocalizedPath, stripLocalePrefix } from "@/lib/locale";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const navItems = [
|
||||
{ key: "home", path: "" },
|
||||
@@ -22,111 +25,253 @@ const navItems = [
|
||||
|
||||
type SiteHeaderProps = {
|
||||
isAdmin?: boolean;
|
||||
lightLogoUrl?: string | null;
|
||||
darkLogoUrl?: string | null;
|
||||
};
|
||||
|
||||
function isNavItemActive(currentPath: string, itemPath: string) {
|
||||
if (itemPath === "/") {
|
||||
return currentPath === "/";
|
||||
}
|
||||
|
||||
return currentPath === itemPath || currentPath.startsWith(`${itemPath}/`);
|
||||
}
|
||||
|
||||
function NavLinks({
|
||||
isAdmin,
|
||||
onNavigate,
|
||||
mobile = false,
|
||||
}: {
|
||||
isAdmin: boolean;
|
||||
onNavigate?: () => void;
|
||||
mobile?: boolean;
|
||||
}) {
|
||||
const locale = useLocale();
|
||||
const pathname = usePathname();
|
||||
const t = useTranslations("navigation");
|
||||
const currentPath = stripLocalePrefix(pathname);
|
||||
|
||||
return (
|
||||
<>
|
||||
{navItems.map((item) => (
|
||||
(() => {
|
||||
const itemPath = item.path || "/";
|
||||
const isActive = isNavItemActive(currentPath, itemPath);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.key}
|
||||
href={getLocalizedPath(locale, item.path || "/")}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
href={getLocalizedPath(locale, itemPath)}
|
||||
className={cn(
|
||||
"relative z-10 rounded-[var(--radius-pill)] text-sm font-medium transition-colors",
|
||||
mobile ? "px-4 py-3" : "px-3.5 py-2 xl:px-4",
|
||||
isActive ? "text-primary-foreground" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={onNavigate}
|
||||
>
|
||||
{isActive ? (
|
||||
<motion.span
|
||||
layoutId="site-header-active-tab"
|
||||
className="absolute inset-0 -z-10 rounded-[var(--radius-pill)] bg-primary shadow-[0_14px_34px_-22px_hsl(var(--brand-primary)/0.65)]"
|
||||
transition={{ type: "spring", stiffness: 380, damping: 30 }}
|
||||
/>
|
||||
) : null}
|
||||
{t(item.key)}
|
||||
</Link>
|
||||
);
|
||||
})()
|
||||
))}
|
||||
{isAdmin ? (
|
||||
<a
|
||||
href="/root"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
onClick={onNavigate}
|
||||
>
|
||||
{t("root")}
|
||||
</a>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SiteHeader({ isAdmin = false }: SiteHeaderProps) {
|
||||
export function SiteHeader({
|
||||
isAdmin = false,
|
||||
lightLogoUrl,
|
||||
darkLogoUrl,
|
||||
}: SiteHeaderProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const locale = useLocale();
|
||||
const t = useTranslations("navigation");
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 border-b border-border/80 bg-background/88 backdrop-blur-chrome">
|
||||
<Container>
|
||||
<div className="flex min-h-header items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<header className="sticky top-3 z-40 bg-transparent">
|
||||
<Container className="max-w-[88rem]">
|
||||
<div className="relative grid min-h-[4.5rem] grid-cols-[auto_1fr_auto] items-center gap-3 lg:min-h-[5rem]">
|
||||
<div className="flex items-center">
|
||||
<Link
|
||||
href={getLocalizedPath(locale)}
|
||||
className="inline-flex items-center rounded-md border border-input bg-background px-3 py-2 shadow-sm"
|
||||
className="inline-flex items-center py-1 sm:py-1.5 lg:py-2"
|
||||
>
|
||||
<Image
|
||||
src="/logos/light-primary.svg"
|
||||
<SiteLogo
|
||||
lightLogoUrl={lightLogoUrl}
|
||||
darkLogoUrl={darkLogoUrl}
|
||||
alt="mohfarawati"
|
||||
width={140}
|
||||
height={24}
|
||||
className="block h-6 w-auto dark:hidden"
|
||||
priority
|
||||
/>
|
||||
<Image
|
||||
src="/logos/dark-primary.svg"
|
||||
alt="mohfarawati"
|
||||
width={140}
|
||||
height={24}
|
||||
className="hidden h-6 w-auto dark:block"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-5 md:flex">
|
||||
<NavLinks isAdmin={isAdmin} />
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<ThemeToggle ariaLabel={t("themeToggle")} />
|
||||
<LocaleToggle locale={locale} />
|
||||
<div className="pointer-events-none absolute left-1/2 top-1/2 hidden -translate-x-1/2 -translate-y-1/2 lg:flex">
|
||||
<motion.nav
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.28, ease: "easeOut" }}
|
||||
className="pointer-events-auto inline-flex min-w-[27rem] max-w-[42rem] items-center justify-center gap-1 rounded-[var(--radius-pill)] border border-white/24 bg-[linear-gradient(180deg,rgba(255,255,255,0.22),rgba(255,255,255,0.1))] p-1.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.46),inset_0_-1px_0_rgba(255,255,255,0.08),0_18px_44px_-28px_rgba(15,23,42,0.32)] backdrop-blur-[28px] dark:border-white/12 dark:bg-[linear-gradient(180deg,rgba(255,255,255,0.09),rgba(255,255,255,0.03))] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.12),inset_0_-1px_0_rgba(255,255,255,0.03),0_20px_48px_-28px_rgba(0,0,0,0.72)]"
|
||||
>
|
||||
<NavLinks />
|
||||
</motion.nav>
|
||||
</div>
|
||||
|
||||
<div className="hidden items-center justify-end lg:flex">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.28, ease: "easeOut", delay: 0.03 }}
|
||||
className="inline-flex items-center gap-1 rounded-[var(--radius-pill)] border border-white/24 bg-[linear-gradient(180deg,rgba(255,255,255,0.22),rgba(255,255,255,0.1))] p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.46),inset_0_-1px_0_rgba(255,255,255,0.08),0_18px_44px_-28px_rgba(15,23,42,0.32)] backdrop-blur-[28px] dark:border-white/12 dark:bg-[linear-gradient(180deg,rgba(255,255,255,0.09),rgba(255,255,255,0.03))] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.12),inset_0_-1px_0_rgba(255,255,255,0.03),0_20px_48px_-28px_rgba(0,0,0,0.72)]"
|
||||
>
|
||||
<LocaleToggle
|
||||
locale={locale}
|
||||
showLabel
|
||||
className="h-9 rounded-[var(--radius-pill)] px-3 text-foreground hover:bg-white/34 dark:hover:bg-white/10"
|
||||
/>
|
||||
{isAdmin ? (
|
||||
<Button asChild variant="ghost" size="icon" className="h-9 w-9 rounded-[var(--radius-pill)] border border-transparent text-foreground hover:border-white/12 hover:bg-white/34 dark:hover:border-white/10 dark:hover:bg-white/10">
|
||||
<Link href="/root" aria-label={t("root")}>
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<ThemeToggle
|
||||
ariaLabel={t("themeToggle")}
|
||||
variant="ghost"
|
||||
className="h-9 w-9 rounded-[var(--radius-pill)] border border-transparent text-foreground hover:border-white/12 hover:bg-white/34 dark:hover:border-white/10 dark:hover:bg-white/10"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((open) => !open)}
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="md:hidden"
|
||||
className="ml-auto h-11 w-11 rounded-[var(--radius-pill)] border border-white/24 bg-[linear-gradient(180deg,rgba(255,255,255,0.24),rgba(255,255,255,0.11))] text-foreground shadow-[inset_0_1px_0_rgba(255,255,255,0.46),inset_0_-1px_0_rgba(255,255,255,0.08),0_18px_44px_-28px_rgba(15,23,42,0.32)] backdrop-blur-[28px] hover:bg-[linear-gradient(180deg,rgba(255,255,255,0.3),rgba(255,255,255,0.14))] dark:border-white/12 dark:bg-[linear-gradient(180deg,rgba(255,255,255,0.09),rgba(255,255,255,0.03))] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.12),inset_0_-1px_0_rgba(255,255,255,0.03),0_20px_48px_-28px_rgba(0,0,0,0.72)] dark:hover:bg-[linear-gradient(180deg,rgba(255,255,255,0.12),rgba(255,255,255,0.05))] lg:hidden"
|
||||
aria-label={isOpen ? t("closeMenu") : t("openMenu")}
|
||||
>
|
||||
<motion.span
|
||||
animate={{ rotate: isOpen ? 180 : 0, scale: isOpen ? 0.96 : 1 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
{isOpen ? <X className="h-4 w-4" /> : <Menu className="h-4 w-4" />}
|
||||
</motion.span>
|
||||
</Button>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen ? (
|
||||
<div className="border-t border-border/80 bg-background md:hidden">
|
||||
<Container className="py-4">
|
||||
<nav className="flex flex-col gap-3">
|
||||
<NavLinks isAdmin={isAdmin} onNavigate={() => setIsOpen(false)} />
|
||||
</nav>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<ThemeToggle ariaLabel={t("themeToggle")} />
|
||||
<LocaleToggle locale={locale} />
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -14 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.22, ease: "easeOut" }}
|
||||
className="bg-transparent lg:hidden"
|
||||
>
|
||||
<Container className="pb-2 pt-3">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.98 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="overflow-hidden rounded-surface border border-white/24 bg-[linear-gradient(180deg,rgba(255,255,255,0.24),rgba(255,255,255,0.1))] shadow-[inset_0_1px_0_rgba(255,255,255,0.46),inset_0_-1px_0_rgba(255,255,255,0.08),0_18px_44px_-28px_rgba(15,23,42,0.32)] backdrop-blur-[28px] dark:border-white/12 dark:bg-[linear-gradient(180deg,rgba(255,255,255,0.09),rgba(255,255,255,0.03))] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.12),inset_0_-1px_0_rgba(255,255,255,0.03),0_20px_48px_-28px_rgba(0,0,0,0.72)]"
|
||||
>
|
||||
<motion.nav
|
||||
initial="closed"
|
||||
animate="open"
|
||||
exit="closed"
|
||||
variants={{
|
||||
open: {
|
||||
transition: {
|
||||
staggerChildren: 0.04,
|
||||
delayChildren: 0.02,
|
||||
},
|
||||
},
|
||||
closed: {
|
||||
transition: {
|
||||
staggerChildren: 0.03,
|
||||
staggerDirection: -1,
|
||||
},
|
||||
},
|
||||
}}
|
||||
className="flex flex-col gap-1 p-2"
|
||||
>
|
||||
{navItems.map((item) => {
|
||||
const itemPath = item.path || "/";
|
||||
const isActive = isNavItemActive(stripLocalePrefix(pathname), itemPath);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={item.key}
|
||||
variants={{
|
||||
open: { opacity: 1, y: 0 },
|
||||
closed: { opacity: 0, y: -8 },
|
||||
}}
|
||||
transition={{ duration: 0.18, ease: "easeOut" }}
|
||||
>
|
||||
<Link
|
||||
href={getLocalizedPath(locale, itemPath)}
|
||||
className={cn(
|
||||
"flex items-center justify-between rounded-nested px-4 py-3 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
)}
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<span>{t(item.key)}</span>
|
||||
<span className="text-xs uppercase tracking-[0.18em] opacity-60">
|
||||
0{navItems.findIndex((navItem) => navItem.key === item.key) + 1}
|
||||
</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</motion.nav>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 8 }}
|
||||
transition={{ duration: 0.18, ease: "easeOut", delay: 0.04 }}
|
||||
className="border-t border-white/18 bg-[linear-gradient(180deg,rgba(255,255,255,0.14),rgba(255,255,255,0.06))] p-2 dark:border-white/8 dark:bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(255,255,255,0.02))]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<LocaleToggle
|
||||
locale={locale}
|
||||
showLabel
|
||||
className="h-10 rounded-nested px-3 text-foreground hover:bg-white/34 dark:hover:bg-white/10"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{isAdmin ? (
|
||||
<Button asChild variant="ghost" size="icon" className="h-10 w-10 rounded-nested border border-white/18 bg-white/18 text-foreground hover:bg-white/34 dark:border-white/10 dark:bg-white/6 dark:hover:bg-white/10">
|
||||
<Link href="/root" aria-label={t("root")} onClick={() => setIsOpen(false)}>
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<ThemeToggle
|
||||
ariaLabel={t("themeToggle")}
|
||||
variant="ghost"
|
||||
className="h-10 w-10 rounded-nested border border-white/18 bg-white/18 text-foreground hover:bg-white/34 dark:border-white/10 dark:bg-white/6 dark:hover:bg-white/10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</Container>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type SiteLogoProps = {
|
||||
lightLogoUrl?: string | null;
|
||||
darkLogoUrl?: string | null;
|
||||
alt: string;
|
||||
priority?: boolean;
|
||||
};
|
||||
|
||||
export function SiteLogo({
|
||||
lightLogoUrl,
|
||||
darkLogoUrl,
|
||||
alt,
|
||||
priority = false,
|
||||
}: SiteLogoProps) {
|
||||
const { theme, resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const activeTheme = theme === "system" ? resolvedTheme : theme;
|
||||
const isDark = mounted && activeTheme === "dark";
|
||||
const fallbackSrc = isDark ? "/logos/dark-primary.svg" : "/logos/light-primary.svg";
|
||||
const src = isDark ? darkLogoUrl || lightLogoUrl || fallbackSrc : lightLogoUrl || darkLogoUrl || fallbackSrc;
|
||||
|
||||
return (
|
||||
<span className="block">
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={360}
|
||||
height={92}
|
||||
sizes="(min-width: 1280px) 360px, (min-width: 1024px) 320px, (min-width: 640px) 280px, 240px"
|
||||
className="h-9 w-auto object-contain object-left sm:h-10 lg:h-11 xl:h-12"
|
||||
priority={priority}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -110,7 +110,7 @@ export function MediaFieldPicker({
|
||||
}, [serializedValue]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border bg-card p-4 shadow-sm">
|
||||
<div className="space-y-3 rounded-surface border border-border/80 bg-card p-4 shadow-card">
|
||||
<div className="space-y-2">
|
||||
<Label className="sr-only">{title}</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -128,7 +128,7 @@ export function MediaFieldPicker({
|
||||
})
|
||||
}
|
||||
className={cn(
|
||||
"rounded-md border px-4 py-2 text-sm transition-colors",
|
||||
"rounded-nested border px-4 py-2 text-sm transition-colors",
|
||||
value.mode === mode
|
||||
? "border-input bg-primary text-primary-foreground"
|
||||
: "border-input bg-background text-foreground/75 hover:bg-accent hover:text-accent-foreground",
|
||||
@@ -150,7 +150,7 @@ export function MediaFieldPicker({
|
||||
isCleared: true,
|
||||
})
|
||||
}
|
||||
className="rounded-md border border-destructive/25 px-4 py-2 text-sm text-destructive transition-colors hover:bg-destructive/5"
|
||||
className="rounded-nested border border-destructive/25 px-4 py-2 text-sm text-destructive transition-colors hover:bg-destructive/5"
|
||||
>
|
||||
{clearLabel}
|
||||
</button>
|
||||
@@ -208,7 +208,7 @@ export function MediaFieldPicker({
|
||||
{value.mode === "library" ? (
|
||||
<div className="space-y-3">
|
||||
<Label className="sr-only">{libraryLabel ?? "Media Library"}</Label>
|
||||
<div className="space-y-2 rounded-md border bg-card p-3">
|
||||
<div className="space-y-2 rounded-nested border border-border/80 bg-card p-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { FileText, FolderPlus, Hash, Layers3, Pencil, Text, Trash2 } from "lucide-react";
|
||||
|
||||
import type { deleteCategoryAction, upsertCategoryAction } from "@/app/root/portfolio/actions";
|
||||
import { AppCard } from "@/components/ui/app-card";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { PortfolioCategoryView } from "@/lib/portfolio";
|
||||
|
||||
const locales = [
|
||||
{ key: "Ar", lowerKey: "ar" as const, label: "Arabic" },
|
||||
{ key: "En", lowerKey: "en" as const, label: "English" },
|
||||
{ key: "De", lowerKey: "de" as const, label: "German" },
|
||||
] as const;
|
||||
|
||||
const copy = {
|
||||
addCategory: "Kategorie hinzufuegen",
|
||||
saveCategory: "Kategorie speichern",
|
||||
save: "Speichern",
|
||||
delete: "Loeschen",
|
||||
active: "Aktiv",
|
||||
sortOrder: "Sortierung",
|
||||
projects: "Projekte",
|
||||
description: "Beschreibung",
|
||||
currentCategories: "Aktuelle Kategorien",
|
||||
modalDescription: "Neue Kategorie direkt im Popup anlegen.",
|
||||
editDescription: "Kategorie im Popup aendern oder loeschen.",
|
||||
empty: "Noch keine Kategorien vorhanden.",
|
||||
deleteBlocked: "Loeschen erst moeglich, wenn keine Projekte mehr zugeordnet sind.",
|
||||
editCategory: "Kategorie bearbeiten",
|
||||
};
|
||||
|
||||
type CategoryAction = typeof upsertCategoryAction;
|
||||
type CategoryDeleteAction = typeof deleteCategoryAction;
|
||||
|
||||
type PortfolioCategoriesManagerProps = {
|
||||
categories: Array<PortfolioCategoryView & { projectCount: number }>;
|
||||
activeCount: number;
|
||||
assignedProjects: number;
|
||||
saveCategoryAction: CategoryAction;
|
||||
removeCategoryAction: CategoryDeleteAction;
|
||||
};
|
||||
|
||||
function CategoryLocaleFields({
|
||||
idPrefix,
|
||||
values,
|
||||
}: {
|
||||
idPrefix: string;
|
||||
values?: {
|
||||
nameAr?: string;
|
||||
nameEn?: string;
|
||||
nameDe?: string;
|
||||
descriptionAr?: string;
|
||||
descriptionEn?: string;
|
||||
descriptionDe?: string;
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{locales.map((locale) => {
|
||||
const nameKey = `name${locale.key}` as const;
|
||||
const descriptionKey = `description${locale.key}` as const;
|
||||
|
||||
return (
|
||||
<div key={`${idPrefix}-${locale.key}`} className="space-y-4 rounded-lg border border-input bg-background p-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">{locale.label}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${idPrefix}-${nameKey}`} className="sr-only">{`Name ${locale.label}`}</Label>
|
||||
<div className="relative">
|
||||
<Text className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id={`${idPrefix}-${nameKey}`}
|
||||
name={nameKey}
|
||||
defaultValue={values?.[nameKey] ?? ""}
|
||||
required
|
||||
placeholder={`Name ${locale.label}`}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${idPrefix}-${descriptionKey}`} className="sr-only">{`${copy.description} ${locale.label}`}</Label>
|
||||
<div className="relative">
|
||||
<FileText className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Textarea
|
||||
id={`${idPrefix}-${descriptionKey}`}
|
||||
name={descriptionKey}
|
||||
rows={5}
|
||||
defaultValue={values?.[descriptionKey] ?? ""}
|
||||
required
|
||||
placeholder={`${copy.description} ${locale.label}`}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditCategoryDialog({
|
||||
category,
|
||||
open,
|
||||
onOpenChange,
|
||||
saveCategoryAction,
|
||||
removeCategoryAction,
|
||||
}: {
|
||||
category: PortfolioCategoriesManagerProps["categories"][number];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
saveCategoryAction: CategoryAction;
|
||||
removeCategoryAction: CategoryDeleteAction;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copy.editCategory}</DialogTitle>
|
||||
<DialogDescription>{copy.editDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form id={`portfolio-category-form-${category.id}`} action={saveCategoryAction} className="space-y-6">
|
||||
<input type="hidden" name="id" value={category.id} />
|
||||
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`slug-${category.id}`}>Slug</Label>
|
||||
<div className="relative">
|
||||
<Text className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input id={`slug-${category.id}`} name="slug" defaultValue={category.slug} required className="pl-9" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`sortOrder-${category.id}`}>{copy.sortOrder}</Label>
|
||||
<div className="relative">
|
||||
<Hash className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id={`sortOrder-${category.id}`}
|
||||
name="sortOrder"
|
||||
type="number"
|
||||
min="0"
|
||||
defaultValue={category.sortOrder}
|
||||
required
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 rounded-md border border-input bg-card px-4 py-3 text-sm">
|
||||
<Checkbox name="isActive" defaultChecked={category.isActive} />
|
||||
{copy.active}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<CategoryLocaleFields
|
||||
idPrefix={`category-${category.id}`}
|
||||
values={{
|
||||
nameAr: category.name.ar,
|
||||
nameEn: category.name.en,
|
||||
nameDe: category.name.de,
|
||||
descriptionAr: category.description.ar,
|
||||
descriptionEn: category.description.en,
|
||||
descriptionDe: category.description.de,
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<DialogFooter className="items-center justify-between sm:flex-row">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{category.projectCount > 0 ? copy.deleteBlocked : "Kategorie kann geloescht werden."}
|
||||
</p>
|
||||
<div className="flex w-full flex-col-reverse gap-2 sm:w-auto sm:flex-row">
|
||||
<form action={removeCategoryAction}>
|
||||
<input type="hidden" name="id" value={category.id} />
|
||||
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
|
||||
<Button type="submit" variant="destructive" disabled={category.projectCount > 0}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{copy.delete}
|
||||
</Button>
|
||||
</form>
|
||||
<Button type="submit" form={`portfolio-category-form-${category.id}`}>
|
||||
{copy.save}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function PortfolioCategoriesManager({
|
||||
categories,
|
||||
activeCount,
|
||||
assignedProjects,
|
||||
saveCategoryAction,
|
||||
removeCategoryAction,
|
||||
}: PortfolioCategoriesManagerProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editingCategoryId, setEditingCategoryId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.has("success")) {
|
||||
setCreateOpen(false);
|
||||
setEditingCategoryId(null);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<AppCard level={3} className="bg-secondary">
|
||||
<CardContent className="flex flex-col gap-6 p-6 lg:flex-row lg:items-end lg:justify-between lg:p-8">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-lg border border-input bg-background px-4 py-4">
|
||||
<p className="text-xs uppercase tracking-[0.12em] text-muted-foreground">Total</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-foreground">{categories.length}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-input bg-background px-4 py-4">
|
||||
<p className="text-xs uppercase tracking-[0.12em] text-muted-foreground">Active</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-foreground">{activeCount}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-input bg-background px-4 py-4">
|
||||
<p className="text-xs uppercase tracking-[0.12em] text-muted-foreground">Assigned</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-foreground">{assignedProjects}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<FolderPlus className="h-4 w-4" />
|
||||
{copy.addCategory}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copy.addCategory}</DialogTitle>
|
||||
<DialogDescription>{copy.modalDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form id="portfolio-category-create-form" action={saveCategoryAction} className="space-y-6">
|
||||
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-slug">Slug</Label>
|
||||
<div className="relative">
|
||||
<Text className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input id="create-slug" name="slug" required placeholder="Slug" className="pl-9" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-sortOrder">{copy.sortOrder}</Label>
|
||||
<div className="relative">
|
||||
<Hash className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input id="create-sortOrder" name="sortOrder" type="number" min="0" defaultValue="0" required className="pl-9" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 rounded-md border border-input bg-card px-4 py-3 text-sm">
|
||||
<Checkbox name="isActive" defaultChecked />
|
||||
{copy.active}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<CategoryLocaleFields idPrefix="create" />
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit">{copy.saveCategory}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
|
||||
<AppCard className="bg-secondary">
|
||||
<CardContent className="space-y-4 p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Layers3 className="h-5 w-5 text-brand-primary" />
|
||||
<h2 className="text-lg font-semibold text-foreground">{copy.currentCategories}</h2>
|
||||
</div>
|
||||
|
||||
{categories.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{copy.empty}</p>
|
||||
) : (
|
||||
<Accordion type="single" collapsible className="space-y-3">
|
||||
{categories.map((category) => (
|
||||
<AccordionItem key={category.id} value={category.id}>
|
||||
<AccordionTrigger className="bg-secondary hover:no-underline">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2 text-left lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-base font-semibold text-foreground">
|
||||
{category.name.de || category.name.en || category.name.ar}
|
||||
</span>
|
||||
<Badge variant={category.isActive ? "success" : "warning"}>
|
||||
{category.isActive ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<span>{category.slug}</span>
|
||||
<span>•</span>
|
||||
<span>{copy.sortOrder} {category.sortOrder}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{category.projectCount} {copy.projects}</Badge>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setEditingCategoryId(category.id);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
{copy.editCategory}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="bg-background">
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{locales.map((locale) => (
|
||||
<div key={`${category.id}-${locale.key}`} className="rounded-lg border border-input bg-card p-4">
|
||||
<p className="text-sm font-medium text-foreground">{locale.label}</p>
|
||||
<p className="mt-3 text-sm text-foreground">{category.name[locale.lowerKey]}</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{category.description[locale.lowerKey]}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
|
||||
<EditCategoryDialog
|
||||
category={category}
|
||||
open={editingCategoryId === category.id}
|
||||
onOpenChange={(open) => setEditingCategoryId(open ? category.id : null)}
|
||||
saveCategoryAction={saveCategoryAction}
|
||||
removeCategoryAction={removeCategoryAction}
|
||||
/>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
)}
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,7 @@
|
||||
import { Boxes, ExternalLink, Filter, FolderKanban, Layers3, Plus, Tags } from "lucide-react";
|
||||
import { ExternalLink, Filter, FolderKanban, Plus, Tags } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { MotionFade } from "@/components/motion-fade";
|
||||
import { PortfolioSubnav } from "@/components/root/portfolio-subnav";
|
||||
import { AppCard } from "@/components/ui/app-card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -26,76 +25,45 @@ type PortfolioProjectsOverviewProps = {
|
||||
};
|
||||
|
||||
const copy = {
|
||||
summary: "Portfolio cockpit",
|
||||
description: "Alle Projekte, Filter und Einstiegspunkte fuer die Bearbeitung an einem Ort.",
|
||||
totalCategories: "Kategorien",
|
||||
totalProjects: "Projekte",
|
||||
publishedProjects: "Veroeffentlicht",
|
||||
newProject: "Neues Projekt",
|
||||
newCategory: "Neue Kategorie",
|
||||
category: "Kategorie",
|
||||
all: "Alle",
|
||||
status: "Status",
|
||||
draft: "Entwurf",
|
||||
published: "Veroeffentlicht",
|
||||
filter: "Filter anwenden",
|
||||
empty: "Noch keine Projekte vorhanden. Lege das erste Projekt an und beginne direkt mit Inhalt, Abschnitten und Dateien.",
|
||||
openProject: "Projekt ansehen",
|
||||
editProject: "Projekt bearbeiten",
|
||||
review: "Bearbeitungsstand",
|
||||
reviewDone: "Bereit",
|
||||
reviewMissing: "Fehlt etwas",
|
||||
filter: "Filtern",
|
||||
newProject: "Neues Projekt",
|
||||
newCategory: "Neues Kategorie",
|
||||
openProject: "Ansehen",
|
||||
editProject: "Bearbeiten",
|
||||
untitled: "Unbenanntes Projekt",
|
||||
noCategory: "Ohne Kategorie",
|
||||
noPreview: "Kein Preview Link",
|
||||
hasPreview: "Preview Link vorhanden",
|
||||
hasCover: "Cover gesetzt",
|
||||
missingCover: "Cover fehlt",
|
||||
hasSections: "Abschnitte vorhanden",
|
||||
noSections: "Keine Abschnitte",
|
||||
hasAssets: "Dateien vorhanden",
|
||||
noAssets: "Keine Dateien",
|
||||
empty: "Noch keine Projekte vorhanden.",
|
||||
};
|
||||
|
||||
function getProjectCompletion(project: PortfolioProjectView) {
|
||||
const completedChecks = [
|
||||
Boolean(project.slug.trim()),
|
||||
Boolean(project.coverImagePath),
|
||||
project.sections.length > 0,
|
||||
project.assets.length > 0,
|
||||
Boolean(project.title.ar.trim() && project.title.en.trim() && project.title.de.trim()),
|
||||
Boolean(project.summary.ar.trim() && project.summary.en.trim() && project.summary.de.trim()),
|
||||
].filter(Boolean).length;
|
||||
|
||||
return {
|
||||
completedChecks,
|
||||
totalChecks: 6,
|
||||
ready: completedChecks === 6,
|
||||
};
|
||||
}
|
||||
|
||||
export function PortfolioProjectsOverview({
|
||||
categories,
|
||||
projects,
|
||||
selectedCategory,
|
||||
selectedStatus,
|
||||
}: PortfolioProjectsOverviewProps) {
|
||||
const publishedProjects = projects.filter((project) => project.isPublished).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PortfolioSubnav active="projects" />
|
||||
|
||||
<MotionFade delay={0.05}>
|
||||
<AppCard level={3}>
|
||||
<CardContent className="flex flex-col gap-6 p-6 lg:flex-row lg:items-end lg:justify-between lg:p-8">
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{copy.summary}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-lg border border-input bg-background px-4 py-4">
|
||||
<p className="text-xs uppercase tracking-[0.12em] text-muted-foreground">Projects</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-foreground">{projects.length}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-input bg-background px-4 py-4">
|
||||
<p className="text-xs uppercase tracking-[0.12em] text-muted-foreground">Categories</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-foreground">{categories.length}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-input bg-background px-4 py-4">
|
||||
<p className="text-xs uppercase tracking-[0.12em] text-muted-foreground">Published</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-foreground">
|
||||
{projects.filter((project) => project.isPublished).length}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-semibold text-foreground">Projektverwaltung</h2>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">{copy.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -117,45 +85,7 @@ export function PortfolioProjectsOverview({
|
||||
</AppCard>
|
||||
</MotionFade>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
{[
|
||||
{
|
||||
icon: Layers3,
|
||||
label: copy.totalCategories,
|
||||
value: categories.length,
|
||||
},
|
||||
{
|
||||
icon: FolderKanban,
|
||||
label: copy.totalProjects,
|
||||
value: projects.length,
|
||||
},
|
||||
{
|
||||
icon: Boxes,
|
||||
label: copy.publishedProjects,
|
||||
value: publishedProjects,
|
||||
},
|
||||
].map((item, index) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<MotionFade key={item.label} delay={0.08 + index * 0.04}>
|
||||
<AppCard level={2}>
|
||||
<CardContent className="flex items-center gap-4 p-6">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<Icon className="h-5 w-5 text-brand-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{item.label}</p>
|
||||
<p className="text-3xl font-semibold text-foreground">{item.value}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</MotionFade>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
<MotionFade delay={0.14}>
|
||||
<MotionFade delay={0.1}>
|
||||
<AppCard>
|
||||
<CardContent className="p-6">
|
||||
<form className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
|
||||
@@ -211,14 +141,11 @@ export function PortfolioProjectsOverview({
|
||||
</MotionFade>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{projects.map((project, index) => {
|
||||
const completion = getProjectCompletion(project);
|
||||
|
||||
return (
|
||||
<MotionFade key={project.id} delay={0.18 + index * 0.03}>
|
||||
{projects.map((project, index) => (
|
||||
<MotionFade key={project.id} delay={0.14 + index * 0.03}>
|
||||
<AppCard interactive>
|
||||
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-2">
|
||||
<CardHeader className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CardTitle className="text-xl">
|
||||
{getLocalizedValue(project.title, "de") || copy.untitled}
|
||||
@@ -227,43 +154,9 @@ export function PortfolioProjectsOverview({
|
||||
{project.isPublished ? copy.published : copy.draft}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{project.category.name.de || copy.noCategory}</span>
|
||||
<span>•</span>
|
||||
<span>{project.projectYear}</span>
|
||||
<span>•</span>
|
||||
<span>{project.slug}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant={completion.ready ? "success" : "warning"}>
|
||||
{copy.review}: {completion.completedChecks}/{completion.totalChecks}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{completion.ready ? copy.reviewDone : copy.reviewMissing}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-5">
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">
|
||||
{getLocalizedValue(project.summary, "de") || "Noch keine Kurzbeschreibung hinterlegt."}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.category.name.de}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant={project.previewUrl ? "success" : "outline"}>
|
||||
{project.previewUrl ? copy.hasPreview : copy.noPreview}
|
||||
</Badge>
|
||||
<Badge variant={project.coverImagePath ? "success" : "outline"}>
|
||||
{project.coverImagePath ? copy.hasCover : copy.missingCover}
|
||||
</Badge>
|
||||
<Badge variant={project.sections.length > 0 ? "success" : "outline"}>
|
||||
{project.sections.length > 0 ? copy.hasSections : copy.noSections}
|
||||
</Badge>
|
||||
<Badge variant={project.assets.length > 0 ? "success" : "outline"}>
|
||||
{project.assets.length > 0 ? copy.hasAssets : copy.noAssets}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
@@ -278,37 +171,23 @@ export function PortfolioProjectsOverview({
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href={`/root/portfolio/projects/${project.id}`}>{copy.editProject}</Link>
|
||||
<Link href={`/root/portfolio/projects/${project.id}`}>
|
||||
<FolderKanban className="h-4 w-4" />
|
||||
{copy.editProject}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</CardHeader>
|
||||
</AppCard>
|
||||
</MotionFade>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<MotionFade delay={0.18}>
|
||||
<AppCard>
|
||||
<CardContent className="space-y-4 p-6">
|
||||
<p className="text-sm text-muted-foreground">{copy.empty}</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button asChild>
|
||||
<Link href="/root/portfolio/projects/new">
|
||||
<Plus className="h-4 w-4" />
|
||||
{copy.newProject}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/root/portfolio/categories">
|
||||
<Tags className="h-4 w-4" />
|
||||
{copy.newCategory}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<CardContent className="p-6 text-sm text-muted-foreground">
|
||||
{copy.empty}
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</MotionFade>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@ export function SidebarMaintenanceControl({
|
||||
<label
|
||||
htmlFor="sidebar-maintenance-enabled"
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center justify-between gap-3 rounded-md border px-3 py-2 transition-colors",
|
||||
"flex cursor-pointer items-center justify-between gap-3 rounded-nested border px-3 py-2 transition-colors",
|
||||
enabled
|
||||
? "border-status-warning/40 bg-status-warning-soft/80"
|
||||
: "border-input bg-card hover:bg-accent/20",
|
||||
@@ -43,7 +43,7 @@ export function SidebarMaintenanceControl({
|
||||
<span className="flex min-w-0 items-center gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-9 w-9 items-center justify-center rounded-full border",
|
||||
"flex h-9 w-9 items-center justify-center rounded-pill border",
|
||||
enabled
|
||||
? "border-status-warning/40 bg-status-warning-soft text-status-warning"
|
||||
: "border-border bg-background text-muted-foreground",
|
||||
|
||||
@@ -117,6 +117,20 @@ export function SiteSettingsForm({
|
||||
mediaOptions,
|
||||
}: SiteSettingsFormProps) {
|
||||
const [settings, setSettings] = useState(initialSettings);
|
||||
const [siteLogoLight, setSiteLogoLight] = useState<MediaFieldState>(
|
||||
createImageFieldState(
|
||||
initialBindings.siteLogoLight?.assetId,
|
||||
initialBindings.siteLogoLight?.url,
|
||||
"Site Logo Light",
|
||||
),
|
||||
);
|
||||
const [siteLogoDark, setSiteLogoDark] = useState<MediaFieldState>(
|
||||
createImageFieldState(
|
||||
initialBindings.siteLogoDark?.assetId,
|
||||
initialBindings.siteLogoDark?.url,
|
||||
"Site Logo Dark",
|
||||
),
|
||||
);
|
||||
const [favicon, setFavicon] = useState<MediaFieldState>(
|
||||
createImageFieldState(
|
||||
initialBindings.favicon?.assetId,
|
||||
@@ -131,6 +145,16 @@ export function SiteSettingsForm({
|
||||
"Default OG Image",
|
||||
),
|
||||
);
|
||||
const siteLogoLightPreviewUrl = getMediaPreviewUrl(
|
||||
siteLogoLight,
|
||||
mediaOptions,
|
||||
initialBindings.siteLogoLight?.url,
|
||||
);
|
||||
const siteLogoDarkPreviewUrl = getMediaPreviewUrl(
|
||||
siteLogoDark,
|
||||
mediaOptions,
|
||||
initialBindings.siteLogoDark?.url,
|
||||
);
|
||||
const faviconPreviewUrl = getMediaPreviewUrl(favicon, mediaOptions, initialBindings.favicon?.url);
|
||||
const defaultOgImagePreviewUrl = getMediaPreviewUrl(
|
||||
defaultOgImage,
|
||||
@@ -295,12 +319,54 @@ export function SiteSettingsForm({
|
||||
|
||||
<AppCard>
|
||||
<CardHeader>
|
||||
<CardTitle>Preview Images</CardTitle>
|
||||
<CardTitle>Brand And Preview Images</CardTitle>
|
||||
<CardDescription>
|
||||
Favicon erscheint im Browser. Das Default OG Bild wird fuer Social Sharing genutzt, wenn eine Seite kein eigenes Bild liefert.
|
||||
Logo erscheint im Header. Favicon erscheint im Browser. Das Default OG Bild wird fuer Social Sharing genutzt, wenn eine Seite kein eigenes Bild liefert.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-6 xl:grid-cols-2">
|
||||
<MediaFieldPicker
|
||||
title="Site Logo Light"
|
||||
value={siteLogoLight}
|
||||
onChange={setSiteLogoLight}
|
||||
options={mediaOptions}
|
||||
hasInitialValue={Boolean(initialBindings.siteLogoLight?.assetId || initialBindings.siteLogoLight?.url)}
|
||||
inputName="siteLogoLightMedia"
|
||||
fileFieldName="siteLogoLightFile"
|
||||
fileLabel="Upload Light Logo"
|
||||
libraryLabel="Light Logo Library"
|
||||
accept="image/*,.svg"
|
||||
allowExternal={false}
|
||||
allowClear
|
||||
clearLabel="Remove Light Logo"
|
||||
emptyValue={{
|
||||
mode: "upload",
|
||||
assetId: "",
|
||||
url: "",
|
||||
}}
|
||||
/>
|
||||
|
||||
<MediaFieldPicker
|
||||
title="Site Logo Dark"
|
||||
value={siteLogoDark}
|
||||
onChange={setSiteLogoDark}
|
||||
options={mediaOptions}
|
||||
hasInitialValue={Boolean(initialBindings.siteLogoDark?.assetId || initialBindings.siteLogoDark?.url)}
|
||||
inputName="siteLogoDarkMedia"
|
||||
fileFieldName="siteLogoDarkFile"
|
||||
fileLabel="Upload Dark Logo"
|
||||
libraryLabel="Dark Logo Library"
|
||||
accept="image/*,.svg"
|
||||
allowExternal={false}
|
||||
allowClear
|
||||
clearLabel="Remove Dark Logo"
|
||||
emptyValue={{
|
||||
mode: "upload",
|
||||
assetId: "",
|
||||
url: "",
|
||||
}}
|
||||
/>
|
||||
|
||||
<MediaFieldPicker
|
||||
title="Favicon"
|
||||
value={favicon}
|
||||
@@ -380,6 +446,45 @@ export function SiteSettingsForm({
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<ImageIcon className="h-4 w-4 text-brand-primary" />
|
||||
Header Logo Preview
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<div className="rounded-md border bg-card p-4">
|
||||
<p className="mb-3 text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Light
|
||||
</p>
|
||||
{siteLogoLightPreviewUrl ? (
|
||||
<img
|
||||
src={siteLogoLightPreviewUrl}
|
||||
alt="Site Logo Light"
|
||||
className="h-10 w-auto max-w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-10 items-center text-sm text-muted-foreground">
|
||||
Default light logo will be used
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-md border border-border/70 bg-slate-950 p-4">
|
||||
<p className="mb-3 text-xs font-medium uppercase tracking-[0.2em] text-white/55">
|
||||
Dark
|
||||
</p>
|
||||
{siteLogoDarkPreviewUrl ? (
|
||||
<img
|
||||
src={siteLogoDarkPreviewUrl}
|
||||
alt="Site Logo Dark"
|
||||
className="h-10 w-auto max-w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-10 items-center text-sm text-white/60">
|
||||
Default dark logo will be used
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<Search className="h-4 w-4 text-brand-primary" />
|
||||
Search Preview
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { AppCard } from "@/components/ui/app-card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function WorkspaceHero({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
aside,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
aside?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AppCard level={3}>
|
||||
<CardContent className="flex flex-col gap-4 p-6 lg:flex-row lg:items-end lg:justify-between lg:p-8">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{eyebrow}
|
||||
</p>
|
||||
<h2 className="text-2xl font-semibold text-foreground">{title}</h2>
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
{aside ? <div className="flex flex-wrap gap-2">{aside}</div> : null}
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceSidebarPanel({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AppCard level={2}>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">{children}</CardContent>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceStepButton({
|
||||
active,
|
||||
completed,
|
||||
index,
|
||||
label,
|
||||
eyebrow,
|
||||
onClick,
|
||||
completeIcon,
|
||||
}: {
|
||||
active: boolean;
|
||||
completed: boolean;
|
||||
index: number;
|
||||
label: string;
|
||||
eyebrow: string;
|
||||
onClick: () => void;
|
||||
completeIcon?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-3 rounded-surface border px-4 py-3 text-left transition-colors",
|
||||
active
|
||||
? "border-input bg-accent/40"
|
||||
: "border-input bg-background hover:bg-accent/20",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-pill border border-input bg-background text-sm font-semibold text-foreground">
|
||||
{completed ? completeIcon ?? "OK" : index + 1}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{eyebrow}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-foreground">{label}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceLocaleCard({
|
||||
title,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
hint: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AppCard level={2}>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-lg">{title}</CardTitle>
|
||||
<CardDescription>{hint}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">{children}</CardContent>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceStatusBadge({
|
||||
done,
|
||||
doneLabel = "Ready",
|
||||
openLabel = "Open",
|
||||
}: {
|
||||
done: boolean;
|
||||
doneLabel?: string;
|
||||
openLabel?: string;
|
||||
}) {
|
||||
return <Badge variant={done ? "success" : "warning"}>{done ? doneLabel : openLabel}</Badge>;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ type ThemeToggleProps = {
|
||||
label?: string;
|
||||
variant?: ButtonProps["variant"];
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
};
|
||||
|
||||
export function ThemeToggle({
|
||||
@@ -19,6 +20,7 @@ export function ThemeToggle({
|
||||
label,
|
||||
variant = "outline",
|
||||
className,
|
||||
iconClassName,
|
||||
}: ThemeToggleProps) {
|
||||
const { setTheme, theme, resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
@@ -36,7 +38,7 @@ export function ThemeToggle({
|
||||
aria-label={ariaLabel}
|
||||
className={cn(label ? "justify-start" : undefined, className)}
|
||||
>
|
||||
<Moon className="h-4 w-4" />
|
||||
<Moon className={cn("h-4 w-4", iconClassName)} />
|
||||
{label ? <span>{label}</span> : null}
|
||||
</Button>
|
||||
);
|
||||
@@ -54,7 +56,7 @@ export function ThemeToggle({
|
||||
aria-label={ariaLabel}
|
||||
className={cn(label ? "justify-start" : undefined, className)}
|
||||
>
|
||||
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
{isDark ? <Sun className={cn("h-4 w-4", iconClassName)} /> : <Moon className={cn("h-4 w-4", iconClassName)} />}
|
||||
{label ? <span>{label}</span> : null}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Accordion = AccordionPrimitive.Root;
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn("rounded-surface border border-input bg-card", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
AccordionItem.displayName = "AccordionItem";
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between gap-4 px-5 py-4 text-left text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
));
|
||||
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="border-t border-border px-5 py-5">{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
));
|
||||
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||
|
||||
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
|
||||
@@ -5,14 +5,14 @@ import { Card } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const appCardVariants = cva(
|
||||
"rounded-lg transition-colors",
|
||||
"rounded-surface transition-colors",
|
||||
{
|
||||
variants: {
|
||||
level: {
|
||||
1: "border bg-card text-card-foreground shadow-sm",
|
||||
2: "border bg-card text-card-foreground shadow-sm",
|
||||
3: "border bg-card text-card-foreground shadow-md",
|
||||
inverse: "border-transparent bg-foreground text-background shadow-sm",
|
||||
1: "border border-border/80 bg-card text-card-foreground shadow-card",
|
||||
2: "border border-border/80 bg-card text-card-foreground shadow-card",
|
||||
3: "border border-border/80 bg-card text-card-foreground shadow-panel",
|
||||
inverse: "border-transparent bg-foreground text-background shadow-card",
|
||||
},
|
||||
padding: {
|
||||
none: "",
|
||||
|
||||
@@ -5,13 +5,13 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-nested text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/92",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/90",
|
||||
outline: "border border-input bg-background hover:border-border-strong hover:bg-accent hover:text-accent-foreground",
|
||||
ghost: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
link: "rounded-none text-primary underline-offset-4 hover:underline",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
|
||||
@@ -7,7 +7,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
"rounded-surface border border-border/80 bg-card text-card-foreground shadow-card",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-foreground/30 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:w-full sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||
);
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props} />
|
||||
);
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
@@ -22,7 +22,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
"flex cursor-default select-none items-center rounded-nested px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
@@ -42,7 +42,7 @@ const DropdownMenuSubContent = React.forwardRef<
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-surface border border-border/80 bg-popover p-1 text-popover-foreground shadow-panel",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -60,7 +60,7 @@ const DropdownMenuContent = React.forwardRef<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"z-50 min-w-[12rem] overflow-hidden rounded-surface border border-border/80 bg-popover p-1 text-popover-foreground shadow-panel",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -79,7 +79,7 @@ const DropdownMenuItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"relative flex cursor-default select-none items-center rounded-nested px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
@@ -96,7 +96,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"relative flex cursor-default select-none items-center rounded-nested py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
@@ -121,7 +121,7 @@ const DropdownMenuRadioItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"relative flex cursor-default select-none items-center rounded-nested py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -7,7 +7,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base text-foreground ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"flex h-10 w-full rounded-nested border border-input bg-background px-3 py-2 text-base text-foreground shadow-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -55,7 +55,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
"flex h-10 w-full items-center justify-between rounded-nested border border-input bg-background px-3 py-2 text-sm shadow-xs ring-offset-background placeholder:text-muted-foreground focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/30 focus:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -107,7 +107,7 @@ const SelectContent = React.forwardRef<
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[10rem] overflow-y-auto overflow-x-hidden rounded-surface border border-input bg-background text-foreground shadow-panel data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
@@ -152,7 +152,7 @@ const SelectItem = React.forwardRef<
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"relative flex w-full cursor-default select-none items-center rounded-nested py-2 pl-8 pr-3 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -44,7 +44,7 @@ function TabsList({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex h-auto flex-wrap items-center gap-2 rounded-lg border bg-muted/40 p-1.5",
|
||||
"inline-flex h-auto flex-wrap items-center gap-2 rounded-surface border border-border/80 bg-muted/40 p-1.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -69,7 +69,7 @@ function TabsTrigger({
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
"inline-flex items-center justify-center rounded-nested px-3 py-2 text-sm font-medium text-muted-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50",
|
||||
isActive && "bg-background text-foreground shadow-sm",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"tex
|
||||
({ className, ...props }, ref) => (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"flex min-h-[80px] w-full rounded-nested border border-input bg-background px-3 py-2 text-base text-foreground shadow-xs ring-offset-background placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -53,6 +53,7 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
|
||||
surfaces: "Surfaces",
|
||||
typography: "Typography",
|
||||
headers: "Headers",
|
||||
workspace: "Workspace",
|
||||
cardsDesc:
|
||||
"Aussenflaechen, innere Karten und verschachtelte Ebenen mit einheitlicher Hierarchie.",
|
||||
buttonsDesc:
|
||||
@@ -67,6 +68,8 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
|
||||
"Typografische Grundstufen fuer Seiten, Sektionen und Metainformationen.",
|
||||
headersDesc:
|
||||
"Header Muster fuer Seiten und Sektionen innerhalb des Systems.",
|
||||
workspaceDesc:
|
||||
"Editor Muster mit breitem Inhaltsbereich und schmaler Kontextspalte fuer Admin Oberflaechen.",
|
||||
tabsDesc:
|
||||
"Tabs bleiben in einer Route und verwenden dieselbe verschachtelte Steuerlogik.",
|
||||
outerCard: "Aeussere Card",
|
||||
@@ -128,7 +131,12 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
|
||||
outerSurfaceHeaderText: "Dieses Muster wird fuer Seitensektionen mit staerkerer Hierarchie genutzt.",
|
||||
sectionHeader: "Abschnittsheader",
|
||||
nestedBlockHeading: "Ueberschrift des verschachtelten Blocks",
|
||||
nestedBlockHeadingText: "Innerhalb einer aeusseren Card verwenden, wenn Inhalte bereits gruppiert sind."
|
||||
nestedBlockHeadingText: "Innerhalb einer aeusseren Card verwenden, wenn Inhalte bereits gruppiert sind.",
|
||||
workspaceHeader: "Editor Workspace",
|
||||
workspaceMain: "Breiter Inhaltsbereich",
|
||||
workspaceMainText: "Hier liegen Formschritte, Listen und eigentliche Bearbeitung.",
|
||||
workspaceSide: "Kontextspalte",
|
||||
workspaceSideText: "Status, Checklisten und Fokusdetails bleiben immer sichtbar."
|
||||
}
|
||||
: {
|
||||
title: "UI Kit",
|
||||
@@ -142,6 +150,7 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
|
||||
surfaces: "Surfaces",
|
||||
typography: "Typography",
|
||||
headers: "Headers",
|
||||
workspace: "Workspace",
|
||||
cardsDesc:
|
||||
"Outer surfaces, inner cards, and nested levels with one consistent hierarchy.",
|
||||
buttonsDesc:
|
||||
@@ -156,6 +165,8 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
|
||||
"Typography foundations for pages, sections, and supporting copy.",
|
||||
headersDesc:
|
||||
"Header patterns for pages and sections inside the system.",
|
||||
workspaceDesc:
|
||||
"Editor pattern with a wide content area and a narrow context rail for admin workflows.",
|
||||
tabsDesc:
|
||||
"Tabs stay within one route and use the same nested control logic.",
|
||||
outerCard: "Outer card",
|
||||
@@ -217,7 +228,12 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
|
||||
outerSurfaceHeaderText: "This pattern is used for page-level sections that need stronger hierarchy.",
|
||||
sectionHeader: "Section header",
|
||||
nestedBlockHeading: "Nested block heading",
|
||||
nestedBlockHeadingText: "Use inside an outer card when content is already grouped."
|
||||
nestedBlockHeadingText: "Use inside an outer card when content is already grouped.",
|
||||
workspaceHeader: "Editor workspace",
|
||||
workspaceMain: "Wide content area",
|
||||
workspaceMainText: "This area carries form steps, lists, and direct editing work.",
|
||||
workspaceSide: "Context rail",
|
||||
workspaceSideText: "Status, checklists, and focus details stay visible at all times."
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -249,6 +265,7 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
|
||||
<TabsTrigger value="surfaces">{copy.surfaces}</TabsTrigger>
|
||||
<TabsTrigger value="typography">{copy.typography}</TabsTrigger>
|
||||
<TabsTrigger value="headers">{copy.headers}</TabsTrigger>
|
||||
<TabsTrigger value="workspace">{copy.workspace}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="cards">
|
||||
@@ -516,6 +533,44 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
|
||||
</div>
|
||||
</UiKitSection>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="workspace">
|
||||
<UiKitSection title={copy.workspace} description={copy.workspaceDesc}>
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<AppCard level={2} className="xl:col-span-2">
|
||||
<CardContent className="space-y-4 p-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">{copy.workspaceMain}</p>
|
||||
<h3 className="mt-2 text-xl font-semibold text-foreground">{copy.workspaceHeader}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{copy.workspaceMainText}</p>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded-lg border border-input bg-background p-4 text-sm text-muted-foreground">
|
||||
Step 1
|
||||
</div>
|
||||
<div className="rounded-lg border border-input bg-background p-4 text-sm text-muted-foreground">
|
||||
Step 2
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
|
||||
<AppCard level={2}>
|
||||
<CardContent className="space-y-4 p-6">
|
||||
<p className="text-sm font-medium text-muted-foreground">{copy.workspaceSide}</p>
|
||||
<p className="text-sm text-muted-foreground">{copy.workspaceSideText}</p>
|
||||
<div className="space-y-3">
|
||||
<Badge variant="success">Ready</Badge>
|
||||
<Badge variant="warning">Open</Badge>
|
||||
<div className="rounded-lg border border-input bg-background p-4 text-sm text-muted-foreground">
|
||||
Checklist / status / focus details
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</div>
|
||||
</UiKitSection>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,8 @@ export {
|
||||
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,
|
||||
SITE_SETTINGS_KEY,
|
||||
DEFAULT_SITE_NAME,
|
||||
buildDefaultSiteSettings,
|
||||
@@ -21,6 +23,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,
|
||||
SITE_SETTINGS_KEY,
|
||||
buildDefaultSiteSettings,
|
||||
getDefaultSiteSettingsMediaBindings,
|
||||
@@ -112,6 +116,22 @@ export async function getSiteSettingsMediaBindings(): Promise<SiteSettingsMediaB
|
||||
|
||||
return usages.reduce<SiteSettingsMediaBindings>(
|
||||
(result, usage) => {
|
||||
if (usage.fieldKey === SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY) {
|
||||
result.siteLogoLight = {
|
||||
assetId: usage.asset.id,
|
||||
url: usage.asset.url,
|
||||
version: usage.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
if (usage.fieldKey === SITE_SETTINGS_LOGO_DARK_FIELD_KEY) {
|
||||
result.siteLogoDark = {
|
||||
assetId: usage.asset.id,
|
||||
url: usage.asset.url,
|
||||
version: usage.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
if (usage.fieldKey === SITE_SETTINGS_FAVICON_FIELD_KEY) {
|
||||
result.favicon = {
|
||||
assetId: usage.asset.id,
|
||||
|
||||
@@ -25,15 +25,16 @@ export const categoryInputSchema = z.object({
|
||||
isActive: z.boolean(),
|
||||
});
|
||||
|
||||
export const sectionInputSchema = z.object({
|
||||
export const sectionInputSchema = z
|
||||
.object({
|
||||
id: z.string().trim().optional(),
|
||||
type: z.nativeEnum(PortfolioSectionType),
|
||||
titleAr: requiredText("Section titleAr"),
|
||||
titleEn: requiredText("Section titleEn"),
|
||||
titleDe: requiredText("Section titleDe"),
|
||||
bodyAr: requiredText("Section bodyAr"),
|
||||
bodyEn: requiredText("Section bodyEn"),
|
||||
bodyDe: requiredText("Section bodyDe"),
|
||||
bodyAr: optionalTrimmedText,
|
||||
bodyEn: optionalTrimmedText,
|
||||
bodyDe: optionalTrimmedText,
|
||||
imagePath: optionalTrimmedText,
|
||||
media: mediaFieldInputSchema.optional(),
|
||||
linkUrl: optionalTrimmedText.refine(
|
||||
@@ -41,7 +42,44 @@ export const sectionInputSchema = z.object({
|
||||
"Section linkUrl must be an absolute URL or start with /.",
|
||||
),
|
||||
sortOrder: z.coerce.number().int().min(0).max(9999),
|
||||
});
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
const hasBody = Boolean(value.bodyAr && value.bodyEn && value.bodyDe);
|
||||
const hasImage = value.media?.mode === "library"
|
||||
? Boolean(value.media.assetId)
|
||||
: value.media?.mode === "upload"
|
||||
? true
|
||||
: Boolean(value.imagePath);
|
||||
|
||||
if (
|
||||
(value.type === PortfolioSectionType.RICH_TEXT ||
|
||||
value.type === PortfolioSectionType.STATS ||
|
||||
value.type === PortfolioSectionType.DELIVERABLES) &&
|
||||
!hasBody
|
||||
) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["bodyAr"],
|
||||
message: "This section type requires body content in all languages.",
|
||||
});
|
||||
}
|
||||
|
||||
if (value.type === PortfolioSectionType.GALLERY && !hasImage) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["media"],
|
||||
message: "Single image sections require an image.",
|
||||
});
|
||||
}
|
||||
|
||||
if (value.type === PortfolioSectionType.LINK && !value.linkUrl) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["linkUrl"],
|
||||
message: "Link sections require a link URL.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const assetInputSchema = z.object({
|
||||
id: z.string().trim().optional(),
|
||||
|
||||
@@ -6,6 +6,8 @@ export const SITE_SETTINGS_ENTITY_TYPE = "site-settings";
|
||||
export const SITE_SETTINGS_ENTITY_ID = "global";
|
||||
export const SITE_SETTINGS_FAVICON_FIELD_KEY = "favicon";
|
||||
export const SITE_SETTINGS_DEFAULT_OG_IMAGE_FIELD_KEY = "defaultOgImage";
|
||||
export const SITE_SETTINGS_LOGO_LIGHT_FIELD_KEY = "siteLogoLight";
|
||||
export const SITE_SETTINGS_LOGO_DARK_FIELD_KEY = "siteLogoDark";
|
||||
export const PAGE_TITLE_TOKEN = "{pageTitle}";
|
||||
export const SITE_NAME_TOKEN = "{siteName}";
|
||||
|
||||
@@ -30,6 +32,8 @@ export type SiteSettingsMediaBinding = {
|
||||
};
|
||||
|
||||
export type SiteSettingsMediaBindings = {
|
||||
siteLogoLight: SiteSettingsMediaBinding | null;
|
||||
siteLogoDark: SiteSettingsMediaBinding | null;
|
||||
favicon: SiteSettingsMediaBinding | null;
|
||||
defaultOgImage: SiteSettingsMediaBinding | null;
|
||||
};
|
||||
@@ -62,6 +66,8 @@ function normalizeSiteLocaleSettings(
|
||||
|
||||
export function getDefaultSiteSettingsMediaBindings(): SiteSettingsMediaBindings {
|
||||
return {
|
||||
siteLogoLight: null,
|
||||
siteLogoDark: null,
|
||||
favicon: null,
|
||||
defaultOgImage: null,
|
||||
};
|
||||
|
||||
+3
-3
@@ -22,9 +22,9 @@
|
||||
"note": "شكراً لصبرك. النسخة الجديدة ستصل قريباً بهوية أنضج وحضور أدق."
|
||||
},
|
||||
"homepage": {
|
||||
"heroKicker": "Digital Studio",
|
||||
"heroTitle": "مواقع ومنتجات تنطلق بسرعة.",
|
||||
"heroText": "هذه الصفحة الرئيسية تشكل قاعدة لانطلاقة تسويقية ومنتجية متعددة اللغات.",
|
||||
"heroKicker": "مرحباً، أنا مو",
|
||||
"heroTitle": "frontend\n developer\n& designer",
|
||||
"heroText": "أصمم وأبني تجارب رقمية نظيفة مع اهتمام دقيق بالتفاصيل والحركة وسهولة الاستخدام.",
|
||||
"portfolioTitle": "أعمال مميزة",
|
||||
"portfolioText": "منطقة تجريبية لعرض مشاريع مختارة.",
|
||||
"productsTitle": "منتجات مميزة",
|
||||
|
||||
+4
-4
@@ -3,7 +3,7 @@
|
||||
"home": "Start",
|
||||
"portfolio": "Portfolio",
|
||||
"products": "Produkte",
|
||||
"about": "Ueber mich",
|
||||
"about": "Über mich",
|
||||
"contact": "Kontakt",
|
||||
"root": "Root",
|
||||
"openMenu": "Menue oeffnen",
|
||||
@@ -22,9 +22,9 @@
|
||||
"note": "Danke fuer deine Geduld. Die naechste Version geht bald mit mehr Klarheit, Tempo und Charakter live."
|
||||
},
|
||||
"homepage": {
|
||||
"heroKicker": "Digital Studio",
|
||||
"heroTitle": "Webseiten und Produkte, die schnell liefern.",
|
||||
"heroText": "Diese Startseite ist die Basis fuer ein mehrsprachiges Marketing- und Produkt-Setup.",
|
||||
"heroKicker": "Hi, ich bin Moh",
|
||||
"heroTitle": "frontend\n developer\n& designer",
|
||||
"heroText": "Ich entwerfe und entwickle klare digitale Erlebnisse mit Fokus auf Details, Motion und starke Interfaces.",
|
||||
"portfolioTitle": "Featured Projects",
|
||||
"portfolioText": "Platzhalter fuer ausgewaehlte Kundenprojekte.",
|
||||
"productsTitle": "Featured Products",
|
||||
|
||||
+3
-3
@@ -22,9 +22,9 @@
|
||||
"note": "Thank you for your patience. The next version is arriving soon with more clarity, speed, and intent."
|
||||
},
|
||||
"homepage": {
|
||||
"heroKicker": "Digital Studio",
|
||||
"heroTitle": "Websites and products that ship fast.",
|
||||
"heroText": "This homepage is a starter for a multilingual marketing and product setup.",
|
||||
"heroKicker": "Hi, I am Moh",
|
||||
"heroTitle": "frontend\ndelevoper\n& designer",
|
||||
"heroText": "I design and build clean digital experiences with a sharp eye for detail, motion, and usable interfaces.",
|
||||
"portfolioTitle": "Featured Projects",
|
||||
"portfolioText": "Placeholder area for highlighted client projects.",
|
||||
"productsTitle": "Featured Products",
|
||||
|
||||
Generated
+62
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.4.2",
|
||||
"@prisma/client": "^7.4.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
@@ -1649,6 +1650,37 @@
|
||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-accordion": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz",
|
||||
"integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-collapsible": "1.1.12",
|
||||
"@radix-ui/react-collection": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-arrow": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
||||
@@ -1702,6 +1734,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collapsible": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
|
||||
"integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collection": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.4.2",
|
||||
"@prisma/client": "^7.4.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: mohs-frontend-system
|
||||
description: Use this skill for frontend work in Next.js, Tailwind CSS, and shadcn/ui projects, especially when editing pages, layouts, components, styling, or refactoring UI systems. Apply it when Codex needs to keep visual decisions consistent, reuse shared primitives, centralize tokens and layout patterns, and avoid mixed old and new UI approaches.
|
||||
---
|
||||
|
||||
# Mohs Frontend System
|
||||
|
||||
## Overview
|
||||
|
||||
Follow the existing project structure.
|
||||
Reuse shared components before creating new ones.
|
||||
Keep visual decisions global and system-driven instead of scattering them inside page files.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Inspect the current frontend structure before editing.
|
||||
2. Find existing shared primitives in `components/ui/*` and `components/layout/*`.
|
||||
3. Check global tokens and style rules in `app/globals.css`.
|
||||
4. Check Tailwind mapping in `tailwind.config.ts`.
|
||||
5. Decide whether the change belongs in global styles, shared primitives, or page-level composition.
|
||||
6. Reuse and extend shared patterns instead of introducing one-off classes or duplicated markup.
|
||||
7. Finish the refactor completely. Do not leave mixed patterns behind.
|
||||
|
||||
## System Rules
|
||||
|
||||
- Follow the existing project structure.
|
||||
- Reuse shared components before creating new ones.
|
||||
- Keep visual decisions global, not scattered in page files.
|
||||
- Prefer tokens and shared primitives over repeated raw classes.
|
||||
- Prefer consistency over creativity.
|
||||
- Do not invent random styles, spacing, radius, shadows, or layouts.
|
||||
- Use shadcn/ui as a base only, not as the final design language.
|
||||
- Keep layout primitives shared.
|
||||
- Keep UI primitives shared.
|
||||
- Do not leave old and new patterns mixed together.
|
||||
- Do not leave partial refactors.
|
||||
|
||||
## File Ownership
|
||||
|
||||
- Global styles and tokens: `app/globals.css`
|
||||
- Tailwind mapping: `tailwind.config.ts`
|
||||
- Shared UI: `components/ui/*`
|
||||
- Shared layout: `components/layout/*`
|
||||
|
||||
## Implementation Rules
|
||||
|
||||
- Put reusable visual decisions into shared layers first.
|
||||
- Keep page files focused on composition and data flow.
|
||||
- If a new pattern is needed in more than one place, extract it immediately.
|
||||
- If an old pattern conflicts with the new one, replace the old pattern in the touched area instead of leaving both.
|
||||
- Match the existing code style and naming conventions of the repo.
|
||||
|
||||
## Output Style
|
||||
|
||||
1. Briefly explain the change.
|
||||
2. List files to edit or create.
|
||||
3. Execute the full implementation.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Moh Frontend System"
|
||||
short_description: "Next.js frontend system rules"
|
||||
default_prompt: "Use $mohs-frontend-system to implement or refactor frontend UI consistently in this project."
|
||||
@@ -18,6 +18,8 @@ describe("metadata helpers", () => {
|
||||
it("builds root metadata with dynamic icons and social preview", () => {
|
||||
const settings = buildDefaultSiteSettings("Studio Moh");
|
||||
const metadata = buildAppMetadataFromConfig(settings, {
|
||||
siteLogoLight: null,
|
||||
siteLogoDark: null,
|
||||
favicon: {
|
||||
assetId: "fav",
|
||||
url: "/uploads/media/site-settings/favicon.svg",
|
||||
@@ -30,9 +32,9 @@ describe("metadata helpers", () => {
|
||||
|
||||
expect(metadata.title).toBe("Studio Moh");
|
||||
expect(metadata.icons).toEqual({
|
||||
icon: ["/uploads/media/site-settings/favicon.svg"],
|
||||
shortcut: ["/uploads/media/site-settings/favicon.svg"],
|
||||
apple: ["/uploads/media/site-settings/favicon.svg"],
|
||||
icon: [{ url: "/favicon.ico?v=default" }],
|
||||
shortcut: [{ url: "/favicon.ico?v=default" }],
|
||||
apple: [{ url: "/apple-icon.png?v=default" }],
|
||||
});
|
||||
expect(metadata.twitter).toMatchObject({
|
||||
card: "summary_large_image",
|
||||
@@ -46,6 +48,8 @@ describe("metadata helpers", () => {
|
||||
const metadata = buildLocalizedMetadataFromConfig({
|
||||
settings,
|
||||
bindings: {
|
||||
siteLogoLight: null,
|
||||
siteLogoDark: null,
|
||||
favicon: null,
|
||||
defaultOgImage: null,
|
||||
},
|
||||
@@ -70,6 +74,8 @@ describe("metadata helpers", () => {
|
||||
const metadata = buildLocalizedMetadataFromConfig({
|
||||
settings,
|
||||
bindings: {
|
||||
siteLogoLight: null,
|
||||
siteLogoDark: null,
|
||||
favicon: null,
|
||||
defaultOgImage: null,
|
||||
},
|
||||
|
||||
@@ -81,6 +81,28 @@ describe("portfolio validation", () => {
|
||||
}).type,
|
||||
).toBe("RICH_TEXT");
|
||||
|
||||
expect(
|
||||
sectionInputSchema.parse({
|
||||
type: "GALLERY",
|
||||
titleAr: "معرض",
|
||||
titleEn: "Single Image",
|
||||
titleDe: "Einzelbild",
|
||||
bodyAr: "",
|
||||
bodyEn: "",
|
||||
bodyDe: "",
|
||||
imagePath: "/uploads/media/sections/example.svg",
|
||||
media: {
|
||||
mode: "library",
|
||||
assetId: "asset_2",
|
||||
url: "",
|
||||
label: "Single Image",
|
||||
kind: "IMAGE",
|
||||
},
|
||||
linkUrl: "",
|
||||
sortOrder: 1,
|
||||
}).type,
|
||||
).toBe("GALLERY");
|
||||
|
||||
expect(
|
||||
assetInputSchema.parse({
|
||||
kind: "IMAGE",
|
||||
@@ -121,4 +143,28 @@ describe("portfolio validation", () => {
|
||||
}),
|
||||
).toThrow(/url/i);
|
||||
});
|
||||
|
||||
it("rejects link sections without a link", () => {
|
||||
expect(() =>
|
||||
sectionInputSchema.parse({
|
||||
type: "LINK",
|
||||
titleAr: "رابط",
|
||||
titleEn: "Link",
|
||||
titleDe: "Link",
|
||||
bodyAr: "",
|
||||
bodyEn: "",
|
||||
bodyDe: "",
|
||||
imagePath: "",
|
||||
media: {
|
||||
mode: "upload",
|
||||
assetId: "",
|
||||
url: "",
|
||||
label: "",
|
||||
kind: "IMAGE",
|
||||
},
|
||||
linkUrl: "",
|
||||
sortOrder: 0,
|
||||
}),
|
||||
).toThrow(/link/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
const rootDir = path.dirname(fileURLToPath(new URL(import.meta.url)));
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": rootDir,
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user