diff --git a/app/[locale]/(site)/about/page.tsx b/app/[locale]/(site)/about/page.tsx
index d666de8..f922d6c 100644
--- a/app/[locale]/(site)/about/page.tsx
+++ b/app/[locale]/(site)/about/page.tsx
@@ -3,6 +3,7 @@ import { Compass, Layers3, Users } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { Container } from "@/components/layout/container";
+import { PageHero } from "@/components/layout/page-hero";
import { MotionFade } from "@/components/motion-fade";
import { buildLocalizedMetadata } from "@/lib/metadata";
import { resolveLocale } from "@/lib/locale";
@@ -34,21 +35,11 @@ export default async function AboutPage({ params: { locale } }: AboutPageProps)
const t = await getTranslations({ locale: localeKey, namespace: "aboutPage" });
return (
-
-
-
-
-
- {t("title")}
-
-
- {t("intro")}
-
-
-
-
+ <>
+
-
+
+
@@ -89,9 +80,9 @@ export default async function AboutPage({ params: { locale } }: AboutPageProps)
-
+
-
+
@@ -111,7 +102,8 @@ export default async function AboutPage({ params: { locale } }: AboutPageProps)
-
-
+
+
+ >
);
}
diff --git a/app/[locale]/(site)/contact/page.tsx b/app/[locale]/(site)/contact/page.tsx
index af3a176..85c40e8 100644
--- a/app/[locale]/(site)/contact/page.tsx
+++ b/app/[locale]/(site)/contact/page.tsx
@@ -4,6 +4,7 @@ import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Container } from "@/components/layout/container";
+import { PageHero } from "@/components/layout/page-hero";
import { MotionFade } from "@/components/motion-fade";
import { buildLocalizedMetadata } from "@/lib/metadata";
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
@@ -39,8 +40,11 @@ export default async function ContactPage({ params: { locale } }: ContactPagePro
const t = await getTranslations({ locale: localeKey, namespace: "contactPage" });
return (
-
-
+ <>
+
+
+
+
{t("title")}
@@ -63,9 +67,9 @@ export default async function ContactPage({ params: { locale } }: ContactPagePro
-
+
-
+
-
-
+
+
+ >
);
}
diff --git a/app/[locale]/(site)/layout.tsx b/app/[locale]/(site)/layout.tsx
index ca8b216..59e7497 100644
--- a/app/[locale]/(site)/layout.tsx
+++ b/app/[locale]/(site)/layout.tsx
@@ -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 (
-
-
+
+
+
{children}
diff --git a/app/[locale]/(site)/page.tsx b/app/[locale]/(site)/page.tsx
index fea36d9..1d8c35a 100644
--- a/app/[locale]/(site)/page.tsx
+++ b/app/[locale]/(site)/page.tsx
@@ -4,12 +4,12 @@ import {
BriefcaseBusiness,
Boxes,
Mail,
- Sparkles,
} from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Container } from "@/components/layout/container";
+import { HomeHero } from "@/components/layout/home-hero";
import { MotionFade } from "@/components/motion-fade";
import { getSiteSettings } from "@/lib/app-config";
import { buildLocalizedMetadata } from "@/lib/metadata";
@@ -56,48 +56,18 @@ export default async function HomePage({ params: { locale } }: HomePageProps) {
const t = await getTranslations({ locale: localeKey, namespace: "homepage" });
return (
-
-
-
-
-
-
-
- {t("heroKicker")}
-
-
- {t("heroTitle")}
-
-
- {t("heroText")}
-
-
-
-
- {t("toPortfolio")}
-
-
-
-
- {t("toProducts")}
-
-
-
+ <>
+
-
-
-
- {t("heroCardTitle")}
-
-
- {t("heroCardText")}
-
-
-
-
-
-
-
+
+
@@ -139,9 +109,9 @@ export default async function HomePage({ params: { locale } }: HomePageProps) {
-
+
-
+
@@ -182,9 +152,9 @@ export default async function HomePage({ params: { locale } }: HomePageProps) {
-
+
-
+
@@ -206,7 +176,8 @@ export default async function HomePage({ params: { locale } }: HomePageProps) {
-
-
+
+
+ >
);
}
diff --git a/app/[locale]/(site)/portfolio/[slug]/page.tsx b/app/[locale]/(site)/portfolio/[slug]/page.tsx
index c5cd967..7ba1ef1 100644
--- a/app/[locale]/(site)/portfolio/[slug]/page.tsx
+++ b/app/[locale]/(site)/portfolio/[slug]/page.tsx
@@ -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
>>["sections"][number],
+ localeKey: ReturnType,
+ t: Awaited>,
+) {
+ const title = getLocalizedValue(section.title, localeKey);
+ const body = getLocalizedValue(section.body, localeKey);
+
+ if (section.type === "GALLERY") {
+ return (
+
+
{title}
+ {section.imagePath ? (
+
+ ) : (
+
+ No image configured.
+
+ )}
+
+ );
+ }
+
+ if (section.type === "LINK") {
+ return (
+
+
{title}
+ {body ? (
+
{body}
+ ) : null}
+ {section.linkUrl ? (
+
+
+ {t("openLink")}
+
+
+
+ ) : null}
+
+ );
+ }
+
+ if (section.type === "STATS" || section.type === "DELIVERABLES") {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
export async function generateMetadata({
params: { locale, slug },
}: PortfolioItemPageProps): Promise {
@@ -87,8 +155,14 @@ export default async function PortfolioItemPage({
const t = await getTranslations({ locale: localeKey, namespace: "portfolioDetail" });
return (
-
-
+ <>
+
+
+
+
@@ -161,44 +235,22 @@ export default async function PortfolioItemPage({
) : null}
-
+
-
+
{item.sections.map((section, index) => (
-
- {getLocalizedValue(section.title, localeKey)}
-
-
- {getLocalizedValue(section.body, localeKey)}
-
- {section.imagePath ? (
-
- ) : null}
- {section.linkUrl ? (
-
-
- {t("openLink")}
-
-
-
- ) : null}
+ {renderSectionContent(section, localeKey, t)}
))}
-
+
- {item.assets.length > 0 ? (
-
+ {item.assets.length > 0 ? (
+
{t("gallery")}
@@ -230,8 +282,9 @@ export default async function PortfolioItemPage({
-
- ) : null}
-
+
+ ) : null}
+
+ >
);
}
diff --git a/app/[locale]/(site)/portfolio/page.tsx b/app/[locale]/(site)/portfolio/page.tsx
index fa6c342..768bf13 100644
--- a/app/[locale]/(site)/portfolio/page.tsx
+++ b/app/[locale]/(site)/portfolio/page.tsx
@@ -1,9 +1,10 @@
import type { Metadata } from "next";
-import { ArrowUpRight, CalendarDays, FolderKanban } from "lucide-react";
+import { ArrowUpRight, CalendarDays } from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Container } from "@/components/layout/container";
+import { PageHero } from "@/components/layout/page-hero";
import { MotionFade } from "@/components/motion-fade";
import { buildLocalizedMetadata } from "@/lib/metadata";
import { AppCard } from "@/components/ui/app-card";
@@ -55,24 +56,11 @@ export default async function PortfolioPage({
]);
return (
-
-
-
-
-
-
-
- {t("title")}
-
-
-
- {t("intro")}
-
-
-
-
+ <>
+
-
-
+
{projects.map((item, index) => (
@@ -139,7 +127,8 @@ export default async function PortfolioPage({
) : null}
-
-
+
+
+ >
);
}
diff --git a/app/[locale]/(site)/products/[slug]/page.tsx b/app/[locale]/(site)/products/[slug]/page.tsx
index 65c03ae..0e25253 100644
--- a/app/[locale]/(site)/products/[slug]/page.tsx
+++ b/app/[locale]/(site)/products/[slug]/page.tsx
@@ -5,6 +5,7 @@ import { getTranslations } from "next-intl/server";
import { notFound } from "next/navigation";
import { Container } from "@/components/layout/container";
+import { PageHero } from "@/components/layout/page-hero";
import { MotionFade } from "@/components/motion-fade";
import { routing } from "@/i18n/routing";
import { buildLocalizedMetadata } from "@/lib/metadata";
@@ -64,8 +65,14 @@ export default async function ProductPage({ params: { locale, slug } }: ProductP
const t = await getTranslations({ locale: localeKey, namespace: "productDetail" });
return (
-
-
+ <>
+
+
+
+
@@ -111,9 +118,9 @@ export default async function ProductPage({ params: { locale, slug } }: ProductP
-
+
-
+
{t("included")}
@@ -134,7 +141,8 @@ export default async function ProductPage({ params: { locale, slug } }: ProductP
-
-
+
+
+ >
);
}
diff --git a/app/[locale]/(site)/products/page.tsx b/app/[locale]/(site)/products/page.tsx
index 639e680..2b5474f 100644
--- a/app/[locale]/(site)/products/page.tsx
+++ b/app/[locale]/(site)/products/page.tsx
@@ -1,9 +1,10 @@
import type { Metadata } from "next";
-import { ArrowUpRight, Boxes, CircleDollarSign } from "lucide-react";
+import { ArrowUpRight, CircleDollarSign } from "lucide-react";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Container } from "@/components/layout/container";
+import { PageHero } from "@/components/layout/page-hero";
import { MotionFade } from "@/components/motion-fade";
import { buildLocalizedMetadata } from "@/lib/metadata";
import { AppCard } from "@/components/ui/app-card";
@@ -36,24 +37,11 @@ export default async function ProductsPage({ params: { locale } }: ProductsPageP
const t = await getTranslations({ locale: localeKey, namespace: "productsPage" });
return (
-
-
-
-
-
-
-
- {t("title")}
-
-
-
- {t("intro")}
-
-
-
-
+ <>
+
-
+
+
{productItems.map((item, index) => (
@@ -84,7 +72,8 @@ export default async function ProductsPage({ params: { locale } }: ProductsPageP
))}
-
-
+
+
+ >
);
}
diff --git a/app/[locale]/(site)/success/page.tsx b/app/[locale]/(site)/success/page.tsx
index 44fa5c0..2bdff12 100644
--- a/app/[locale]/(site)/success/page.tsx
+++ b/app/[locale]/(site)/success/page.tsx
@@ -4,6 +4,7 @@ import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Container } from "@/components/layout/container";
+import { PageHero } from "@/components/layout/page-hero";
import { MotionFade } from "@/components/motion-fade";
import { buildLocalizedMetadata } from "@/lib/metadata";
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
@@ -36,8 +37,11 @@ export default async function SuccessPage({ params: { locale } }: SuccessPagePro
const t = await getTranslations({ locale: localeKey, namespace: "successPage" });
return (
-
-
+ <>
+
+
+
+
@@ -57,7 +61,8 @@ export default async function SuccessPage({ params: { locale } }: SuccessPagePro
-
-
+
+
+ >
);
}
diff --git a/app/[locale]/coming-soon/page.tsx b/app/[locale]/coming-soon/page.tsx
index 407cad5..88e7da1 100644
--- a/app/[locale]/coming-soon/page.tsx
+++ b/app/[locale]/coming-soon/page.tsx
@@ -3,14 +3,13 @@ import { Sparkles } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { AnimatedLogo } from "@/components/layout/animated-logo";
-import { Container } from "@/components/layout/container";
import { FloatingPreferences } from "@/components/layout/floating-preferences";
+import { HeroAtmosphere } from "@/components/layout/hero-atmosphere";
+import { SiteAmbientBackdrop } from "@/components/layout/site-ambient-backdrop";
import { MotionFade } from "@/components/motion-fade";
import { getSiteSettings } from "@/lib/app-config";
import { buildLocalizedMetadata } from "@/lib/metadata";
import { resolveLocale } from "@/lib/locale";
-import { AppCard } from "@/components/ui/app-card";
-import { CardContent } from "@/components/ui/card";
type ComingSoonPageProps = {
params: {
@@ -41,47 +40,40 @@ export default async function ComingSoonPage({
const t = await getTranslations({ locale: localeKey, namespace: "comingSoon" });
return (
-
+
+
-
-
-
-
-
-
+
+
-
-
-
- {t("badge")}
-
+
+
+
+
+ {t("badge")}
+
-
+
-
- {t("titleStart")}{" "}
- {t("titleAccent")} {" "}
- {t("titleEnd")}
-
+
+ {t("titleStart")}{" "}
+ {t("titleAccent")} {" "}
+ {t("titleEnd")}
+
-
- {t("description")}
-
+
+ {t("description")}
+
-
- {t("note")}
-
-
-
-
+
+ {t("note")}
+
+
-
+
);
}
diff --git a/app/globals.css b/app/globals.css
index 931bf61..a309ba4 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -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;
}
diff --git a/app/root/portfolio/categories/page.tsx b/app/root/portfolio/categories/page.tsx
index 4287138..0131b89 100644
--- a/app/root/portfolio/categories/page.tsx
+++ b/app/root/portfolio/categories/page.tsx
@@ -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 (
}
>
{searchParams?.success ? (
-
+
) : null}
{searchParams?.error ? (
-
+
) : null}
-
-
-
- Neue Kategorie
- Eine Kategorie wird genau einem oder mehreren Projekten zugeordnet.
-
-
-
-
-
-
-
-
- {categories.map((category, index) => (
-
-
-
-
-
-
-
-
-
-
-
{copy.sortOrder}
-
-
-
-
-
-
-
-
-
- {locales.map((locale) => (
-
- {locale.label}
-
- ))}
-
-
- {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 (
-
-
-
{locale.hint}
-
-
{`Name ${locale.label}`}
-
-
-
-
-
-
-
{`${copy.description} ${locale.label}`}
-
-
-
-
-
-
-
- );
- })}
-
-
-
-
-
- {copy.active}
-
-
-
-
{category.projectCount} {copy.projects}
-
- {copy.save}
-
-
-
-
-
-
-
- 0}>
- {copy.delete}
-
-
-
-
-
- ))}
-
+
);
diff --git a/app/root/portfolio/projects/[id]/page.tsx b/app/root/portfolio/projects/[id]/page.tsx
index 4d60fdb..9b2901f 100644
--- a/app/root/portfolio/projects/[id]/page.tsx
+++ b/app/root/portfolio/projects/[id]/page.tsx
@@ -78,7 +78,6 @@ export default async function RootPortfolioProjectPage({
logoutAction={logoutAction}
headerTitle={copy.title}
headerDescription={copy.subtitle}
- saveFormId="portfolio-project-form"
>
{searchParams?.success ? (
diff --git a/app/root/portfolio/projects/new/page.tsx b/app/root/portfolio/projects/new/page.tsx
index f89dc6f..535442a 100644
--- a/app/root/portfolio/projects/new/page.tsx
+++ b/app/root/portfolio/projects/new/page.tsx
@@ -58,7 +58,6 @@ export default async function RootNewPortfolioProjectPage({
logoutAction={logoutAction}
headerTitle={copy.title}
headerDescription={copy.subtitle}
- saveFormId="portfolio-project-form"
>
{searchParams?.error ? (
diff --git a/app/root/site-settings/actions.ts b/app/root/site-settings/actions.ts
index 31b2056..39b3ea7 100644
--- a/app/root/site-settings/actions.ts
+++ b/app/root/site-settings/actions.ts
@@ -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
? [
{
diff --git a/components/dashboard/sidebar.tsx b/components/dashboard/sidebar.tsx
index b2d299f..57bc040 100644
--- a/components/dashboard/sidebar.tsx
+++ b/components/dashboard/sidebar.tsx
@@ -32,7 +32,7 @@ export function DashboardSidebar({ items, iconSrc, top, footer }: DashboardSideb
-
+
{/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/layout/home-hero.tsx b/components/layout/home-hero.tsx
new file mode 100644
index 0000000..818d9e6
--- /dev/null
+++ b/components/layout/home-hero.tsx
@@ -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 (
+
+
+
+
+
+
+ {kicker}
+
+
+ {title}
+
+
+ {description}
+
+
+
+
+ {portfolioLabel}
+
+
+
+
+ {contactLabel}
+
+
+
+
+
+
+ );
+}
diff --git a/components/layout/locale-toggle.tsx b/components/layout/locale-toggle.tsx
index 5d161b0..a74bf18 100644
--- a/components/layout/locale-toggle.tsx
+++ b/components/layout/locale-toggle.tsx
@@ -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) {
+ {showLabel ? {currentLocale} : null}
diff --git a/components/layout/page-hero.tsx b/components/layout/page-hero.tsx
new file mode 100644
index 0000000..803f61d
--- /dev/null
+++ b/components/layout/page-hero.tsx
@@ -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 (
+
+
+
+
+
+ {title}
+
+
+ {title}
+
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/components/layout/site-ambient-backdrop.tsx b/components/layout/site-ambient-backdrop.tsx
new file mode 100644
index 0000000..4cbfa6d
--- /dev/null
+++ b/components/layout/site-ambient-backdrop.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+import { motion } from "framer-motion";
+
+export function SiteAmbientBackdrop() {
+ return (
+
+ );
+}
diff --git a/components/layout/site-header.tsx b/components/layout/site-header.tsx
index c70c424..0a0966e 100644
--- a/components/layout/site-header.tsx
+++ b/components/layout/site-header.tsx
@@ -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) => (
-
- {t(item.key)}
-
+ (() => {
+ const itemPath = item.path || "/";
+ const isActive = isNavItemActive(currentPath, itemPath);
+
+ return (
+
+ {isActive ? (
+
+ ) : null}
+ {t(item.key)}
+
+ );
+ })()
))}
- {isAdmin ? (
-
- {t("root")}
-
- ) : 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 (
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+ {isAdmin ? (
+
+
+
+
+
+ ) : null}
+
+
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")}
>
- {isOpen ? : }
+
+ {isOpen ? : }
+
- {isOpen ? (
-
-
-
- setIsOpen(false)} />
-
-
-
-
-
-
-
- ) : null}
+
+ {isOpen ? (
+
+
+
+
+ {navItems.map((item) => {
+ const itemPath = item.path || "/";
+ const isActive = isNavItemActive(stripLocalePrefix(pathname), itemPath);
+
+ return (
+
+ setIsOpen(false)}
+ >
+ {t(item.key)}
+
+ 0{navItems.findIndex((navItem) => navItem.key === item.key) + 1}
+
+
+
+ );
+ })}
+
+
+
+
+
+
+ {isAdmin ? (
+
+ setIsOpen(false)}>
+
+
+
+ ) : null}
+
+
+
+
+
+
+
+ ) : null}
+
);
}
diff --git a/components/layout/site-logo.tsx b/components/layout/site-logo.tsx
new file mode 100644
index 0000000..83314ea
--- /dev/null
+++ b/components/layout/site-logo.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/components/root/media-field-picker.tsx b/components/root/media-field-picker.tsx
index 5080b66..ecfc86d 100644
--- a/components/root/media-field-picker.tsx
+++ b/components/root/media-field-picker.tsx
@@ -110,7 +110,7 @@ export function MediaFieldPicker({
}, [serializedValue]);
return (
-
+
{title}
@@ -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}
@@ -208,7 +208,7 @@ export function MediaFieldPicker({
{value.mode === "library" ? (
{libraryLabel ?? "Media Library"}
-
+
;
+ 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 (
+
+ {locales.map((locale) => {
+ const nameKey = `name${locale.key}` as const;
+ const descriptionKey = `description${locale.key}` as const;
+
+ return (
+
+
+
+
+
{`Name ${locale.label}`}
+
+
+
+
+
+
+
+
{`${copy.description} ${locale.label}`}
+
+
+
+
+
+
+ );
+ })}
+
+ );
+}
+
+function EditCategoryDialog({
+ category,
+ open,
+ onOpenChange,
+ saveCategoryAction,
+ removeCategoryAction,
+}: {
+ category: PortfolioCategoriesManagerProps["categories"][number];
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ saveCategoryAction: CategoryAction;
+ removeCategoryAction: CategoryDeleteAction;
+}) {
+ return (
+
+
+
+ {copy.editCategory}
+ {copy.editDescription}
+
+
+
+
+
+
+
+
+
+
+
{copy.sortOrder}
+
+
+
+
+
+
+
+
+ {copy.active}
+
+
+
+
+
+
+
+
+ {category.projectCount > 0 ? copy.deleteBlocked : "Kategorie kann geloescht werden."}
+
+
+
+
+
+ 0}>
+
+ {copy.delete}
+
+
+
+ {copy.save}
+
+
+
+
+
+ );
+}
+
+export function PortfolioCategoriesManager({
+ categories,
+ activeCount,
+ assignedProjects,
+ saveCategoryAction,
+ removeCategoryAction,
+}: PortfolioCategoriesManagerProps) {
+ const searchParams = useSearchParams();
+ const [createOpen, setCreateOpen] = useState(false);
+ const [editingCategoryId, setEditingCategoryId] = useState
(null);
+
+ useEffect(() => {
+ if (searchParams.has("success")) {
+ setCreateOpen(false);
+ setEditingCategoryId(null);
+ }
+ }, [searchParams]);
+
+ return (
+
+
+
+
+
+
Total
+
{categories.length}
+
+
+
Active
+
{activeCount}
+
+
+
Assigned
+
{assignedProjects}
+
+
+
+
+
+
+
+ {copy.addCategory}
+
+
+
+
+ {copy.addCategory}
+ {copy.modalDescription}
+
+
+
+
+
+
+
+
+
+
{copy.sortOrder}
+
+
+
+
+
+
+
+
+ {copy.active}
+
+
+
+
+
+
+ {copy.saveCategory}
+
+
+
+
+
+
+
+
+
+
+
+
{copy.currentCategories}
+
+
+ {categories.length === 0 ? (
+ {copy.empty}
+ ) : (
+
+ {categories.map((category) => (
+
+
+
+
+
+
+ {category.name.de || category.name.en || category.name.ar}
+
+
+ {category.isActive ? "Aktiv" : "Inaktiv"}
+
+
+
+ {category.slug}
+ •
+ {copy.sortOrder} {category.sortOrder}
+
+
+
+
+
{category.projectCount} {copy.projects}
+
{
+ event.preventDefault();
+ event.stopPropagation();
+ setEditingCategoryId(category.id);
+ }}
+ >
+
+ {copy.editCategory}
+
+
+
+
+
+
+
+ {locales.map((locale) => (
+
+
{locale.label}
+
{category.name[locale.lowerKey]}
+
{category.description[locale.lowerKey]}
+
+ ))}
+
+
+
+
+ setEditingCategoryId(open ? category.id : null)}
+ saveCategoryAction={saveCategoryAction}
+ removeCategoryAction={removeCategoryAction}
+ />
+
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/components/root/portfolio-project-form.tsx b/components/root/portfolio-project-form.tsx
index 4fa13fd..0d7cab0 100644
--- a/components/root/portfolio-project-form.tsx
+++ b/components/root/portfolio-project-form.tsx
@@ -1,25 +1,14 @@
"use client";
import type { MediaKind, PortfolioAssetKind, PortfolioSectionType } from "@prisma/client";
-import {
- ArrowDown,
- ArrowUp,
- BriefcaseBusiness,
- CalendarRange,
- FolderTree,
- Hash,
- Link2,
- Plus,
- Sparkles,
- Trash2,
-} from "lucide-react";
-import { useState } from "react";
+import { ArrowDown, ArrowUp, CalendarDays, FolderTree, Link2, Plus, Text, Trash2, UserRound } from "lucide-react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { MediaFieldPicker, type MediaFieldState } from "@/components/root/media-field-picker";
import { AppCard } from "@/components/ui/app-card";
+import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CardContent, 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 {
@@ -29,11 +18,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { moveArrayItem } from "@/lib/array";
import type { MediaOption } from "@/lib/media";
import type { PortfolioCategoryView, PortfolioProjectView } from "@/lib/portfolio";
+import { cn } from "@/lib/utils";
+
+type PanelKey = "basic" | "localized" | "sections" | "assets";
type SectionFormValue = {
id?: string;
@@ -62,6 +53,26 @@ type AssetFormValue = {
sortOrder: number;
};
+type ProjectFormState = {
+ categoryId: string;
+ slug: string;
+ clientName: string;
+ projectYear: string;
+ previewUrl: string;
+ sortOrder: string;
+ isFeatured: boolean;
+ isPublished: boolean;
+ titleAr: string;
+ titleEn: string;
+ titleDe: string;
+ serviceLabelAr: string;
+ serviceLabelEn: string;
+ serviceLabelDe: string;
+ summaryAr: string;
+ summaryEn: string;
+ summaryDe: string;
+};
+
type PortfolioProjectFormProps = {
action: (formData: FormData) => void | Promise;
categories: PortfolioCategoryView[];
@@ -72,6 +83,12 @@ type PortfolioProjectFormProps = {
submitLabel: string;
};
+const localeFieldConfig = [
+ { key: "ar" as const, suffix: "Ar" as const, label: "Arabic" },
+ { key: "en" as const, suffix: "En" as const, label: "English" },
+ { key: "de" as const, suffix: "De" as const, label: "German" },
+] as const;
+
const sectionTypeOptions: PortfolioSectionType[] = [
"RICH_TEXT",
"GALLERY",
@@ -81,11 +98,23 @@ const sectionTypeOptions: PortfolioSectionType[] = [
];
const assetKindOptions: PortfolioAssetKind[] = ["IMAGE", "DOCUMENT"];
-const localeFieldConfig = [
- { key: "ar" as const, suffix: "Ar" as const, label: "AR" },
- { key: "en" as const, suffix: "En" as const, label: "EN" },
- { key: "de" as const, suffix: "De" as const, label: "DE" },
-];
+
+function getSectionTypeLabel(type: PortfolioSectionType) {
+ switch (type) {
+ case "RICH_TEXT":
+ return "Rich Text";
+ case "GALLERY":
+ return "Single Image";
+ case "STATS":
+ return "Stats";
+ case "DELIVERABLES":
+ return "Deliverables";
+ case "LINK":
+ return "Link";
+ default:
+ return type;
+ }
+}
function createMediaFieldState(params: {
kind: MediaKind;
@@ -102,6 +131,31 @@ function createMediaFieldState(params: {
};
}
+function createInitialProjectState(
+ project: PortfolioProjectView | null | undefined,
+ categories: PortfolioCategoryView[],
+): ProjectFormState {
+ return {
+ categoryId: project?.category.id ?? categories[0]?.id ?? "",
+ slug: project?.slug ?? "",
+ clientName: project?.clientName ?? "",
+ projectYear: String(project?.projectYear ?? new Date().getFullYear()),
+ previewUrl: project?.previewUrl ?? "",
+ sortOrder: String(project?.sortOrder ?? 0),
+ isFeatured: project?.isFeatured ?? false,
+ isPublished: project?.isPublished ?? false,
+ titleAr: project?.title.ar ?? "",
+ titleEn: project?.title.en ?? "",
+ titleDe: project?.title.de ?? "",
+ serviceLabelAr: project?.serviceLabel.ar ?? "",
+ serviceLabelEn: project?.serviceLabel.en ?? "",
+ serviceLabelDe: project?.serviceLabel.de ?? "",
+ summaryAr: project?.summary.ar ?? "",
+ summaryEn: project?.summary.en ?? "",
+ summaryDe: project?.summary.de ?? "",
+ };
+}
+
function createEmptySection(index: number): SectionFormValue {
return {
type: "RICH_TEXT",
@@ -112,9 +166,7 @@ function createEmptySection(index: number): SectionFormValue {
bodyEn: "",
bodyDe: "",
imagePath: "",
- media: createMediaFieldState({
- kind: "IMAGE",
- }),
+ media: createMediaFieldState({ kind: "IMAGE" }),
linkUrl: "",
sortOrder: index,
};
@@ -125,9 +177,7 @@ function createEmptyAsset(index: number): AssetFormValue {
kind: "IMAGE",
filePath: "",
fileFieldName: `asset-upload-${index}`,
- media: createMediaFieldState({
- kind: "IMAGE",
- }),
+ media: createMediaFieldState({ kind: "IMAGE" }),
altAr: "",
altEn: "",
altDe: "",
@@ -135,6 +185,100 @@ function createEmptyAsset(index: number): AssetFormValue {
};
}
+function getAssetKindLabel(kind: PortfolioAssetKind) {
+ return kind === "IMAGE" ? "Image" : "Document";
+}
+
+function hasText(value: string) {
+ return value.trim().length > 0;
+}
+
+function isSectionComplete(section: SectionFormValue) {
+ const hasLocalizedTitle =
+ hasText(section.titleAr) &&
+ hasText(section.titleEn) &&
+ hasText(section.titleDe);
+
+ if (!hasLocalizedTitle) {
+ return false;
+ }
+
+ if (section.type === "GALLERY") {
+ return hasText(section.media.assetId) || hasText(section.imagePath);
+ }
+
+ if (section.type === "LINK") {
+ return hasText(section.linkUrl);
+ }
+
+ return (
+ hasText(section.bodyAr) &&
+ hasText(section.bodyEn) &&
+ hasText(section.bodyDe)
+ );
+}
+
+function isAssetComplete(asset: AssetFormValue) {
+ return (
+ (hasText(asset.media.assetId) || hasText(asset.media.url)) &&
+ hasText(asset.altAr) &&
+ hasText(asset.altEn) &&
+ hasText(asset.altDe)
+ );
+}
+
+function PanelButton({
+ active,
+ title,
+ done,
+ onClick,
+}: {
+ active: boolean;
+ title: string;
+ done: boolean;
+ onClick: () => void;
+}) {
+ return (
+
+ {title}
+
+ {done ? "Ready" : "Open"}
+
+
+ );
+}
+
+function LocaleBlock({
+ title,
+ renderField,
+}: {
+ title: string;
+ renderField: (locale: (typeof localeFieldConfig)[number]) => React.ReactNode;
+}) {
+ return (
+
+
{title}
+
+ {localeFieldConfig.map((locale) => (
+
+
{locale.label}
+ {renderField(locale)}
+
+ ))}
+
+
+ );
+}
+
export function PortfolioProjectForm({
action,
categories,
@@ -144,6 +288,10 @@ export function PortfolioProjectForm({
redirectPath,
submitLabel,
}: PortfolioProjectFormProps) {
+ const [activePanel, setActivePanel] = useState("basic");
+ const [projectState, setProjectState] = useState(
+ createInitialProjectState(project, categories),
+ );
const [coverMedia, setCoverMedia] = useState(
createMediaFieldState({
kind: "IMAGE",
@@ -193,18 +341,21 @@ export function PortfolioProjectForm({
altDe: asset.alt.de,
sortOrder: index,
}))
- : [],
+ : [createEmptyAsset(0)],
);
- const [isFeatured, setIsFeatured] = useState(project?.isFeatured ?? false);
- const [isPublished, setIsPublished] = useState(project?.isPublished ?? false);
+ const [selectedSectionIndex, setSelectedSectionIndex] = useState(0);
+ const [selectedAssetIndex, setSelectedAssetIndex] = useState(0);
+ const sectionsInputRef = useRef(null);
+ const assetsInputRef = useRef(null);
+ const selectedSection = sections[selectedSectionIndex];
+ const selectedAsset = assets[selectedAssetIndex];
const sectionsPayload = JSON.stringify(
sections.map((section, index) => ({
...section,
sortOrder: index,
})),
);
-
const assetsPayload = JSON.stringify(
assets.map((asset, index) => ({
...asset,
@@ -212,530 +363,520 @@ export function PortfolioProjectForm({
})),
);
+ useEffect(() => {
+ const hiddenInput = sectionsInputRef.current;
+
+ if (!hiddenInput) {
+ return;
+ }
+
+ hiddenInput.dispatchEvent(new Event("input", { bubbles: true }));
+ hiddenInput.dispatchEvent(new Event("change", { bubbles: true }));
+ }, [sectionsPayload]);
+
+ useEffect(() => {
+ const hiddenInput = assetsInputRef.current;
+
+ if (!hiddenInput) {
+ return;
+ }
+
+ hiddenInput.dispatchEvent(new Event("input", { bubbles: true }));
+ hiddenInput.dispatchEvent(new Event("change", { bubbles: true }));
+ }, [assetsPayload]);
+
+ useEffect(() => {
+ if (selectedSectionIndex > sections.length - 1) {
+ setSelectedSectionIndex(Math.max(0, sections.length - 1));
+ }
+ }, [sections.length, selectedSectionIndex]);
+
+ useEffect(() => {
+ if (selectedAssetIndex > assets.length - 1) {
+ setSelectedAssetIndex(Math.max(0, assets.length - 1));
+ }
+ }, [assets.length, selectedAssetIndex]);
+
+ const validation = useMemo(() => {
+ const basicDone =
+ hasText(projectState.categoryId) &&
+ hasText(projectState.slug) &&
+ hasText(projectState.clientName) &&
+ hasText(projectState.projectYear) &&
+ hasText(projectState.sortOrder);
+
+ const localizedDone = localeFieldConfig.every((locale) =>
+ hasText(projectState[`title${locale.suffix}`]) &&
+ hasText(projectState[`serviceLabel${locale.suffix}`]) &&
+ hasText(projectState[`summary${locale.suffix}`]),
+ );
+
+ const sectionsDone = sections.length > 0 && sections.every(isSectionComplete);
+ const assetsDone = assets.length > 0 && assets.every(isAssetComplete);
+
+ return {
+ basicDone,
+ localizedDone,
+ sectionsDone,
+ assetsDone,
+ allDone: basicDone && localizedDone && sectionsDone && assetsDone,
+ };
+ }, [assets, projectState, sections]);
+
+ const setProjectField = (key: K, value: ProjectFormState[K]) => {
+ setProjectState((current) => ({
+ ...current,
+ [key]: value,
+ }));
+ };
+
+ const updateSection = (index: number, nextValue: SectionFormValue) => {
+ setSections((current) =>
+ current.map((item, currentIndex) => (currentIndex === index ? nextValue : item)),
+ );
+ };
+
+ const updateAsset = (index: number, nextValue: AssetFormValue) => {
+ setAssets((current) =>
+ current.map((item, currentIndex) => (currentIndex === index ? nextValue : item)),
+ );
+ };
+
return (
-
-
+
+
-
-
- Projekt Basisdaten
-
-
-
-
Kategorie
-
-
-
-
-
-
-
- {categories.map((category) => (
-
- {category.name.de} / {category.name.en}
-
- ))}
-
-
-
-
+
+
+
+ setActivePanel("basic")} />
+ setActivePanel("localized")} />
+ setActivePanel("sections")} />
+ setActivePanel("assets")} />
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- setIsFeatured(checked === true)}
- />
-
- Hervorgehoben
-
-
-
- setIsPublished(checked === true)}
- />
-
- Veroeffentlicht
-
-
-
-
-
-
- Lokalisierte Inhalte
-
-
-
-
- {localeFieldConfig.map((locale) => (
-
- {locale.label}
-
- ))}
-
- {localeFieldConfig.map((locale) => (
-
-
-
- {locale.key === "ar"
- ? "Arabische Inhalte fuer die arabische Website."
- : locale.key === "en"
- ? "Englische Inhalte fuer die englische Website."
- : "Deutsche Inhalte fuer die deutsche Website."}
-
+
+
+
+
+ Basic Info
+
+
+
-
{`Titel ${locale.label}`}
+
Category
+
+
+ setProjectField("categoryId", value)} required>
+
+
+
+
+ {categories.map((category) => (
+
+ {category.name.de} / {category.name.en}
+
+ ))}
+
+
+
+
+
+
+
Slug
+
+
+ setProjectField("slug", event.target.value)} className="pl-9" required />
+
+
+
+
+
Client
+
+
+ setProjectField("clientName", event.target.value)} className="pl-9" required />
+
+
+
+
+
Project Year
+
+
+ setProjectField("projectYear", event.target.value)} className="pl-9" required />
+
+
+
+
+
Preview URL
+
+
+ setProjectField("previewUrl", event.target.value)} className="pl-9" />
+
+
+
+
+
Sort Order
+
+
+ setProjectField("sortOrder", event.target.value)} className="pl-9" required />
+
+
+
+
+
+
+
+
+ setProjectField("isFeatured", event.target.checked)} />
+ Featured
+
+
+
+ setProjectField("isPublished", event.target.checked)} />
+ Published
+
+
+
+
+
+
+
-
- {`Leistungslabel ${locale.label}`}
+ )}
+ />
+
+ (
setProjectField(`serviceLabel${locale.suffix}`, event.target.value)}
/>
-
-
- {`Kurzbeschreibung ${locale.label}`}
+ )}
+ />
+
+ (
setProjectField(`summary${locale.suffix}`, event.target.value)}
/>
-
-
-
- ))}
-
-
-
+ )}
+ />
+
+
+
-
-
- Abschnitte
- setSections((current) => [...current, createEmptySection(current.length)])}
- >
-
- Abschnitt hinzufuegen
-
-
-
- {sections.map((section, index) => (
-
-
-
-
{`Abschnitt #${index + 1}`}
-
-
+
+
+ Sections
+ {
+ setSections((current) => [...current, createEmptySection(current.length)]);
+ setSelectedSectionIndex(sections.length);
+ }}
+ >
+
+ Add Section
+
+
+
+
+ {sections.map((section, index) => (
+
- setSections((current) => moveArrayItem(current, index, index - 1))
- }
- disabled={index === 0}
+ onClick={() => setSelectedSectionIndex(index)}
+ className={cn(
+ "w-full rounded-lg border p-4 text-left transition-colors",
+ selectedSectionIndex === index
+ ? "border-input bg-accent/40"
+ : "border-input bg-background hover:bg-accent/20",
+ )}
>
-
-
-
- setSections((current) => moveArrayItem(current, index, index + 1))
- }
- disabled={index === sections.length - 1}
- >
-
-
-
- setSections((current) => current.filter((_, currentIndex) => currentIndex !== index))
- }
- disabled={sections.length === 1}
- >
-
- Entfernen
-
-
+
+
+
+ {section.titleDe || section.titleEn || section.titleAr || `Section ${index + 1}`}
+
+
{getSectionTypeLabel(section.type)}
+
+
+ {isSectionComplete(section) ? "Ready" : "Open"}
+
+
+
+ ))}
-
-
- Typ
-
- setSections((current) =>
- current.map((item, currentIndex) =>
- currentIndex === index
- ? { ...item, type: value as PortfolioSectionType }
- : item,
- ),
- )
- }
- >
-
-
-
-
- {sectionTypeOptions.map((type) => (
-
- {type}
-
- ))}
-
-
-
+
+ {selectedSection ? (
+
+
+
+
{`Section ${selectedSectionIndex + 1}`}
+
+
{
+ setSections((current) => moveArrayItem(current, selectedSectionIndex, selectedSectionIndex - 1));
+ setSelectedSectionIndex((current) => Math.max(0, current - 1));
+ }} disabled={selectedSectionIndex === 0}>
+
+
+
{
+ setSections((current) => moveArrayItem(current, selectedSectionIndex, selectedSectionIndex + 1));
+ setSelectedSectionIndex((current) => Math.min(sections.length - 1, current + 1));
+ }} disabled={selectedSectionIndex === sections.length - 1}>
+
+
+
{
+ if (sections.length === 1) {
+ return;
+ }
+ setSections((current) => current.filter((_, index) => index !== selectedSectionIndex));
+ setSelectedSectionIndex((current) => Math.max(0, current - 1));
+ }}>
+
+
+
+
-
-
- setSections((current) =>
- current.map((item, currentIndex) =>
- currentIndex === index
- ? {
- ...item,
- media,
- imagePath: media.url,
- }
- : item,
- ),
- )
- }
- options={mediaOptions}
- inputName={`section-media-${index}`}
- fileFieldName={`section-image-upload-${index}`}
- accept="image/*,.svg"
- />
-
+
+
+ Type
+ updateSection(selectedSectionIndex, { ...selectedSection, type: value as PortfolioSectionType })}>
+
+
+
+
+ {sectionTypeOptions.map((type) => (
+ {getSectionTypeLabel(type)}
+ ))}
+
+
+
-
-
Link URL
-
-
-
- setSections((current) =>
- current.map((item, currentIndex) =>
- currentIndex === index ? { ...item, linkUrl: event.target.value } : item,
- ),
- )
- }
- placeholder="Link URL"
- className="pl-9"
- />
-
-
+ {selectedSection.type === "LINK" ? (
+
+
Link URL
+
+
+ updateSection(selectedSectionIndex, { ...selectedSection, linkUrl: event.target.value })} className="pl-9" />
+
+
+ ) : null}
+
- {localeFieldConfig.map((locale) => (
-
- {`Titel ${locale.label}`}
-
- setSections((current) =>
- current.map((item, currentIndex) =>
- currentIndex === index
- ? {
- ...item,
- [`title${locale.suffix}`]: event.target.value,
- }
- : item,
- ),
- )
- }
- />
-
- ))}
+ {selectedSection.type === "GALLERY" ? (
+ updateSection(selectedSectionIndex, { ...selectedSection, media, imagePath: media.url })}
+ options={mediaOptions}
+ inputName={`section-media-${selectedSectionIndex}`}
+ fileFieldName={`section-image-upload-${selectedSectionIndex}`}
+ accept="image/*,.svg"
+ allowExternal={false}
+ />
+ ) : null}
- {localeFieldConfig.map((locale) => (
-
- {`Text ${locale.label}`}
-
- setSections((current) =>
- current.map((item, currentIndex) =>
- currentIndex === index
- ? {
- ...item,
- [`body${locale.suffix}`]: event.target.value,
- }
- : item,
- ),
- )
- }
- />
-
- ))}
+ (
+ updateSection(selectedSectionIndex, { ...selectedSection, [`title${locale.suffix}`]: event.target.value })}
+ />
+ )}
+ />
+
+ {selectedSection.type !== "GALLERY" && selectedSection.type !== "LINK" ? (
+ (
+ updateSection(selectedSectionIndex, { ...selectedSection, [`body${locale.suffix}`]: event.target.value })}
+ />
+ )}
+ />
+ ) : null}
+
+
+ ) : null}
- ))}
-
-
+
-
-
- Dateien
- setAssets((current) => [...current, createEmptyAsset(current.length)])}
- >
-
- Datei hinzufuegen
-
-
-
- {assets.length === 0 ? (
- Noch keine Dateien hinzugefuegt.
- ) : null}
-
- {assets.map((asset, index) => (
-
-
-
-
{`Datei #${index + 1}`}
-
-
+
+
+ Assets
+ {
+ setAssets((current) => [...current, createEmptyAsset(current.length)]);
+ setSelectedAssetIndex(assets.length);
+ }}
+ >
+
+ Add Asset
+
+
+
+
+ {assets.map((asset, index) => (
+
setAssets((current) => moveArrayItem(current, index, index - 1))}
- disabled={index === 0}
+ onClick={() => setSelectedAssetIndex(index)}
+ className={cn(
+ "w-full rounded-lg border p-4 text-left transition-colors",
+ selectedAssetIndex === index
+ ? "border-input bg-accent/40"
+ : "border-input bg-background hover:bg-accent/20",
+ )}
>
-
-
-
setAssets((current) => moveArrayItem(current, index, index + 1))}
- disabled={index === assets.length - 1}
- >
-
-
-
- setAssets((current) => current.filter((_, currentIndex) => currentIndex !== index))
- }
- >
-
- Entfernen
-
-
+
+
+
+ {asset.altDe || asset.altEn || asset.altAr || `Asset ${index + 1}`}
+
+
{getAssetKindLabel(asset.kind)}
+
+
+ {isAssetComplete(asset) ? "Ready" : "Open"}
+
+
+
+ ))}
-
-
- Typ
-
- setAssets((current) =>
- current.map((item, currentIndex) =>
- currentIndex === index
- ? {
- ...item,
- kind: value as PortfolioAssetKind,
- media: {
- ...item.media,
- kind: value as PortfolioAssetKind,
- },
- }
- : item,
- ),
- )
- }
- >
-
-
-
-
- {assetKindOptions.map((kind) => (
-
- {kind}
-
- ))}
-
-
-
+
+ {selectedAsset ? (
+
+
+
+
{`Asset ${selectedAssetIndex + 1}`}
+
+
{
+ setAssets((current) => moveArrayItem(current, selectedAssetIndex, selectedAssetIndex - 1));
+ setSelectedAssetIndex((current) => Math.max(0, current - 1));
+ }} disabled={selectedAssetIndex === 0}>
+
+
+
{
+ setAssets((current) => moveArrayItem(current, selectedAssetIndex, selectedAssetIndex + 1));
+ setSelectedAssetIndex((current) => Math.min(assets.length - 1, current + 1));
+ }} disabled={selectedAssetIndex === assets.length - 1}>
+
+
+
{
+ if (assets.length === 1) {
+ return;
+ }
+ setAssets((current) => current.filter((_, index) => index !== selectedAssetIndex));
+ setSelectedAssetIndex((current) => Math.max(0, current - 1));
+ }}>
+
+
+
+
-
-
- setAssets((current) =>
- current.map((item, currentIndex) =>
- currentIndex === index
- ? {
- ...item,
- media,
- filePath: media.url,
- }
- : item,
- ),
- )
- }
- options={mediaOptions}
- inputName={`asset-media-${index}`}
- fileFieldName={asset.fileFieldName}
- accept="image/*,.svg,.pdf"
- />
-
+
+ Type
+ updateAsset(selectedAssetIndex, { ...selectedAsset, kind: value as PortfolioAssetKind, media: { ...selectedAsset.media, kind: value as MediaKind } })}>
+
+
+
+
+ {assetKindOptions.map((kind) => (
+ {getAssetKindLabel(kind)}
+ ))}
+
+
+
- {localeFieldConfig.map((locale) => (
-
- {`Alt ${locale.label}`}
-
- setAssets((current) =>
- current.map((item, currentIndex) =>
- currentIndex === index
- ? {
- ...item,
- [`alt${locale.suffix}`]: event.target.value,
- }
- : item,
- ),
- )
- }
- />
-
- ))}
+ updateAsset(selectedAssetIndex, { ...selectedAsset, media, filePath: media.url })}
+ options={mediaOptions}
+ inputName={`asset-media-${selectedAssetIndex}`}
+ fileFieldName={selectedAsset.fileFieldName}
+ accept={selectedAsset.kind === "IMAGE" ? "image/*,.svg" : ".pdf,.doc,.docx,.ppt,.pptx"}
+ allowExternal={false}
+ />
+
+ (
+ updateAsset(selectedAssetIndex, { ...selectedAsset, [`alt${locale.suffix}`]: event.target.value })}
+ />
+ )}
+ />
+
+
+ ) : null}
- ))}
-
-
-
-
- {submitLabel}
+
+
+
+
+
+
+ {validation.allDone
+ ? "Alles bereit zum Speichern."
+ : "Du kannst jetzt speichern. Falls Pflichtfelder fehlen, bekommst du oben eine Fehlermeldung."}
+
+
+ {submitLabel}
+
+
+
);
}
diff --git a/components/root/portfolio-projects-overview.tsx b/components/root/portfolio-projects-overview.tsx
index 478f3b4..eb39b0b 100644
--- a/components/root/portfolio-projects-overview.tsx
+++ b/components/root/portfolio-projects-overview.tsx
@@ -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 (
-
-
-
-
- {copy.summary}
-
-
-
Projektverwaltung
-
{copy.description}
+
+
+
Projects
+
{projects.length}
+
+
+
Categories
+
{categories.length}
+
+
+
Published
+
+ {projects.filter((project) => project.isPublished).length}
+
@@ -117,45 +85,7 @@ export function PortfolioProjectsOverview({
-
- {[
- {
- 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 (
-
-
-
-
-
-
-
-
{item.label}
-
{item.value}
-
-
-
-
- );
- })}
-
-
-
+
@@ -211,104 +141,53 @@ export function PortfolioProjectsOverview({
- {projects.map((project, index) => {
- const completion = getProjectCompletion(project);
-
- return (
-
-
-
-
-
-
- {getLocalizedValue(project.title, "de") || copy.untitled}
-
-
- {project.isPublished ? copy.published : copy.draft}
-
-
-
- {project.category.name.de || copy.noCategory}
- •
- {project.projectYear}
- •
- {project.slug}
-
-
-
-
-
- {copy.review}: {completion.completedChecks}/{completion.totalChecks}
-
-
- {completion.ready ? copy.reviewDone : copy.reviewMissing}
+ {projects.map((project, index) => (
+
+
+
+
+
+
+ {getLocalizedValue(project.title, "de") || copy.untitled}
+
+
+ {project.isPublished ? copy.published : copy.draft}
-
-
-
-
- {getLocalizedValue(project.summary, "de") || "Noch keine Kurzbeschreibung hinterlegt."}
+
+ {project.category.name.de}
+
-
-
- {project.previewUrl ? copy.hasPreview : copy.noPreview}
-
-
- {project.coverImagePath ? copy.hasCover : copy.missingCover}
-
- 0 ? "success" : "outline"}>
- {project.sections.length > 0 ? copy.hasSections : copy.noSections}
-
- 0 ? "success" : "outline"}>
- {project.assets.length > 0 ? copy.hasAssets : copy.noAssets}
-
-
-
-
-
-
-
- {copy.openProject}
-
-
-
- {copy.editProject}
-
-
-
-
-
- );
- })}
-
- {projects.length === 0 ? (
-
-
-
- {copy.empty}
-
-
-
- {copy.newProject}
+
+
+
+ {copy.openProject}
-
-
-
- {copy.newCategory}
+
+
+
+ {copy.editProject}
-
+
+ ))}
+
+ {projects.length === 0 ? (
+
+
+ {copy.empty}
+
+
) : null}
diff --git a/components/root/sidebar-maintenance-control.tsx b/components/root/sidebar-maintenance-control.tsx
index f6d2087..f0bbb96 100644
--- a/components/root/sidebar-maintenance-control.tsx
+++ b/components/root/sidebar-maintenance-control.tsx
@@ -34,7 +34,7 @@ export function SidebarMaintenanceControl({
(
+ createImageFieldState(
+ initialBindings.siteLogoLight?.assetId,
+ initialBindings.siteLogoLight?.url,
+ "Site Logo Light",
+ ),
+ );
+ const [siteLogoDark, setSiteLogoDark] = useState(
+ createImageFieldState(
+ initialBindings.siteLogoDark?.assetId,
+ initialBindings.siteLogoDark?.url,
+ "Site Logo Dark",
+ ),
+ );
const [favicon, setFavicon] = useState(
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({
- Preview Images
+ Brand And Preview Images
- 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.
+
+
+
+
+
+
+ Header Logo Preview
+
+
+
+
+ Light
+
+ {siteLogoLightPreviewUrl ? (
+
+ ) : (
+
+ Default light logo will be used
+
+ )}
+
+
+
+ Dark
+
+ {siteLogoDarkPreviewUrl ? (
+
+ ) : (
+
+ Default dark logo will be used
+
+ )}
+
+
+
Search Preview
diff --git a/components/root/workspace-patterns.tsx b/components/root/workspace-patterns.tsx
new file mode 100644
index 0000000..90c8e4a
--- /dev/null
+++ b/components/root/workspace-patterns.tsx
@@ -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 (
+
+
+
+
+ {eyebrow}
+
+
{title}
+
{description}
+
+ {aside ? {aside}
: null}
+
+
+ );
+}
+
+export function WorkspaceSidebarPanel({
+ title,
+ children,
+}: {
+ title: string;
+ children: ReactNode;
+}) {
+ return (
+
+
+ {title}
+
+ {children}
+
+ );
+}
+
+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 (
+
+
+ {completed ? completeIcon ?? "OK" : index + 1}
+
+
+
+ {eyebrow}
+
+
{label}
+
+
+ );
+}
+
+export function WorkspaceLocaleCard({
+ title,
+ hint,
+ children,
+}: {
+ title: string;
+ hint: string;
+ children: ReactNode;
+}) {
+ return (
+
+
+ {title}
+ {hint}
+
+ {children}
+
+ );
+}
+
+export function WorkspaceStatusBadge({
+ done,
+ doneLabel = "Ready",
+ openLabel = "Open",
+}: {
+ done: boolean;
+ doneLabel?: string;
+ openLabel?: string;
+}) {
+ return
{done ? doneLabel : openLabel} ;
+}
diff --git a/components/theme-toggle.tsx b/components/theme-toggle.tsx
index ea07d80..e51abbd 100644
--- a/components/theme-toggle.tsx
+++ b/components/theme-toggle.tsx
@@ -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)}
>
-
+
{label ?
{label} : null}
);
@@ -54,7 +56,7 @@ export function ThemeToggle({
aria-label={ariaLabel}
className={cn(label ? "justify-start" : undefined, className)}
>
- {isDark ?
:
}
+ {isDark ?
:
}
{label ?
{label} : null}
);
diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx
new file mode 100644
index 0000000..0e95900
--- /dev/null
+++ b/components/ui/accordion.tsx
@@ -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
,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+
+AccordionItem.displayName = "AccordionItem";
+
+const AccordionTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ svg]:rotate-180",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+));
+
+AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
+
+const AccordionContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+));
+
+AccordionContent.displayName = AccordionPrimitive.Content.displayName;
+
+export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
diff --git a/components/ui/app-card.tsx b/components/ui/app-card.tsx
index f90e964..983fb3c 100644
--- a/components/ui/app-card.tsx
+++ b/components/ui/app-card.tsx
@@ -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: "",
diff --git a/components/ui/button.tsx b/components/ui/button.tsx
index ad1fc5b..8aedc0c 100644
--- a/components/ui/button.tsx
+++ b/components/ui/button.tsx
@@ -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",
diff --git a/components/ui/card.tsx b/components/ui/card.tsx
index 9b02e29..fa2b5ec 100644
--- a/components/ui/card.tsx
+++ b/components/ui/card.tsx
@@ -7,7 +7,7 @@ const Card = React.forwardRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+
+const DialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+));
+
+DialogContent.displayName = DialogPrimitive.Content.displayName;
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+
+DialogTitle.displayName = DialogPrimitive.Title.displayName;
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+
+DialogDescription.displayName = DialogPrimitive.Description.displayName;
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+};
diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx
index 87fef1b..f3b9756 100644
--- a/components/ui/dropdown-menu.tsx
+++ b/components/ui/dropdown-menu.tsx
@@ -22,7 +22,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
>(
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}
@@ -105,9 +105,9 @@ const SelectContent = React.forwardRef<
>(({ className, children, position = "popper", ...props }, ref) => (
(({ className, children, ...props }, ref) => (
(
{copy.surfaces}
{copy.typography}
{copy.headers}
+ {copy.workspace}
@@ -516,6 +533,44 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
+
+
+
+
+
+
+
+
{copy.workspaceMain}
+
{copy.workspaceHeader}
+
+ {copy.workspaceMainText}
+
+
+ Step 1
+
+
+ Step 2
+
+
+
+
+
+
+
+ {copy.workspaceSide}
+ {copy.workspaceSideText}
+
+
Ready
+
Open
+
+ Checklist / status / focus details
+
+
+
+
+
+
+
);
diff --git a/lib/app-config.ts b/lib/app-config.ts
index ce93961..667e10d 100644
--- a/lib/app-config.ts
+++ b/lib/app-config.ts
@@ -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
(
(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,
diff --git a/lib/portfolio-validation.ts b/lib/portfolio-validation.ts
index fdaae64..d12a57b 100644
--- a/lib/portfolio-validation.ts
+++ b/lib/portfolio-validation.ts
@@ -25,23 +25,61 @@ export const categoryInputSchema = z.object({
isActive: z.boolean(),
});
-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"),
- imagePath: optionalTrimmedText,
- media: mediaFieldInputSchema.optional(),
- linkUrl: optionalTrimmedText.refine(
- (value) => value === "" || /^https?:\/\//.test(value) || value.startsWith("/"),
- "Section linkUrl must be an absolute URL or start with /.",
- ),
- sortOrder: z.coerce.number().int().min(0).max(9999),
-});
+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: optionalTrimmedText,
+ bodyEn: optionalTrimmedText,
+ bodyDe: optionalTrimmedText,
+ imagePath: optionalTrimmedText,
+ media: mediaFieldInputSchema.optional(),
+ linkUrl: optionalTrimmedText.refine(
+ (value) => value === "" || /^https?:\/\//.test(value) || value.startsWith("/"),
+ "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(),
diff --git a/lib/site-settings.ts b/lib/site-settings.ts
index 379a609..fdd4895 100644
--- a/lib/site-settings.ts
+++ b/lib/site-settings.ts
@@ -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,
};
diff --git a/messages/ar.json b/messages/ar.json
index b708679..dad1307 100644
--- a/messages/ar.json
+++ b/messages/ar.json
@@ -22,9 +22,9 @@
"note": "شكراً لصبرك. النسخة الجديدة ستصل قريباً بهوية أنضج وحضور أدق."
},
"homepage": {
- "heroKicker": "Digital Studio",
- "heroTitle": "مواقع ومنتجات تنطلق بسرعة.",
- "heroText": "هذه الصفحة الرئيسية تشكل قاعدة لانطلاقة تسويقية ومنتجية متعددة اللغات.",
+ "heroKicker": "مرحباً، أنا مو",
+ "heroTitle": "frontend\n developer\n& designer",
+ "heroText": "أصمم وأبني تجارب رقمية نظيفة مع اهتمام دقيق بالتفاصيل والحركة وسهولة الاستخدام.",
"portfolioTitle": "أعمال مميزة",
"portfolioText": "منطقة تجريبية لعرض مشاريع مختارة.",
"productsTitle": "منتجات مميزة",
diff --git a/messages/de.json b/messages/de.json
index 35b9962..a3e69cb 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -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",
diff --git a/messages/en.json b/messages/en.json
index 1462253..8db1a4f 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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",
diff --git a/package-lock.json b/package-lock.json
index 67624a7..714f28a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index ced3518..3e6c852 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/skills/mohs-frontend-system/SKILL.md b/skills/mohs-frontend-system/SKILL.md
new file mode 100644
index 0000000..9c32ba0
--- /dev/null
+++ b/skills/mohs-frontend-system/SKILL.md
@@ -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.
diff --git a/skills/mohs-frontend-system/agents/openai.yaml b/skills/mohs-frontend-system/agents/openai.yaml
new file mode 100644
index 0000000..f84169c
--- /dev/null
+++ b/skills/mohs-frontend-system/agents/openai.yaml
@@ -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."
diff --git a/tests/metadata.test.ts b/tests/metadata.test.ts
index 1122c04..2913c00 100644
--- a/tests/metadata.test.ts
+++ b/tests/metadata.test.ts
@@ -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,
},
diff --git a/tests/portfolio-validation.test.ts b/tests/portfolio-validation.test.ts
index 948f0e0..c00705d 100644
--- a/tests/portfolio-validation.test.ts
+++ b/tests/portfolio-validation.test.ts
@@ -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);
+ });
});
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..39c7137
--- /dev/null
+++ b/vitest.config.ts
@@ -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",
+ },
+});