Implement portfolio admin management
This commit is contained in:
@@ -16,7 +16,11 @@ import { AppCard } from "@/components/ui/app-card";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CardContent } from "@/components/ui/card";
|
import { CardContent } from "@/components/ui/card";
|
||||||
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
|
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
|
||||||
import { pickText, portfolioItems, productItems } from "@/lib/site-data";
|
import {
|
||||||
|
getLocalizedValue,
|
||||||
|
getPublishedPortfolioProjects,
|
||||||
|
} from "@/lib/portfolio";
|
||||||
|
import { pickText, productItems } from "@/lib/site-data";
|
||||||
|
|
||||||
type HomePageProps = {
|
type HomePageProps = {
|
||||||
params: {
|
params: {
|
||||||
@@ -24,6 +28,8 @@ type HomePageProps = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params: { locale },
|
params: { locale },
|
||||||
}: HomePageProps): Promise<Metadata> {
|
}: HomePageProps): Promise<Metadata> {
|
||||||
@@ -40,7 +46,7 @@ export async function generateMetadata({
|
|||||||
|
|
||||||
export default async function HomePage({ params: { locale } }: HomePageProps) {
|
export default async function HomePage({ params: { locale } }: HomePageProps) {
|
||||||
const localeKey = resolveLocale(locale);
|
const localeKey = resolveLocale(locale);
|
||||||
const featuredProjects = portfolioItems.slice(0, 3);
|
const featuredProjects = (await getPublishedPortfolioProjects()).slice(0, 3);
|
||||||
const featuredProducts = productItems.slice(0, 3);
|
const featuredProducts = productItems.slice(0, 3);
|
||||||
const t = await getTranslations({ locale: localeKey, namespace: "homepage" });
|
const t = await getTranslations({ locale: localeKey, namespace: "homepage" });
|
||||||
|
|
||||||
@@ -109,13 +115,13 @@ export default async function HomePage({ params: { locale } }: HomePageProps) {
|
|||||||
className="group block"
|
className="group block"
|
||||||
>
|
>
|
||||||
<p className="text-sm text-muted-foreground/80">
|
<p className="text-sm text-muted-foreground/80">
|
||||||
{pickText(item.category, localeKey)} - {item.year}
|
{getLocalizedValue(item.category.name, localeKey)} - {item.projectYear}
|
||||||
</p>
|
</p>
|
||||||
<h3 className="mt-2 text-lg font-semibold text-foreground">
|
<h3 className="mt-2 text-lg font-semibold text-foreground">
|
||||||
{pickText(item.title, localeKey)}
|
{getLocalizedValue(item.title, localeKey)}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
{pickText(item.summary, localeKey)}
|
{getLocalizedValue(item.summary, localeKey)}
|
||||||
</p>
|
</p>
|
||||||
<span className="mt-4 inline-flex items-center gap-2 text-sm font-medium text-foreground/80 group-hover:text-foreground">
|
<span className="mt-4 inline-flex items-center gap-2 text-sm font-medium text-foreground/80 group-hover:text-foreground">
|
||||||
{t("toPortfolio")}
|
{t("toPortfolio")}
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { ArrowLeft, CalendarDays, FolderKanban, Tag } from "lucide-react";
|
import { ArrowLeft, ArrowUpRight, CalendarDays, FolderKanban, Tag, UserRound } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
import { Container } from "@/components/layout/container";
|
import { Container } from "@/components/layout/container";
|
||||||
import { MotionFade } from "@/components/motion-fade";
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
import { routing } from "@/i18n/routing";
|
|
||||||
import { buildLocalizedMetadata } from "@/lib/metadata";
|
import { buildLocalizedMetadata } from "@/lib/metadata";
|
||||||
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
|
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
|
||||||
import { getPortfolioItem, pickText, portfolioItems } from "@/lib/site-data";
|
import {
|
||||||
|
getLocalizedValue,
|
||||||
|
getPublishedPortfolioProjectBySlug,
|
||||||
|
} from "@/lib/portfolio";
|
||||||
import { AppCard } from "@/components/ui/app-card";
|
import { AppCard } from "@/components/ui/app-card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CardContent } from "@/components/ui/card";
|
import { CardContent } from "@/components/ui/card";
|
||||||
@@ -21,12 +24,30 @@ type PortfolioItemPageProps = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function generateStaticParams() {
|
export const dynamic = "force-dynamic";
|
||||||
return routing.locales.flatMap((locale) =>
|
|
||||||
portfolioItems.map((item) => ({
|
function PortfolioImage({
|
||||||
locale,
|
src,
|
||||||
slug: item.slug,
|
alt,
|
||||||
})),
|
className,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}: {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
className: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
unoptimized
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +55,7 @@ export async function generateMetadata({
|
|||||||
params: { locale, slug },
|
params: { locale, slug },
|
||||||
}: PortfolioItemPageProps): Promise<Metadata> {
|
}: PortfolioItemPageProps): Promise<Metadata> {
|
||||||
const localeKey = resolveLocale(locale);
|
const localeKey = resolveLocale(locale);
|
||||||
const item = getPortfolioItem(slug);
|
const item = await getPublishedPortfolioProjectBySlug(slug);
|
||||||
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return buildLocalizedMetadata({
|
return buildLocalizedMetadata({
|
||||||
@@ -48,8 +69,8 @@ export async function generateMetadata({
|
|||||||
return buildLocalizedMetadata({
|
return buildLocalizedMetadata({
|
||||||
locale: localeKey,
|
locale: localeKey,
|
||||||
pathname: `/portfolio/${slug}`,
|
pathname: `/portfolio/${slug}`,
|
||||||
title: pickText(item.title, localeKey),
|
title: getLocalizedValue(item.title, localeKey),
|
||||||
description: pickText(item.summary, localeKey),
|
description: getLocalizedValue(item.summary, localeKey),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +78,7 @@ export default async function PortfolioItemPage({
|
|||||||
params: { locale, slug },
|
params: { locale, slug },
|
||||||
}: PortfolioItemPageProps) {
|
}: PortfolioItemPageProps) {
|
||||||
const localeKey = resolveLocale(locale);
|
const localeKey = resolveLocale(locale);
|
||||||
const item = getPortfolioItem(slug);
|
const item = await getPublishedPortfolioProjectBySlug(slug);
|
||||||
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
notFound();
|
notFound();
|
||||||
@@ -78,25 +99,41 @@ export default async function PortfolioItemPage({
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<h1 className="mt-5 text-3xl font-semibold text-foreground sm:text-4xl">
|
<h1 className="mt-5 text-3xl font-semibold text-foreground sm:text-4xl">
|
||||||
{pickText(item.title, localeKey)}
|
{getLocalizedValue(item.title, localeKey)}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-4 text-base text-muted-foreground sm:text-lg">
|
<p className="mt-4 text-base text-muted-foreground sm:text-lg">
|
||||||
{pickText(item.summary, localeKey)}
|
{getLocalizedValue(item.summary, localeKey)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{item.coverImagePath ? (
|
||||||
|
<div className="mt-6 overflow-hidden rounded-surface border border-border">
|
||||||
|
<PortfolioImage
|
||||||
|
src={item.coverImagePath}
|
||||||
|
alt={getLocalizedValue(item.title, localeKey)}
|
||||||
|
width={1600}
|
||||||
|
height={900}
|
||||||
|
className="h-auto w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="mt-6 flex flex-wrap gap-3 text-sm text-foreground/80">
|
<div className="mt-6 flex flex-wrap gap-3 text-sm text-foreground/80">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
icon: Tag,
|
icon: Tag,
|
||||||
label: pickText(item.category, localeKey),
|
label: getLocalizedValue(item.category.name, localeKey),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: CalendarDays,
|
icon: CalendarDays,
|
||||||
label: item.year,
|
label: String(item.projectYear),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: FolderKanban,
|
icon: FolderKanban,
|
||||||
label: item.slug,
|
label: getLocalizedValue(item.serviceLabel, localeKey),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: UserRound,
|
||||||
|
label: item.clientName,
|
||||||
},
|
},
|
||||||
].map((meta) => {
|
].map((meta) => {
|
||||||
const Icon = meta.icon;
|
const Icon = meta.icon;
|
||||||
@@ -111,35 +148,90 @@ export default async function PortfolioItemPage({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{item.previewUrl ? (
|
||||||
|
<div className="mt-6">
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={item.previewUrl} target="_blank" rel="noreferrer">
|
||||||
|
{t("preview")}
|
||||||
|
<ArrowUpRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
</MotionFade>
|
</MotionFade>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
{[
|
{item.sections.map((section, index) => (
|
||||||
{
|
<MotionFade key={section.id} delay={0.05 * (index + 1)}>
|
||||||
title: t("challenge"),
|
|
||||||
text: t("challengeText"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("solution"),
|
|
||||||
text: t("solutionText"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("outcome"),
|
|
||||||
text: t("outcomeText"),
|
|
||||||
},
|
|
||||||
].map((section, index) => (
|
|
||||||
<MotionFade key={section.title} delay={0.05 * (index + 1)}>
|
|
||||||
<AppCard>
|
<AppCard>
|
||||||
<CardContent className="p-5">
|
<CardContent className="p-5">
|
||||||
<h2 className="text-lg font-semibold text-foreground">{section.title}</h2>
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
<p className="mt-2 text-sm text-muted-foreground">{section.text}</p>
|
{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-nested object-cover"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{section.linkUrl ? (
|
||||||
|
<Button asChild variant="outline" className="mt-4">
|
||||||
|
<Link href={section.linkUrl} target="_blank" rel="noreferrer">
|
||||||
|
{t("openLink")}
|
||||||
|
<ArrowUpRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
</MotionFade>
|
</MotionFade>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{item.assets.length > 0 ? (
|
||||||
|
<MotionFade delay={0.1}>
|
||||||
|
<AppCard>
|
||||||
|
<CardContent className="p-6 lg:p-8">
|
||||||
|
<h2 className="text-xl font-semibold text-foreground">{t("gallery")}</h2>
|
||||||
|
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||||
|
{item.assets.map((asset) => (
|
||||||
|
<div key={asset.id} className="overflow-hidden rounded-surface border border-border">
|
||||||
|
{asset.kind === "IMAGE" ? (
|
||||||
|
<PortfolioImage
|
||||||
|
src={asset.filePath}
|
||||||
|
alt={getLocalizedValue(asset.alt, localeKey)}
|
||||||
|
width={1200}
|
||||||
|
height={720}
|
||||||
|
className="h-64 w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-64 items-center justify-center bg-surface-1 p-6 text-center text-sm text-muted-foreground">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p>{getLocalizedValue(asset.alt, localeKey)}</p>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={asset.filePath} target="_blank" rel="noreferrer">
|
||||||
|
{t("download")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
</MotionFade>
|
||||||
|
) : null}
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,13 +9,22 @@ import { buildLocalizedMetadata } from "@/lib/metadata";
|
|||||||
import { AppCard } from "@/components/ui/app-card";
|
import { AppCard } from "@/components/ui/app-card";
|
||||||
import { CardContent } from "@/components/ui/card";
|
import { CardContent } from "@/components/ui/card";
|
||||||
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
|
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
|
||||||
import { pickText, portfolioItems } from "@/lib/site-data";
|
import {
|
||||||
|
getActivePortfolioCategories,
|
||||||
|
getLocalizedValue,
|
||||||
|
getPublishedPortfolioProjects,
|
||||||
|
} from "@/lib/portfolio";
|
||||||
|
|
||||||
type PortfolioPageProps = {
|
type PortfolioPageProps = {
|
||||||
params: {
|
params: {
|
||||||
locale: string;
|
locale: string;
|
||||||
};
|
};
|
||||||
|
searchParams?: {
|
||||||
|
category?: string;
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params: { locale },
|
params: { locale },
|
||||||
@@ -31,9 +40,19 @@ export async function generateMetadata({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function PortfolioPage({ params: { locale } }: PortfolioPageProps) {
|
export default async function PortfolioPage({
|
||||||
|
params: { locale },
|
||||||
|
searchParams,
|
||||||
|
}: PortfolioPageProps) {
|
||||||
const localeKey = resolveLocale(locale);
|
const localeKey = resolveLocale(locale);
|
||||||
const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" });
|
const t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" });
|
||||||
|
const selectedCategory = searchParams?.category ?? "";
|
||||||
|
const [categories, projects] = await Promise.all([
|
||||||
|
getActivePortfolioCategories(),
|
||||||
|
getPublishedPortfolioProjects({
|
||||||
|
categorySlug: selectedCategory || undefined,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="flex flex-col gap-section py-10 lg:py-14">
|
<Container className="flex flex-col gap-section py-10 lg:py-14">
|
||||||
@@ -53,8 +72,34 @@ export default async function PortfolioPage({ params: { locale } }: PortfolioPag
|
|||||||
</AppCard>
|
</AppCard>
|
||||||
</MotionFade>
|
</MotionFade>
|
||||||
|
|
||||||
|
<section className="flex flex-wrap gap-3">
|
||||||
|
<Link
|
||||||
|
href={getLocalizedPath(localeKey, "/portfolio")}
|
||||||
|
className={`rounded-pill border px-4 py-2 text-sm transition-colors ${
|
||||||
|
selectedCategory === ""
|
||||||
|
? "border-border-strong bg-foreground text-background"
|
||||||
|
: "border-border bg-background text-foreground/80 hover:border-border-strong hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("all")}
|
||||||
|
</Link>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<Link
|
||||||
|
key={category.id}
|
||||||
|
href={getLocalizedPath(localeKey, `/portfolio?category=${category.slug}`)}
|
||||||
|
className={`rounded-pill border px-4 py-2 text-sm transition-colors ${
|
||||||
|
selectedCategory === category.slug
|
||||||
|
? "border-border-strong bg-foreground text-background"
|
||||||
|
: "border-border bg-background text-foreground/80 hover:border-border-strong hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{getLocalizedValue(category.name, localeKey)}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-4 md:grid-cols-2">
|
<section className="grid gap-4 md:grid-cols-2">
|
||||||
{portfolioItems.map((item, index) => (
|
{projects.map((item, index) => (
|
||||||
<MotionFade key={item.slug} delay={index * 0.05}>
|
<MotionFade key={item.slug} delay={index * 0.05}>
|
||||||
<AppCard interactive>
|
<AppCard interactive>
|
||||||
<CardContent className="p-5">
|
<CardContent className="p-5">
|
||||||
@@ -64,18 +109,18 @@ export default async function PortfolioPage({ params: { locale } }: PortfolioPag
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<p className="text-sm text-muted-foreground/80">
|
<p className="text-sm text-muted-foreground/80">
|
||||||
{pickText(item.category, localeKey)}
|
{getLocalizedValue(item.category.name, localeKey)}
|
||||||
</p>
|
</p>
|
||||||
<p className="inline-flex items-center gap-1 text-xs text-muted-foreground/80">
|
<p className="inline-flex items-center gap-1 text-xs text-muted-foreground/80">
|
||||||
<CalendarDays className="h-3.5 w-3.5" />
|
<CalendarDays className="h-3.5 w-3.5" />
|
||||||
{item.year}
|
{item.projectYear}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="mt-3 text-xl font-semibold text-foreground">
|
<h2 className="mt-3 text-xl font-semibold text-foreground">
|
||||||
{pickText(item.title, localeKey)}
|
{getLocalizedValue(item.title, localeKey)}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
{pickText(item.summary, localeKey)}
|
{getLocalizedValue(item.summary, localeKey)}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-4 inline-flex items-center gap-2 text-sm font-medium text-foreground/80 group-hover:text-foreground">
|
<p className="mt-4 inline-flex items-center gap-2 text-sm font-medium text-foreground/80 group-hover:text-foreground">
|
||||||
{t("open")}
|
{t("open")}
|
||||||
@@ -86,6 +131,14 @@ export default async function PortfolioPage({ params: { locale } }: PortfolioPag
|
|||||||
</AppCard>
|
</AppCard>
|
||||||
</MotionFade>
|
</MotionFade>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<AppCard className="md:col-span-2">
|
||||||
|
<CardContent className="p-6 text-sm text-muted-foreground">
|
||||||
|
{t("empty")}
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ const copy = {
|
|||||||
overview: "Uebersicht",
|
overview: "Uebersicht",
|
||||||
maintenance: "Wartungsmodus",
|
maintenance: "Wartungsmodus",
|
||||||
uiKit: "UI Kit",
|
uiKit: "UI Kit",
|
||||||
|
media: "Media",
|
||||||
|
portfolio: "Portfolio",
|
||||||
maintenanceText: "Wenn aktiv, werden alle Seiten auf die Coming Soon Seite weitergeleitet.",
|
maintenanceText: "Wenn aktiv, werden alle Seiten auf die Coming Soon Seite weitergeleitet.",
|
||||||
maintenanceOn: "Aktiv",
|
maintenanceOn: "Aktiv",
|
||||||
maintenanceOff: "Inaktiv",
|
maintenanceOff: "Inaktiv",
|
||||||
|
|||||||
+46
-2
@@ -1,4 +1,4 @@
|
|||||||
import { ArrowLeft, ExternalLink, LockKeyhole, LogOut } from "lucide-react";
|
import { ArrowLeft, ExternalLink, ImageIcon, LockKeyhole, LogOut } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
@@ -49,6 +49,14 @@ const copy = {
|
|||||||
uiKitTitle: "UI Kit",
|
uiKitTitle: "UI Kit",
|
||||||
uiKitDescription: "Globale Referenz fuer Cards, Buttons, Inputs und Surface Levels.",
|
uiKitDescription: "Globale Referenz fuer Cards, Buttons, Inputs und Surface Levels.",
|
||||||
uiKitAction: "Zur UI Kit",
|
uiKitAction: "Zur UI Kit",
|
||||||
|
media: "Media",
|
||||||
|
portfolio: "Portfolio",
|
||||||
|
portfolioTitle: "Portfolio",
|
||||||
|
portfolioDescription: "Kategorien, Projekte, Sections und Assets verwalten.",
|
||||||
|
portfolioAction: "Zum Portfolio",
|
||||||
|
mediaTitle: "Media Library",
|
||||||
|
mediaDescription: "Uploads, externe URLs und Verwendungsorte zentral verwalten.",
|
||||||
|
mediaAction: "Zur Media Library",
|
||||||
loginTitle: "Root Login",
|
loginTitle: "Root Login",
|
||||||
loginText: "Nur autorisierte Nutzer duerfen diesen Bereich verwenden.",
|
loginText: "Nur autorisierte Nutzer duerfen diesen Bereich verwenden.",
|
||||||
passwordLabel: "Passwort",
|
passwordLabel: "Passwort",
|
||||||
@@ -204,7 +212,7 @@ export default async function RootPage({ searchParams }: RootPageProps) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
<section className="grid gap-4 lg:grid-cols-2">
|
<section className="grid gap-4 lg:grid-cols-4">
|
||||||
<MotionFade delay={0.05}>
|
<MotionFade delay={0.05}>
|
||||||
<AppCard level={2}>
|
<AppCard level={2}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -244,6 +252,42 @@ export default async function RootPage({ searchParams }: RootPageProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
</MotionFade>
|
</MotionFade>
|
||||||
|
|
||||||
|
<MotionFade delay={0.15}>
|
||||||
|
<AppCard level={2}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>{copy.portfolioTitle}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-lg font-semibold text-foreground">{copy.portfolioTitle}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{copy.portfolioDescription}</p>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/root/portfolio">
|
||||||
|
{copy.portfolioAction}
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
</MotionFade>
|
||||||
|
|
||||||
|
<MotionFade delay={0.2}>
|
||||||
|
<AppCard level={2}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>{copy.mediaTitle}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-lg font-semibold text-foreground">{copy.mediaTitle}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{copy.mediaDescription}</p>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/root/media">
|
||||||
|
{copy.mediaAction}
|
||||||
|
<ImageIcon className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
</MotionFade>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
|||||||
@@ -0,0 +1,591 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { MediaUsageType, Prisma } from "@prisma/client";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { ZodError } from "zod";
|
||||||
|
|
||||||
|
import { routing } from "@/i18n/routing";
|
||||||
|
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||||
|
import { deleteEntityMediaUsages, replaceEntityMediaUsages } from "@/lib/media";
|
||||||
|
import { resolveMediaSelection } from "@/lib/media-service";
|
||||||
|
import { getLocalizedPath } from "@/lib/locale";
|
||||||
|
import { removeManagedMediaFile } from "@/lib/media-storage";
|
||||||
|
import { mediaFieldInputSchema } from "@/lib/media-validation";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import {
|
||||||
|
assetInputSchema,
|
||||||
|
categoryInputSchema,
|
||||||
|
projectInputSchema,
|
||||||
|
sectionInputSchema,
|
||||||
|
} from "@/lib/portfolio-validation";
|
||||||
|
|
||||||
|
function ensureAdmin() {
|
||||||
|
if (!isAdminAuthenticated()) {
|
||||||
|
clearAdminSessionCookie();
|
||||||
|
redirect("/root");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRedirectPath(formData: FormData, fallbackPath: string) {
|
||||||
|
return String(formData.get("redirectPath") ?? fallbackPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function withMessage(pathname: string, type: "success" | "error", message: string) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set(type, message);
|
||||||
|
|
||||||
|
return `${pathname}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCheckboxValue(formData: FormData, key: string) {
|
||||||
|
return formData.get(key) === "on";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonArray(rawValue: FormDataEntryValue | null, key: string) {
|
||||||
|
if (typeof rawValue !== "string" || rawValue.trim() === "") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawValue);
|
||||||
|
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
throw new Error(`${key} must be an array.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Invalid ${key} payload.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonObject(rawValue: FormDataEntryValue | null, key: string) {
|
||||||
|
if (typeof rawValue !== "string" || rawValue.trim() === "") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawValue);
|
||||||
|
|
||||||
|
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
|
||||||
|
throw new Error(`${key} must be an object.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Invalid ${key} payload.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseZodError(error: ZodError) {
|
||||||
|
return error.issues[0]?.message ?? "Validation failed.";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revalidatePortfolioPages() {
|
||||||
|
revalidatePath("/root");
|
||||||
|
revalidatePath("/root/media");
|
||||||
|
revalidatePath("/root/portfolio");
|
||||||
|
revalidatePath("/root/portfolio/categories");
|
||||||
|
revalidatePath("/root/portfolio/projects");
|
||||||
|
revalidatePath("/portfolio");
|
||||||
|
|
||||||
|
for (const locale of routing.locales) {
|
||||||
|
revalidatePath(getLocalizedPath(locale, "/portfolio"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeManagedPaths(paths: string[]) {
|
||||||
|
for (const filePath of Array.from(new Set(paths.filter(Boolean)))) {
|
||||||
|
await removeManagedMediaFile(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertCategoryAction(formData: FormData) {
|
||||||
|
ensureAdmin();
|
||||||
|
|
||||||
|
const redirectPath = getRedirectPath(formData, "/root/portfolio/categories");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = categoryInputSchema.parse({
|
||||||
|
id: String(formData.get("id") ?? "").trim() || undefined,
|
||||||
|
slug: String(formData.get("slug") ?? ""),
|
||||||
|
nameAr: String(formData.get("nameAr") ?? ""),
|
||||||
|
nameEn: String(formData.get("nameEn") ?? ""),
|
||||||
|
nameDe: String(formData.get("nameDe") ?? ""),
|
||||||
|
descriptionAr: String(formData.get("descriptionAr") ?? ""),
|
||||||
|
descriptionEn: String(formData.get("descriptionEn") ?? ""),
|
||||||
|
descriptionDe: String(formData.get("descriptionDe") ?? ""),
|
||||||
|
sortOrder: String(formData.get("sortOrder") ?? "0"),
|
||||||
|
isActive: normalizeCheckboxValue(formData, "isActive"),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (parsed.id) {
|
||||||
|
await prisma.category.update({
|
||||||
|
where: {
|
||||||
|
id: parsed.id,
|
||||||
|
},
|
||||||
|
data: parsed,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.category.create({
|
||||||
|
data: parsed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await revalidatePortfolioPages();
|
||||||
|
redirect(withMessage(redirectPath, "success", "Category saved."));
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof ZodError
|
||||||
|
? parseZodError(error)
|
||||||
|
: error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002"
|
||||||
|
? "Category slug must be unique."
|
||||||
|
: "Unable to save category.";
|
||||||
|
|
||||||
|
redirect(withMessage(redirectPath, "error", message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCategoryAction(formData: FormData) {
|
||||||
|
ensureAdmin();
|
||||||
|
|
||||||
|
const redirectPath = getRedirectPath(formData, "/root/portfolio/categories");
|
||||||
|
const id = String(formData.get("id") ?? "");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const projectCount = await prisma.portfolioProject.count({
|
||||||
|
where: {
|
||||||
|
categoryId: id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (projectCount > 0) {
|
||||||
|
redirect(withMessage(redirectPath, "error", "Cannot delete a category with projects."));
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.category.delete({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await revalidatePortfolioPages();
|
||||||
|
redirect(withMessage(redirectPath, "success", "Category deleted."));
|
||||||
|
} catch {
|
||||||
|
redirect(withMessage(redirectPath, "error", "Unable to delete category."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveProjectAction(formData: FormData) {
|
||||||
|
ensureAdmin();
|
||||||
|
|
||||||
|
const fallbackRedirect = String(formData.get("id") ?? "").trim()
|
||||||
|
? `/root/portfolio/projects/${String(formData.get("id") ?? "").trim()}`
|
||||||
|
: "/root/portfolio/projects/new";
|
||||||
|
const redirectPath = getRedirectPath(formData, fallbackRedirect);
|
||||||
|
const uploadedPaths: string[] = [];
|
||||||
|
const createdMediaAssetIds: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sections = parseJsonArray(formData.get("sections"), "sections").map((section, index) =>
|
||||||
|
sectionInputSchema.parse({
|
||||||
|
...section,
|
||||||
|
media: section.media ? mediaFieldInputSchema.parse(section.media) : undefined,
|
||||||
|
sortOrder: section.sortOrder ?? index,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const assets = parseJsonArray(formData.get("assets"), "assets").map((asset, index) =>
|
||||||
|
assetInputSchema.parse({
|
||||||
|
...asset,
|
||||||
|
media: asset.media ? mediaFieldInputSchema.parse(asset.media) : undefined,
|
||||||
|
sortOrder: asset.sortOrder ?? index,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const coverMedia = parseJsonObject(formData.get("coverMedia"), "coverMedia");
|
||||||
|
|
||||||
|
const parsed = projectInputSchema.parse({
|
||||||
|
id: String(formData.get("id") ?? "").trim() || undefined,
|
||||||
|
categoryId: String(formData.get("categoryId") ?? ""),
|
||||||
|
slug: String(formData.get("slug") ?? ""),
|
||||||
|
titleAr: String(formData.get("titleAr") ?? ""),
|
||||||
|
titleEn: String(formData.get("titleEn") ?? ""),
|
||||||
|
titleDe: String(formData.get("titleDe") ?? ""),
|
||||||
|
summaryAr: String(formData.get("summaryAr") ?? ""),
|
||||||
|
summaryEn: String(formData.get("summaryEn") ?? ""),
|
||||||
|
summaryDe: String(formData.get("summaryDe") ?? ""),
|
||||||
|
clientName: String(formData.get("clientName") ?? ""),
|
||||||
|
projectYear: String(formData.get("projectYear") ?? ""),
|
||||||
|
serviceLabelAr: String(formData.get("serviceLabelAr") ?? ""),
|
||||||
|
serviceLabelEn: String(formData.get("serviceLabelEn") ?? ""),
|
||||||
|
serviceLabelDe: String(formData.get("serviceLabelDe") ?? ""),
|
||||||
|
previewUrl: String(formData.get("previewUrl") ?? ""),
|
||||||
|
currentCoverImagePath: String(formData.get("currentCoverImagePath") ?? ""),
|
||||||
|
coverMedia: coverMedia ? mediaFieldInputSchema.parse(coverMedia) : undefined,
|
||||||
|
sortOrder: String(formData.get("sortOrder") ?? "0"),
|
||||||
|
isFeatured: normalizeCheckboxValue(formData, "isFeatured"),
|
||||||
|
isPublished: normalizeCheckboxValue(formData, "isPublished"),
|
||||||
|
sections,
|
||||||
|
assets,
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingProject = parsed.id
|
||||||
|
? await prisma.portfolioProject.findUnique({
|
||||||
|
where: {
|
||||||
|
id: parsed.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
isPublished: true,
|
||||||
|
publishedAt: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const shouldPublishNow = parsed.isPublished && !existingProject?.publishedAt;
|
||||||
|
|
||||||
|
const coverSelection = await resolveMediaSelection({
|
||||||
|
media: parsed.coverMedia,
|
||||||
|
uploadFile: formData.get("coverFile"),
|
||||||
|
folder: "covers",
|
||||||
|
fallbackLabel: parsed.titleDe || parsed.titleEn || parsed.titleAr || parsed.slug,
|
||||||
|
required: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (coverSelection.createdAssetId) {
|
||||||
|
createdMediaAssetIds.push(coverSelection.createdAssetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coverSelection.uploadedUrl) {
|
||||||
|
uploadedPaths.push(coverSelection.uploadedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionRows: Array<{
|
||||||
|
type: (typeof parsed.sections)[number]["type"];
|
||||||
|
titleAr: string;
|
||||||
|
titleEn: string;
|
||||||
|
titleDe: string;
|
||||||
|
bodyAr: string;
|
||||||
|
bodyEn: string;
|
||||||
|
bodyDe: string;
|
||||||
|
imagePath: string | null;
|
||||||
|
imageAssetId: string | null;
|
||||||
|
linkUrl: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < parsed.sections.length; index += 1) {
|
||||||
|
const section = parsed.sections[index];
|
||||||
|
const sectionSelection = await resolveMediaSelection({
|
||||||
|
media: section.media,
|
||||||
|
uploadFile: formData.get(`section-image-upload-${index}`),
|
||||||
|
folder: "sections",
|
||||||
|
fallbackLabel: section.titleDe || section.titleEn || section.titleAr || `section-${index + 1}`,
|
||||||
|
required: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sectionSelection.createdAssetId) {
|
||||||
|
createdMediaAssetIds.push(sectionSelection.createdAssetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sectionSelection.uploadedUrl) {
|
||||||
|
uploadedPaths.push(sectionSelection.uploadedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
sectionRows.push({
|
||||||
|
type: section.type,
|
||||||
|
titleAr: section.titleAr,
|
||||||
|
titleEn: section.titleEn,
|
||||||
|
titleDe: section.titleDe,
|
||||||
|
bodyAr: section.bodyAr,
|
||||||
|
bodyEn: section.bodyEn,
|
||||||
|
bodyDe: section.bodyDe,
|
||||||
|
imagePath: sectionSelection.url || null,
|
||||||
|
imageAssetId: sectionSelection.assetId,
|
||||||
|
linkUrl: section.linkUrl || null,
|
||||||
|
sortOrder: index,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetRows: Array<{
|
||||||
|
kind: (typeof parsed.assets)[number]["kind"];
|
||||||
|
filePath: string;
|
||||||
|
mediaAssetId: string | null;
|
||||||
|
altAr: string;
|
||||||
|
altEn: string;
|
||||||
|
altDe: string;
|
||||||
|
sortOrder: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < parsed.assets.length; index += 1) {
|
||||||
|
const asset = parsed.assets[index];
|
||||||
|
const assetSelection = await resolveMediaSelection({
|
||||||
|
media: asset.media,
|
||||||
|
uploadFile: asset.fileFieldName ? formData.get(asset.fileFieldName) : null,
|
||||||
|
folder: "assets",
|
||||||
|
fallbackLabel: asset.altDe || asset.altEn || asset.altAr || `asset-${index + 1}`,
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!assetSelection.url) {
|
||||||
|
throw new Error("Each asset row needs either an existing file or a new upload.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assetSelection.createdAssetId) {
|
||||||
|
createdMediaAssetIds.push(assetSelection.createdAssetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assetSelection.uploadedUrl) {
|
||||||
|
uploadedPaths.push(assetSelection.uploadedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
assetRows.push({
|
||||||
|
kind: asset.kind,
|
||||||
|
filePath: assetSelection.url,
|
||||||
|
mediaAssetId: assetSelection.assetId,
|
||||||
|
altAr: asset.altAr,
|
||||||
|
altEn: asset.altEn,
|
||||||
|
altDe: asset.altDe,
|
||||||
|
sortOrder: index,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectResult = await prisma.$transaction(async (tx) => {
|
||||||
|
const currentProject = parsed.id
|
||||||
|
? await tx.portfolioProject.update({
|
||||||
|
where: {
|
||||||
|
id: parsed.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
categoryId: parsed.categoryId,
|
||||||
|
slug: parsed.slug,
|
||||||
|
titleAr: parsed.titleAr,
|
||||||
|
titleEn: parsed.titleEn,
|
||||||
|
titleDe: parsed.titleDe,
|
||||||
|
summaryAr: parsed.summaryAr,
|
||||||
|
summaryEn: parsed.summaryEn,
|
||||||
|
summaryDe: parsed.summaryDe,
|
||||||
|
clientName: parsed.clientName,
|
||||||
|
projectYear: parsed.projectYear,
|
||||||
|
serviceLabelAr: parsed.serviceLabelAr,
|
||||||
|
serviceLabelEn: parsed.serviceLabelEn,
|
||||||
|
serviceLabelDe: parsed.serviceLabelDe,
|
||||||
|
previewUrl: parsed.previewUrl || null,
|
||||||
|
coverImagePath: coverSelection.url || null,
|
||||||
|
isFeatured: parsed.isFeatured,
|
||||||
|
isPublished: parsed.isPublished,
|
||||||
|
publishedAt: parsed.isPublished
|
||||||
|
? shouldPublishNow
|
||||||
|
? new Date()
|
||||||
|
: existingProject?.publishedAt ?? new Date()
|
||||||
|
: null,
|
||||||
|
sortOrder: parsed.sortOrder,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: await tx.portfolioProject.create({
|
||||||
|
data: {
|
||||||
|
categoryId: parsed.categoryId,
|
||||||
|
slug: parsed.slug,
|
||||||
|
titleAr: parsed.titleAr,
|
||||||
|
titleEn: parsed.titleEn,
|
||||||
|
titleDe: parsed.titleDe,
|
||||||
|
summaryAr: parsed.summaryAr,
|
||||||
|
summaryEn: parsed.summaryEn,
|
||||||
|
summaryDe: parsed.summaryDe,
|
||||||
|
clientName: parsed.clientName,
|
||||||
|
projectYear: parsed.projectYear,
|
||||||
|
serviceLabelAr: parsed.serviceLabelAr,
|
||||||
|
serviceLabelEn: parsed.serviceLabelEn,
|
||||||
|
serviceLabelDe: parsed.serviceLabelDe,
|
||||||
|
previewUrl: parsed.previewUrl || null,
|
||||||
|
coverImagePath: coverSelection.url || null,
|
||||||
|
isFeatured: parsed.isFeatured,
|
||||||
|
isPublished: parsed.isPublished,
|
||||||
|
publishedAt: parsed.isPublished ? new Date() : null,
|
||||||
|
sortOrder: parsed.sortOrder,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.portfolioSection.deleteMany({
|
||||||
|
where: {
|
||||||
|
projectId: currentProject.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.portfolioAsset.deleteMany({
|
||||||
|
where: {
|
||||||
|
projectId: currentProject.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdSections = [];
|
||||||
|
|
||||||
|
for (const section of sectionRows) {
|
||||||
|
const createdSection = await tx.portfolioSection.create({
|
||||||
|
data: {
|
||||||
|
projectId: currentProject.id,
|
||||||
|
type: section.type,
|
||||||
|
titleAr: section.titleAr,
|
||||||
|
titleEn: section.titleEn,
|
||||||
|
titleDe: section.titleDe,
|
||||||
|
bodyAr: section.bodyAr,
|
||||||
|
bodyEn: section.bodyEn,
|
||||||
|
bodyDe: section.bodyDe,
|
||||||
|
imagePath: section.imagePath || null,
|
||||||
|
linkUrl: section.linkUrl || null,
|
||||||
|
sortOrder: section.sortOrder,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
createdSections.push(createdSection);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAssets = [];
|
||||||
|
|
||||||
|
for (const asset of assetRows) {
|
||||||
|
const createdAsset = await tx.portfolioAsset.create({
|
||||||
|
data: {
|
||||||
|
projectId: currentProject.id,
|
||||||
|
kind: asset.kind,
|
||||||
|
filePath: asset.filePath,
|
||||||
|
altAr: asset.altAr,
|
||||||
|
altEn: asset.altEn,
|
||||||
|
altDe: asset.altDe,
|
||||||
|
sortOrder: asset.sortOrder,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
createdAssets.push(createdAsset);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
project: currentProject,
|
||||||
|
createdSections,
|
||||||
|
createdAssets,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await replaceEntityMediaUsages({
|
||||||
|
entityType: "portfolio-project",
|
||||||
|
entityId: projectResult.project.id,
|
||||||
|
usages: [
|
||||||
|
...(coverSelection.assetId
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
assetId: coverSelection.assetId,
|
||||||
|
usageType: MediaUsageType.PORTFOLIO_COVER,
|
||||||
|
fieldKey: "cover",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...projectResult.createdSections.flatMap((section, index) =>
|
||||||
|
sectionRows[index]?.imageAssetId
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
assetId: sectionRows[index].imageAssetId as string,
|
||||||
|
usageType: MediaUsageType.PORTFOLIO_SECTION,
|
||||||
|
fieldKey: section.id,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
...projectResult.createdAssets.flatMap((asset, index) =>
|
||||||
|
assetRows[index]?.mediaAssetId
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
assetId: assetRows[index].mediaAssetId as string,
|
||||||
|
usageType: MediaUsageType.PORTFOLIO_ASSET,
|
||||||
|
fieldKey: asset.id,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await revalidatePortfolioPages();
|
||||||
|
revalidatePath(`/root/portfolio/projects/${projectResult.project.id}`);
|
||||||
|
revalidatePath(`/portfolio/${projectResult.project.slug}`);
|
||||||
|
|
||||||
|
for (const locale of routing.locales) {
|
||||||
|
revalidatePath(getLocalizedPath(locale, `/portfolio/${projectResult.project.slug}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(
|
||||||
|
withMessage(`/root/portfolio/projects/${projectResult.project.id}`, "success", "Project saved."),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof ZodError
|
||||||
|
? parseZodError(error)
|
||||||
|
: error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002"
|
||||||
|
? "Project slug must be unique."
|
||||||
|
: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Unable to save project.";
|
||||||
|
|
||||||
|
await removeManagedPaths(uploadedPaths);
|
||||||
|
if (createdMediaAssetIds.length > 0) {
|
||||||
|
await prisma.mediaUsage.deleteMany({
|
||||||
|
where: {
|
||||||
|
assetId: {
|
||||||
|
in: createdMediaAssetIds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.mediaAsset.deleteMany({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
in: createdMediaAssetIds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
redirect(withMessage(redirectPath, "error", message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProjectAction(formData: FormData) {
|
||||||
|
ensureAdmin();
|
||||||
|
|
||||||
|
const id = String(formData.get("id") ?? "");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const project = await prisma.portfolioProject.findUnique({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
slug: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
redirect(withMessage("/root/portfolio/projects", "error", "Project not found."));
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectPaths = collectUniqueManagedPaths([
|
||||||
|
project.coverImagePath,
|
||||||
|
...project.sections.map((section) => section.imagePath),
|
||||||
|
...project.assets.map((asset) => asset.filePath),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await prisma.portfolioProject.delete({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await deleteEntityMediaUsages("portfolio-project", id);
|
||||||
|
|
||||||
|
await revalidatePortfolioPages();
|
||||||
|
revalidatePath(`/portfolio/${project.slug}`);
|
||||||
|
|
||||||
|
for (const locale of routing.locales) {
|
||||||
|
revalidatePath(getLocalizedPath(locale, `/portfolio/${project.slug}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect(withMessage("/root/portfolio/projects", "success", "Project deleted."));
|
||||||
|
} catch {
|
||||||
|
redirect(withMessage("/root/portfolio/projects", "error", "Unable to delete project."));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { PortfolioSubnav } from "@/components/root/portfolio-subnav";
|
||||||
|
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 { 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";
|
||||||
|
|
||||||
|
import { deleteCategoryAction, upsertCategoryAction } from "../actions";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const locales = [
|
||||||
|
{ key: "Ar", label: "Arabic", hint: "الواجهة العربية" },
|
||||||
|
{ key: "En", label: "English", hint: "English website" },
|
||||||
|
{ key: "De", label: "German", hint: "Deutsche Website" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const copy = {
|
||||||
|
title: "Portfolio Kategorien",
|
||||||
|
subtitle: "Kategorien fuer Portfolio Projekte verwalten.",
|
||||||
|
overview: "Uebersicht",
|
||||||
|
maintenance: "Wartungsmodus",
|
||||||
|
uiKit: "UI Kit",
|
||||||
|
media: "Media",
|
||||||
|
portfolio: "Portfolio",
|
||||||
|
logout: "Ausloggen",
|
||||||
|
backToSite: "Zur Website",
|
||||||
|
};
|
||||||
|
|
||||||
|
type RootPortfolioCategoriesPageProps = {
|
||||||
|
searchParams?: {
|
||||||
|
success?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function RootPortfolioCategoriesPage({
|
||||||
|
searchParams,
|
||||||
|
}: RootPortfolioCategoriesPageProps) {
|
||||||
|
if (!isAdminAuthenticated()) {
|
||||||
|
redirect("/root");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logoutAction() {
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
clearAdminSessionCookie();
|
||||||
|
redirect("/root");
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = await getAdminPortfolioCategories();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RootDashboardShell
|
||||||
|
copy={copy}
|
||||||
|
active="portfolio"
|
||||||
|
portfolioChild="categories"
|
||||||
|
logoutAction={logoutAction}
|
||||||
|
headerTitle={copy.title}
|
||||||
|
headerDescription={copy.subtitle}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PortfolioSubnav active="categories" />
|
||||||
|
|
||||||
|
{searchParams?.success ? (
|
||||||
|
<p className="rounded-nested border border-status-success/30 bg-status-success/10 px-4 py-3 text-sm text-status-success">
|
||||||
|
{searchParams.success}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{searchParams?.error ? (
|
||||||
|
<p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||||
|
{searchParams.error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<AppCard>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Neue Kategorie</CardTitle>
|
||||||
|
<CardDescription>Eine Kategorie wird genau einem oder mehreren Projekten zugeordnet.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form action={upsertCategoryAction} className="grid gap-4 md:grid-cols-2">
|
||||||
|
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="create-slug">Slug</Label>
|
||||||
|
<Input id="create-slug" name="slug" required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="create-sortOrder">Sort Order</Label>
|
||||||
|
<Input id="create-sortOrder" name="sortOrder" type="number" min="0" defaultValue="0" required />
|
||||||
|
</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-surface border border-border p-4 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}`}>{`Name ${locale.label}`}</Label>
|
||||||
|
<Input id={`create-name-${locale.key}`} name={`name${locale.key}`} required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<Label htmlFor={`create-description-${locale.key}`}>{`Description ${locale.label}`}</Label>
|
||||||
|
<Textarea
|
||||||
|
id={`create-description-${locale.key}`}
|
||||||
|
name={`description${locale.key}`}
|
||||||
|
rows={4}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 rounded-nested border border-border px-4 py-3 text-sm md:col-span-2">
|
||||||
|
<input type="checkbox" name="isActive" defaultChecked />
|
||||||
|
Active
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Button type="submit">Save Category</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{categories.map((category) => (
|
||||||
|
<AppCard key={category.id}>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<form action={upsertCategoryAction} className="grid gap-4 md:grid-cols-2">
|
||||||
|
<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}`}>Slug</Label>
|
||||||
|
<Input id={`slug-${category.id}`} name="slug" defaultValue={category.slug} required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`sortOrder-${category.id}`}>Sort Order</Label>
|
||||||
|
<Input
|
||||||
|
id={`sortOrder-${category.id}`}
|
||||||
|
name="sortOrder"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
defaultValue={category.sortOrder}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</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-surface border border-border p-4 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}`}>{`Name ${locale.label}`}</Label>
|
||||||
|
<Input
|
||||||
|
id={`${nameKey}-${category.id}`}
|
||||||
|
name={nameKey}
|
||||||
|
defaultValue={category.name[lowerLocale]}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<Label htmlFor={`${descriptionKey}-${category.id}`}>{`Description ${locale.label}`}</Label>
|
||||||
|
<Textarea
|
||||||
|
id={`${descriptionKey}-${category.id}`}
|
||||||
|
name={descriptionKey}
|
||||||
|
rows={4}
|
||||||
|
defaultValue={category.description[lowerLocale]}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 rounded-nested border border-border px-4 py-3 text-sm">
|
||||||
|
<input type="checkbox" name="isActive" defaultChecked={category.isActive} />
|
||||||
|
Active
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex items-end justify-between gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">{category.projectCount} projects</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button type="submit">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}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RootDashboardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function RootPortfolioMediaRedirectPage() {
|
||||||
|
redirect("/root/media");
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { Boxes, FolderKanban, ImageIcon, Layers3, Plus, Tags } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { PortfolioSubnav } from "@/components/root/portfolio-subnav";
|
||||||
|
import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
|
||||||
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
|
import { AppCard } from "@/components/ui/app-card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||||
|
import {
|
||||||
|
getAdminPortfolioCategories,
|
||||||
|
getAdminPortfolioProjects,
|
||||||
|
} from "@/lib/portfolio";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const copy = {
|
||||||
|
title: "Portfolio",
|
||||||
|
subtitle: "Verwaltung fuer Kategorien, Projekte und Inhalte.",
|
||||||
|
overview: "Uebersicht",
|
||||||
|
maintenance: "Wartungsmodus",
|
||||||
|
uiKit: "UI Kit",
|
||||||
|
portfolio: "Portfolio",
|
||||||
|
logout: "Ausloggen",
|
||||||
|
backToSite: "Zur Website",
|
||||||
|
totalCategories: "Kategorien",
|
||||||
|
totalProjects: "Projekte",
|
||||||
|
publishedProjects: "Veroeffentlicht",
|
||||||
|
categoriesAction: "Kategorien verwalten",
|
||||||
|
projectsAction: "Projekte verwalten",
|
||||||
|
newProject: "Neues Projekt",
|
||||||
|
newCategory: "Neue Kategorie",
|
||||||
|
media: "Media",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function RootPortfolioPage() {
|
||||||
|
if (!isAdminAuthenticated()) {
|
||||||
|
redirect("/root");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logoutAction() {
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
clearAdminSessionCookie();
|
||||||
|
redirect("/root");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [categories, projects] = await Promise.all([
|
||||||
|
getAdminPortfolioCategories(),
|
||||||
|
getAdminPortfolioProjects(),
|
||||||
|
]);
|
||||||
|
const publishedProjects = projects.filter((project) => project.isPublished).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RootDashboardShell
|
||||||
|
copy={copy}
|
||||||
|
active="portfolio"
|
||||||
|
portfolioChild="overview"
|
||||||
|
logoutAction={logoutAction}
|
||||||
|
headerTitle={copy.title}
|
||||||
|
headerDescription={copy.subtitle}
|
||||||
|
headerActions={
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PortfolioSubnav active="overview" />
|
||||||
|
|
||||||
|
<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={index * 0.05}>
|
||||||
|
<AppCard level={2}>
|
||||||
|
<CardContent className="flex items-center gap-4 p-6">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-pill bg-surface-1">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<section className="grid gap-4 lg:grid-cols-3">
|
||||||
|
<AppCard>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{copy.totalCategories}</CardTitle>
|
||||||
|
<CardDescription>Sortieren, aktivieren und neue Portfolio Gruppen anlegen.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/root/portfolio/categories">{copy.categoriesAction}</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<AppCard>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{copy.totalProjects}</CardTitle>
|
||||||
|
<CardDescription>Drafts, veroeffentlichte Projekte und flexible Sections pflegen.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex gap-3">
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/root/portfolio/projects">{copy.projectsAction}</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/root/portfolio/projects/new">{copy.newProject}</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<AppCard>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{copy.media}</CardTitle>
|
||||||
|
<CardDescription>Alle Cover und Projektdateien an einem Ort pruefen.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/root/media">
|
||||||
|
<ImageIcon className="h-4 w-4" />
|
||||||
|
{copy.media}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</RootDashboardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { PortfolioProjectForm } from "@/components/root/portfolio-project-form";
|
||||||
|
import { PortfolioSubnav } from "@/components/root/portfolio-subnav";
|
||||||
|
import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
|
||||||
|
import { AppCard } from "@/components/ui/app-card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { CardContent } from "@/components/ui/card";
|
||||||
|
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||||
|
import { getMediaOptions } from "@/lib/media";
|
||||||
|
import {
|
||||||
|
getActivePortfolioCategories,
|
||||||
|
getAdminPortfolioProjectById,
|
||||||
|
} from "@/lib/portfolio";
|
||||||
|
|
||||||
|
import { deleteProjectAction, saveProjectAction } from "../../actions";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const copy = {
|
||||||
|
title: "Portfolio Projekt bearbeiten",
|
||||||
|
subtitle: "Projektstatus, Inhalte und Dateien anpassen.",
|
||||||
|
overview: "Uebersicht",
|
||||||
|
maintenance: "Wartungsmodus",
|
||||||
|
uiKit: "UI Kit",
|
||||||
|
media: "Media",
|
||||||
|
portfolio: "Portfolio",
|
||||||
|
logout: "Ausloggen",
|
||||||
|
backToSite: "Zur Website",
|
||||||
|
};
|
||||||
|
|
||||||
|
type RootPortfolioProjectPageProps = {
|
||||||
|
params: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
searchParams?: {
|
||||||
|
success?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function RootPortfolioProjectPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: RootPortfolioProjectPageProps) {
|
||||||
|
if (!isAdminAuthenticated()) {
|
||||||
|
redirect("/root");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logoutAction() {
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
clearAdminSessionCookie();
|
||||||
|
redirect("/root");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [categories, mediaOptions, project] = await Promise.all([
|
||||||
|
getActivePortfolioCategories(),
|
||||||
|
getMediaOptions(),
|
||||||
|
getAdminPortfolioProjectById(params.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
redirect("/root/portfolio/projects?error=Project+not+found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RootDashboardShell
|
||||||
|
copy={copy}
|
||||||
|
active="portfolio"
|
||||||
|
portfolioChild="projects"
|
||||||
|
logoutAction={logoutAction}
|
||||||
|
headerTitle={copy.title}
|
||||||
|
headerDescription={copy.subtitle}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PortfolioSubnav active="projects" />
|
||||||
|
|
||||||
|
{searchParams?.success ? (
|
||||||
|
<p className="rounded-nested border border-status-success/30 bg-status-success/10 px-4 py-3 text-sm text-status-success">
|
||||||
|
{searchParams.success}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{searchParams?.error ? (
|
||||||
|
<p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||||
|
{searchParams.error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<PortfolioProjectForm
|
||||||
|
action={saveProjectAction}
|
||||||
|
categories={categories}
|
||||||
|
mediaOptions={mediaOptions}
|
||||||
|
project={project}
|
||||||
|
redirectPath={`/root/portfolio/projects/${project.id}`}
|
||||||
|
submitLabel="Save Project"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AppCard>
|
||||||
|
<CardContent className="flex items-center justify-between gap-4 p-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">Danger Zone</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Project records are deleted from the database. Uploaded files stay on disk.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form action={deleteProjectAction}>
|
||||||
|
<input type="hidden" name="id" value={project.id} />
|
||||||
|
<Button type="submit" variant="destructive">
|
||||||
|
Delete Project
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
</div>
|
||||||
|
</RootDashboardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { PortfolioProjectForm } from "@/components/root/portfolio-project-form";
|
||||||
|
import { PortfolioSubnav } from "@/components/root/portfolio-subnav";
|
||||||
|
import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
|
||||||
|
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||||
|
import { getMediaOptions } from "@/lib/media";
|
||||||
|
import { getActivePortfolioCategories } from "@/lib/portfolio";
|
||||||
|
|
||||||
|
import { saveProjectAction } from "../../actions";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const copy = {
|
||||||
|
title: "Neues Portfolio Projekt",
|
||||||
|
subtitle: "Projekt mit Kategorie, Sections und Assets anlegen.",
|
||||||
|
overview: "Uebersicht",
|
||||||
|
maintenance: "Wartungsmodus",
|
||||||
|
uiKit: "UI Kit",
|
||||||
|
media: "Media",
|
||||||
|
portfolio: "Portfolio",
|
||||||
|
logout: "Ausloggen",
|
||||||
|
backToSite: "Zur Website",
|
||||||
|
};
|
||||||
|
|
||||||
|
type RootNewPortfolioProjectPageProps = {
|
||||||
|
searchParams?: {
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function RootNewPortfolioProjectPage({
|
||||||
|
searchParams,
|
||||||
|
}: RootNewPortfolioProjectPageProps) {
|
||||||
|
if (!isAdminAuthenticated()) {
|
||||||
|
redirect("/root");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logoutAction() {
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
clearAdminSessionCookie();
|
||||||
|
redirect("/root");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [categories, mediaOptions] = await Promise.all([
|
||||||
|
getActivePortfolioCategories(),
|
||||||
|
getMediaOptions(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RootDashboardShell
|
||||||
|
copy={copy}
|
||||||
|
active="portfolio"
|
||||||
|
portfolioChild="new-project"
|
||||||
|
logoutAction={logoutAction}
|
||||||
|
headerTitle={copy.title}
|
||||||
|
headerDescription={copy.subtitle}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PortfolioSubnav active="projects" />
|
||||||
|
|
||||||
|
{searchParams?.error ? (
|
||||||
|
<p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||||
|
{searchParams.error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<PortfolioProjectForm
|
||||||
|
action={saveProjectAction}
|
||||||
|
categories={categories}
|
||||||
|
mediaOptions={mediaOptions}
|
||||||
|
redirectPath="/root/portfolio/projects/new"
|
||||||
|
submitLabel="Create Project"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</RootDashboardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { PortfolioSubnav } from "@/components/root/portfolio-subnav";
|
||||||
|
import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
|
||||||
|
import { AppCard } from "@/components/ui/app-card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||||
|
import {
|
||||||
|
getAdminPortfolioCategories,
|
||||||
|
getAdminPortfolioProjects,
|
||||||
|
getLocalizedValue,
|
||||||
|
} from "@/lib/portfolio";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const copy = {
|
||||||
|
title: "Portfolio Projekte",
|
||||||
|
subtitle: "Alle Projekte mit Status, Kategorie und Reihenfolge.",
|
||||||
|
overview: "Uebersicht",
|
||||||
|
maintenance: "Wartungsmodus",
|
||||||
|
uiKit: "UI Kit",
|
||||||
|
media: "Media",
|
||||||
|
portfolio: "Portfolio",
|
||||||
|
logout: "Ausloggen",
|
||||||
|
backToSite: "Zur Website",
|
||||||
|
newProject: "Neues Projekt",
|
||||||
|
};
|
||||||
|
|
||||||
|
type RootPortfolioProjectsPageProps = {
|
||||||
|
searchParams?: {
|
||||||
|
category?: string;
|
||||||
|
status?: "all" | "draft" | "published";
|
||||||
|
success?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function RootPortfolioProjectsPage({
|
||||||
|
searchParams,
|
||||||
|
}: RootPortfolioProjectsPageProps) {
|
||||||
|
if (!isAdminAuthenticated()) {
|
||||||
|
redirect("/root");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logoutAction() {
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
clearAdminSessionCookie();
|
||||||
|
redirect("/root");
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedStatus = searchParams?.status === "draft" || searchParams?.status === "published"
|
||||||
|
? searchParams.status
|
||||||
|
: "all";
|
||||||
|
const [categories, projects] = await Promise.all([
|
||||||
|
getAdminPortfolioCategories(),
|
||||||
|
getAdminPortfolioProjects({
|
||||||
|
categoryId: searchParams?.category || undefined,
|
||||||
|
status: selectedStatus,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RootDashboardShell
|
||||||
|
copy={copy}
|
||||||
|
active="portfolio"
|
||||||
|
portfolioChild="projects"
|
||||||
|
logoutAction={logoutAction}
|
||||||
|
headerTitle={copy.title}
|
||||||
|
headerDescription={copy.subtitle}
|
||||||
|
headerActions={
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/root/portfolio/projects/new">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
{copy.newProject}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PortfolioSubnav active="projects" />
|
||||||
|
|
||||||
|
{searchParams?.success ? (
|
||||||
|
<p className="rounded-nested border border-status-success/30 bg-status-success/10 px-4 py-3 text-sm text-status-success">
|
||||||
|
{searchParams.success}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{searchParams?.error ? (
|
||||||
|
<p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||||
|
{searchParams.error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<AppCard>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<form className="grid gap-4 md:grid-cols-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="category" className="text-sm font-medium text-foreground">
|
||||||
|
Category
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="category"
|
||||||
|
name="category"
|
||||||
|
defaultValue={searchParams?.category ?? ""}
|
||||||
|
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
|
||||||
|
>
|
||||||
|
<option value="">All</option>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<option key={category.id} value={category.id}>
|
||||||
|
{category.name.de}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="status" className="text-sm font-medium text-foreground">
|
||||||
|
Status
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="status"
|
||||||
|
name="status"
|
||||||
|
defaultValue={selectedStatus}
|
||||||
|
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
|
||||||
|
>
|
||||||
|
<option value="all">All</option>
|
||||||
|
<option value="draft">Draft</option>
|
||||||
|
<option value="published">Published</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-end">
|
||||||
|
<Button type="submit" variant="outline">
|
||||||
|
Filter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<AppCard key={project.id} interactive>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<CardTitle>{getLocalizedValue(project.title, "de")}</CardTitle>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{project.category.name.de}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className="rounded-pill border border-border px-3 py-1">
|
||||||
|
{project.isPublished ? "Published" : "Draft"}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-pill border border-border px-3 py-1">
|
||||||
|
{project.projectYear}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-pill border border-border px-3 py-1">
|
||||||
|
Sort {project.sortOrder}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div className="space-y-1 text-sm text-muted-foreground">
|
||||||
|
<p>{project.slug}</p>
|
||||||
|
<p>{project.previewUrl ? "Preview link set" : "No preview link"}</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={`/root/portfolio/projects/${project.id}`}>Edit Project</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<AppCard>
|
||||||
|
<CardContent className="p-6 text-sm text-muted-foreground">
|
||||||
|
No projects match the selected filters.
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RootDashboardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ const copy = {
|
|||||||
maintenance: "Wartungsmodus",
|
maintenance: "Wartungsmodus",
|
||||||
overview: "Uebersicht",
|
overview: "Uebersicht",
|
||||||
uiKit: "UI Kit",
|
uiKit: "UI Kit",
|
||||||
|
media: "Media",
|
||||||
|
portfolio: "Portfolio",
|
||||||
logout: "Ausloggen",
|
logout: "Ausloggen",
|
||||||
backToSite: "Zur Website",
|
backToSite: "Zur Website",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type SidebarItem = {
|
|||||||
href: string;
|
href: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
children?: SidebarItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type AppSidebarProps = {
|
type AppSidebarProps = {
|
||||||
@@ -37,8 +38,8 @@ export function AppSidebar({
|
|||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div key={item.label} className="space-y-1">
|
||||||
<Link
|
<Link
|
||||||
key={item.label}
|
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 rounded-nested px-3 py-2 text-sm transition-colors",
|
"flex items-center gap-2 rounded-nested px-3 py-2 text-sm transition-colors",
|
||||||
@@ -50,6 +51,31 @@ export function AppSidebar({
|
|||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{item.children?.length ? (
|
||||||
|
<div className="ml-3 space-y-1 border-l border-sidebar-border pl-3">
|
||||||
|
{item.children.map((child) => {
|
||||||
|
const ChildIcon = child.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={child.label}
|
||||||
|
href={child.href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 rounded-nested px-3 py-2 text-sm transition-colors",
|
||||||
|
child.active
|
||||||
|
? "bg-sidebar-primary text-sidebar-primary-foreground"
|
||||||
|
: "text-sidebar-foreground/72 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChildIcon className="h-4 w-4" />
|
||||||
|
<span>{child.label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -0,0 +1,660 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { MediaKind, PortfolioAssetKind, PortfolioSectionType } from "@prisma/client";
|
||||||
|
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { MediaFieldPicker, type MediaFieldState } from "@/components/root/media-field-picker";
|
||||||
|
import { AppCard } from "@/components/ui/app-card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
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 { moveArrayItem } from "@/lib/array";
|
||||||
|
import type { MediaOption } from "@/lib/media";
|
||||||
|
import type { PortfolioCategoryView, PortfolioProjectView } from "@/lib/portfolio";
|
||||||
|
|
||||||
|
type SectionFormValue = {
|
||||||
|
id?: string;
|
||||||
|
type: PortfolioSectionType;
|
||||||
|
titleAr: string;
|
||||||
|
titleEn: string;
|
||||||
|
titleDe: string;
|
||||||
|
bodyAr: string;
|
||||||
|
bodyEn: string;
|
||||||
|
bodyDe: string;
|
||||||
|
imagePath: string;
|
||||||
|
media: MediaFieldState;
|
||||||
|
linkUrl: string;
|
||||||
|
sortOrder: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AssetFormValue = {
|
||||||
|
id?: string;
|
||||||
|
kind: PortfolioAssetKind;
|
||||||
|
filePath: string;
|
||||||
|
fileFieldName: string;
|
||||||
|
media: MediaFieldState;
|
||||||
|
altAr: string;
|
||||||
|
altEn: string;
|
||||||
|
altDe: string;
|
||||||
|
sortOrder: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PortfolioProjectFormProps = {
|
||||||
|
action: (formData: FormData) => void | Promise<void>;
|
||||||
|
categories: PortfolioCategoryView[];
|
||||||
|
mediaOptions: MediaOption[];
|
||||||
|
project?: PortfolioProjectView | null;
|
||||||
|
redirectPath: string;
|
||||||
|
submitLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionTypeOptions: PortfolioSectionType[] = [
|
||||||
|
"RICH_TEXT",
|
||||||
|
"GALLERY",
|
||||||
|
"STATS",
|
||||||
|
"DELIVERABLES",
|
||||||
|
"LINK",
|
||||||
|
];
|
||||||
|
|
||||||
|
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 createMediaFieldState(params: {
|
||||||
|
kind: MediaKind;
|
||||||
|
assetId?: string | null;
|
||||||
|
url?: string | null;
|
||||||
|
label?: string | null;
|
||||||
|
}): MediaFieldState {
|
||||||
|
return {
|
||||||
|
mode: params.assetId ? "library" : params.url ? "external" : "upload",
|
||||||
|
assetId: params.assetId ?? "",
|
||||||
|
url: params.url ?? "",
|
||||||
|
label: params.label ?? "",
|
||||||
|
kind: params.kind,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptySection(index: number): SectionFormValue {
|
||||||
|
return {
|
||||||
|
type: "RICH_TEXT",
|
||||||
|
titleAr: "",
|
||||||
|
titleEn: "",
|
||||||
|
titleDe: "",
|
||||||
|
bodyAr: "",
|
||||||
|
bodyEn: "",
|
||||||
|
bodyDe: "",
|
||||||
|
imagePath: "",
|
||||||
|
media: createMediaFieldState({
|
||||||
|
kind: "IMAGE",
|
||||||
|
}),
|
||||||
|
linkUrl: "",
|
||||||
|
sortOrder: index,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyAsset(index: number): AssetFormValue {
|
||||||
|
return {
|
||||||
|
kind: "IMAGE",
|
||||||
|
filePath: "",
|
||||||
|
fileFieldName: `asset-upload-${index}`,
|
||||||
|
media: createMediaFieldState({
|
||||||
|
kind: "IMAGE",
|
||||||
|
}),
|
||||||
|
altAr: "",
|
||||||
|
altEn: "",
|
||||||
|
altDe: "",
|
||||||
|
sortOrder: index,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PortfolioProjectForm({
|
||||||
|
action,
|
||||||
|
categories,
|
||||||
|
mediaOptions,
|
||||||
|
project,
|
||||||
|
redirectPath,
|
||||||
|
submitLabel,
|
||||||
|
}: PortfolioProjectFormProps) {
|
||||||
|
const [coverMedia, setCoverMedia] = useState<MediaFieldState>(
|
||||||
|
createMediaFieldState({
|
||||||
|
kind: "IMAGE",
|
||||||
|
assetId: project?.coverMediaAssetId,
|
||||||
|
url: project?.coverImagePath,
|
||||||
|
label: project?.title.de ?? project?.title.en ?? project?.title.ar ?? "",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const [sections, setSections] = useState<SectionFormValue[]>(
|
||||||
|
project?.sections.length
|
||||||
|
? project.sections.map((section, index) => ({
|
||||||
|
id: section.id,
|
||||||
|
type: section.type,
|
||||||
|
titleAr: section.title.ar,
|
||||||
|
titleEn: section.title.en,
|
||||||
|
titleDe: section.title.de,
|
||||||
|
bodyAr: section.body.ar,
|
||||||
|
bodyEn: section.body.en,
|
||||||
|
bodyDe: section.body.de,
|
||||||
|
imagePath: section.imagePath ?? "",
|
||||||
|
media: createMediaFieldState({
|
||||||
|
kind: "IMAGE",
|
||||||
|
assetId: section.mediaAssetId,
|
||||||
|
url: section.imagePath,
|
||||||
|
label: section.title.de || section.title.en || section.title.ar,
|
||||||
|
}),
|
||||||
|
linkUrl: section.linkUrl ?? "",
|
||||||
|
sortOrder: index,
|
||||||
|
}))
|
||||||
|
: [createEmptySection(0)],
|
||||||
|
);
|
||||||
|
const [assets, setAssets] = useState<AssetFormValue[]>(
|
||||||
|
project?.assets.length
|
||||||
|
? project.assets.map((asset, index) => ({
|
||||||
|
id: asset.id,
|
||||||
|
kind: asset.kind,
|
||||||
|
filePath: asset.filePath,
|
||||||
|
fileFieldName: `asset-upload-${index}`,
|
||||||
|
media: createMediaFieldState({
|
||||||
|
kind: asset.kind,
|
||||||
|
assetId: asset.mediaAssetId,
|
||||||
|
url: asset.filePath,
|
||||||
|
label: asset.alt.de || asset.alt.en || asset.alt.ar,
|
||||||
|
}),
|
||||||
|
altAr: asset.alt.ar,
|
||||||
|
altEn: asset.alt.en,
|
||||||
|
altDe: asset.alt.de,
|
||||||
|
sortOrder: index,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sectionsPayload = JSON.stringify(
|
||||||
|
sections.map((section, index) => ({
|
||||||
|
...section,
|
||||||
|
sortOrder: index,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const assetsPayload = JSON.stringify(
|
||||||
|
assets.map((asset, index) => ({
|
||||||
|
...asset,
|
||||||
|
sortOrder: index,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={action} className="space-y-6">
|
||||||
|
<input type="hidden" name="id" value={project?.id ?? ""} />
|
||||||
|
<input type="hidden" name="redirectPath" value={redirectPath} />
|
||||||
|
<input type="hidden" name="currentCoverImagePath" value={project?.coverImagePath ?? ""} />
|
||||||
|
<input type="hidden" name="sections" value={sectionsPayload} />
|
||||||
|
<input type="hidden" name="assets" value={assetsPayload} />
|
||||||
|
|
||||||
|
<AppCard>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Project Basics</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="categoryId">Category</Label>
|
||||||
|
<select
|
||||||
|
id="categoryId"
|
||||||
|
name="categoryId"
|
||||||
|
defaultValue={project?.category.id ?? categories[0]?.id ?? ""}
|
||||||
|
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<option key={category.id} value={category.id}>
|
||||||
|
{category.name.de} / {category.name.en}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="slug">Slug</Label>
|
||||||
|
<Input id="slug" name="slug" defaultValue={project?.slug ?? ""} required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="clientName">Client Name</Label>
|
||||||
|
<Input
|
||||||
|
id="clientName"
|
||||||
|
name="clientName"
|
||||||
|
defaultValue={project?.clientName ?? ""}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="projectYear">Project Year</Label>
|
||||||
|
<Input
|
||||||
|
id="projectYear"
|
||||||
|
name="projectYear"
|
||||||
|
type="number"
|
||||||
|
min="2000"
|
||||||
|
max="2100"
|
||||||
|
defaultValue={project?.projectYear ?? new Date().getFullYear()}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="previewUrl">Preview URL</Label>
|
||||||
|
<Input
|
||||||
|
id="previewUrl"
|
||||||
|
name="previewUrl"
|
||||||
|
type="url"
|
||||||
|
defaultValue={project?.previewUrl ?? ""}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="sortOrder">Sort Order</Label>
|
||||||
|
<Input
|
||||||
|
id="sortOrder"
|
||||||
|
name="sortOrder"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
defaultValue={project?.sortOrder ?? 0}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<MediaFieldPicker
|
||||||
|
title="Cover Image"
|
||||||
|
value={coverMedia}
|
||||||
|
onChange={setCoverMedia}
|
||||||
|
options={mediaOptions}
|
||||||
|
inputName="coverMedia"
|
||||||
|
fileFieldName="coverFile"
|
||||||
|
accept="image/*,.svg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 rounded-nested border border-border px-4 py-3 text-sm">
|
||||||
|
<input type="checkbox" name="isFeatured" defaultChecked={project?.isFeatured ?? false} />
|
||||||
|
Featured
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 rounded-nested border border-border px-4 py-3 text-sm">
|
||||||
|
<input type="checkbox" name="isPublished" defaultChecked={project?.isPublished ?? false} />
|
||||||
|
Published
|
||||||
|
</label>
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<AppCard>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Localized Content</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Tabs defaultValue="de">
|
||||||
|
<TabsList>
|
||||||
|
{localeFieldConfig.map((locale) => (
|
||||||
|
<TabsTrigger key={locale.key} value={locale.key}>
|
||||||
|
{locale.label}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
{localeFieldConfig.map((locale) => (
|
||||||
|
<TabsContent key={locale.key} value={locale.key}>
|
||||||
|
<div className="grid gap-4 rounded-surface border border-border p-4 md:grid-cols-2">
|
||||||
|
<div className="md:col-span-2 text-sm text-muted-foreground">
|
||||||
|
{locale.key === "ar"
|
||||||
|
? "Arabic content for the Arabic website."
|
||||||
|
: locale.key === "en"
|
||||||
|
? "English content for the English website."
|
||||||
|
: "German content for the German website."}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`title${locale.suffix}`}>{`Title ${locale.label}`}</Label>
|
||||||
|
<Input
|
||||||
|
id={`title${locale.suffix}`}
|
||||||
|
name={`title${locale.suffix}`}
|
||||||
|
defaultValue={project?.title[locale.key] ?? ""}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`serviceLabel${locale.suffix}`}>{`Service Label ${locale.label}`}</Label>
|
||||||
|
<Input
|
||||||
|
id={`serviceLabel${locale.suffix}`}
|
||||||
|
name={`serviceLabel${locale.suffix}`}
|
||||||
|
defaultValue={project?.serviceLabel[locale.key] ?? ""}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<Label htmlFor={`summary${locale.suffix}`}>{`Summary ${locale.label}`}</Label>
|
||||||
|
<Textarea
|
||||||
|
id={`summary${locale.suffix}`}
|
||||||
|
name={`summary${locale.suffix}`}
|
||||||
|
defaultValue={project?.summary[locale.key] ?? ""}
|
||||||
|
rows={4}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<AppCard>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle>Sections</CardTitle>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setSections((current) => [...current, createEmptySection(current.length)])}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Add Section
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{sections.map((section, index) => (
|
||||||
|
<AppCard key={section.id ?? `${section.type}-${index}`} level={2}>
|
||||||
|
<CardContent className="space-y-4 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<p className="text-sm font-medium text-foreground">Section #{index + 1}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
setSections((current) => moveArrayItem(current, index, index - 1))
|
||||||
|
}
|
||||||
|
disabled={index === 0}
|
||||||
|
>
|
||||||
|
<ArrowUp className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
setSections((current) => moveArrayItem(current, index, index + 1))
|
||||||
|
}
|
||||||
|
disabled={index === sections.length - 1}
|
||||||
|
>
|
||||||
|
<ArrowDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() =>
|
||||||
|
setSections((current) => current.filter((_, currentIndex) => currentIndex !== index))
|
||||||
|
}
|
||||||
|
disabled={sections.length === 1}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Type</Label>
|
||||||
|
<select
|
||||||
|
value={section.type}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSections((current) =>
|
||||||
|
current.map((item, currentIndex) =>
|
||||||
|
currentIndex === index
|
||||||
|
? { ...item, type: event.target.value as PortfolioSectionType }
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
|
||||||
|
>
|
||||||
|
{sectionTypeOptions.map((type) => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{type}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<MediaFieldPicker
|
||||||
|
title="Section Image"
|
||||||
|
value={section.media}
|
||||||
|
onChange={(media) =>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<Label>Link URL</Label>
|
||||||
|
<Input
|
||||||
|
value={section.linkUrl}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSections((current) =>
|
||||||
|
current.map((item, currentIndex) =>
|
||||||
|
currentIndex === index ? { ...item, linkUrl: event.target.value } : item,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{localeFieldConfig.map((locale) => (
|
||||||
|
<div key={`${locale.key}-title-${index}`} className="space-y-2">
|
||||||
|
<Label>{`Title ${locale.label}`}</Label>
|
||||||
|
<Input
|
||||||
|
value={section[`title${locale.suffix}` as keyof SectionFormValue] as string}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSections((current) =>
|
||||||
|
current.map((item, currentIndex) =>
|
||||||
|
currentIndex === index
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
[`title${locale.suffix}`]: event.target.value,
|
||||||
|
}
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{localeFieldConfig.map((locale) => (
|
||||||
|
<div key={`${locale.key}-body-${index}`} className="space-y-2 md:col-span-2">
|
||||||
|
<Label>{`Body ${locale.label}`}</Label>
|
||||||
|
<Textarea
|
||||||
|
rows={4}
|
||||||
|
value={section[`body${locale.suffix}` as keyof SectionFormValue] as string}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSections((current) =>
|
||||||
|
current.map((item, currentIndex) =>
|
||||||
|
currentIndex === index
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
[`body${locale.suffix}`]: event.target.value,
|
||||||
|
}
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<AppCard>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle>Assets</CardTitle>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setAssets((current) => [...current, createEmptyAsset(current.length)])}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Add Asset
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{assets.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No assets added yet.</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{assets.map((asset, index) => (
|
||||||
|
<AppCard key={asset.id ?? `${asset.kind}-${index}`} level={2}>
|
||||||
|
<CardContent className="space-y-4 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<p className="text-sm font-medium text-foreground">Asset #{index + 1}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setAssets((current) => moveArrayItem(current, index, index - 1))}
|
||||||
|
disabled={index === 0}
|
||||||
|
>
|
||||||
|
<ArrowUp className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setAssets((current) => moveArrayItem(current, index, index + 1))}
|
||||||
|
disabled={index === assets.length - 1}
|
||||||
|
>
|
||||||
|
<ArrowDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() =>
|
||||||
|
setAssets((current) => current.filter((_, currentIndex) => currentIndex !== index))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Kind</Label>
|
||||||
|
<select
|
||||||
|
value={asset.kind}
|
||||||
|
onChange={(event) =>
|
||||||
|
setAssets((current) =>
|
||||||
|
current.map((item, currentIndex) =>
|
||||||
|
currentIndex === index
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
kind: event.target.value as PortfolioAssetKind,
|
||||||
|
media: {
|
||||||
|
...item.media,
|
||||||
|
kind: event.target.value as PortfolioAssetKind,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
|
||||||
|
>
|
||||||
|
{assetKindOptions.map((kind) => (
|
||||||
|
<option key={kind} value={kind}>
|
||||||
|
{kind}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<MediaFieldPicker
|
||||||
|
title="Asset File"
|
||||||
|
value={asset.media}
|
||||||
|
onChange={(media) =>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{localeFieldConfig.map((locale) => (
|
||||||
|
<div key={`${locale.key}-asset-${index}`} className="space-y-2">
|
||||||
|
<Label>{`Alt ${locale.label}`}</Label>
|
||||||
|
<Input
|
||||||
|
value={asset[`alt${locale.suffix}` as keyof AssetFormValue] as string}
|
||||||
|
onChange={(event) =>
|
||||||
|
setAssets((current) =>
|
||||||
|
current.map((item, currentIndex) =>
|
||||||
|
currentIndex === index
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
[`alt${locale.suffix}`]: event.target.value,
|
||||||
|
}
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit">{submitLabel}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type PortfolioSubnavProps = {
|
||||||
|
active: "overview" | "categories" | "projects";
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
key: "overview",
|
||||||
|
label: "Overview",
|
||||||
|
href: "/root/portfolio",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "categories",
|
||||||
|
label: "Categories",
|
||||||
|
href: "/root/portfolio/categories",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "projects",
|
||||||
|
label: "Projects",
|
||||||
|
href: "/root/portfolio/projects",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function PortfolioSubnav({ active }: PortfolioSubnavProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.key}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"rounded-pill border px-4 py-2 text-sm transition-colors",
|
||||||
|
active === item.key
|
||||||
|
? "border-border-strong bg-foreground text-background"
|
||||||
|
: "border-border bg-background text-foreground/75 hover:border-border-strong hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { ArrowLeft, LogOut } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { AppHeader } from "@/components/layout/app-header";
|
||||||
|
import { AppShell } from "@/components/layout/app-shell";
|
||||||
|
import { AppSidebar } from "@/components/layout/app-sidebar";
|
||||||
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { getLocalizedPath } from "@/lib/locale";
|
||||||
|
import { getRootNavigation } from "@/lib/root-navigation";
|
||||||
|
|
||||||
|
type RootDashboardCopy = {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
overview: string;
|
||||||
|
maintenance: string;
|
||||||
|
uiKit: string;
|
||||||
|
portfolio: string;
|
||||||
|
media: string;
|
||||||
|
logout: string;
|
||||||
|
backToSite: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RootDashboardShellProps = {
|
||||||
|
copy: RootDashboardCopy;
|
||||||
|
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media";
|
||||||
|
portfolioChild?: "overview" | "projects" | "new-project" | "categories";
|
||||||
|
logoutAction: () => Promise<void>;
|
||||||
|
headerTitle: string;
|
||||||
|
headerDescription: string;
|
||||||
|
headerActions?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RootDashboardShell({
|
||||||
|
copy,
|
||||||
|
active,
|
||||||
|
portfolioChild,
|
||||||
|
logoutAction,
|
||||||
|
headerTitle,
|
||||||
|
headerDescription,
|
||||||
|
headerActions,
|
||||||
|
children,
|
||||||
|
}: RootDashboardShellProps) {
|
||||||
|
const sidebarItems = getRootNavigation(copy, active, portfolioChild);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
sidebar={
|
||||||
|
<AppSidebar
|
||||||
|
title={copy.title}
|
||||||
|
description={copy.subtitle}
|
||||||
|
items={sidebarItems}
|
||||||
|
footer={
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ThemeToggle ariaLabel="Theme wechseln" />
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline" className="w-full justify-start">
|
||||||
|
<Link href={getLocalizedPath("de")}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
{copy.backToSite}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<form action={logoutAction}>
|
||||||
|
<Button type="submit" variant="destructive" className="w-full justify-start">
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
{copy.logout}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
header={
|
||||||
|
<AppHeader
|
||||||
|
title={headerTitle}
|
||||||
|
description={headerDescription}
|
||||||
|
actions={headerActions}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export function moveArrayItem<T>(items: T[], fromIndex: number, toIndex: number) {
|
||||||
|
if (
|
||||||
|
fromIndex < 0 ||
|
||||||
|
toIndex < 0 ||
|
||||||
|
fromIndex >= items.length ||
|
||||||
|
toIndex >= items.length ||
|
||||||
|
fromIndex === toIndex
|
||||||
|
) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextItems = [...items];
|
||||||
|
const [movedItem] = nextItems.splice(fromIndex, 1);
|
||||||
|
|
||||||
|
nextItems.splice(toIndex, 0, movedItem);
|
||||||
|
|
||||||
|
return nextItems;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export {
|
||||||
|
MEDIA_UPLOAD_ROOT as PORTFOLIO_UPLOAD_ROOT,
|
||||||
|
MAX_MEDIA_FILE_SIZE as MAX_FILE_SIZE,
|
||||||
|
isManagedMediaFilePath as isManagedPortfolioFilePath,
|
||||||
|
removeManagedMediaFile as removeManagedPortfolioFile,
|
||||||
|
resolveMediaUploadPath as resolvePortfolioUploadPath,
|
||||||
|
sanitizeBaseName,
|
||||||
|
} from "@/lib/media-storage";
|
||||||
|
|
||||||
|
import { saveMediaUpload } from "@/lib/media-storage";
|
||||||
|
|
||||||
|
export async function savePortfolioUpload(file: File, folder: string) {
|
||||||
|
const savedFile = await saveMediaUpload(file, folder);
|
||||||
|
|
||||||
|
return savedFile?.url ?? null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { PortfolioAssetKind, PortfolioSectionType } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { mediaFieldInputSchema } from "./media-validation";
|
||||||
|
|
||||||
|
const requiredText = (label: string) =>
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, `${label} is required.`);
|
||||||
|
|
||||||
|
const optionalTrimmedText = z.string().trim().optional().transform((value) => value ?? "");
|
||||||
|
|
||||||
|
export const categoryInputSchema = z.object({
|
||||||
|
id: z.string().trim().optional(),
|
||||||
|
slug: requiredText("Category slug")
|
||||||
|
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Category slug must be lowercase and hyphenated."),
|
||||||
|
nameAr: requiredText("Category nameAr"),
|
||||||
|
nameEn: requiredText("Category nameEn"),
|
||||||
|
nameDe: requiredText("Category nameDe"),
|
||||||
|
descriptionAr: requiredText("Category descriptionAr"),
|
||||||
|
descriptionEn: requiredText("Category descriptionEn"),
|
||||||
|
descriptionDe: requiredText("Category descriptionDe"),
|
||||||
|
sortOrder: z.coerce.number().int().min(0).max(9999),
|
||||||
|
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 assetInputSchema = z.object({
|
||||||
|
id: z.string().trim().optional(),
|
||||||
|
kind: z.nativeEnum(PortfolioAssetKind),
|
||||||
|
filePath: optionalTrimmedText,
|
||||||
|
fileFieldName: optionalTrimmedText,
|
||||||
|
media: mediaFieldInputSchema.optional(),
|
||||||
|
altAr: requiredText("Asset altAr"),
|
||||||
|
altEn: requiredText("Asset altEn"),
|
||||||
|
altDe: requiredText("Asset altDe"),
|
||||||
|
sortOrder: z.coerce.number().int().min(0).max(9999),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const projectInputSchema = z.object({
|
||||||
|
id: z.string().trim().optional(),
|
||||||
|
categoryId: requiredText("Project categoryId"),
|
||||||
|
slug: requiredText("Project slug")
|
||||||
|
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Project slug must be lowercase and hyphenated."),
|
||||||
|
titleAr: requiredText("Project titleAr"),
|
||||||
|
titleEn: requiredText("Project titleEn"),
|
||||||
|
titleDe: requiredText("Project titleDe"),
|
||||||
|
summaryAr: requiredText("Project summaryAr"),
|
||||||
|
summaryEn: requiredText("Project summaryEn"),
|
||||||
|
summaryDe: requiredText("Project summaryDe"),
|
||||||
|
clientName: requiredText("Project clientName"),
|
||||||
|
projectYear: z.coerce.number().int().min(2000).max(2100),
|
||||||
|
serviceLabelAr: requiredText("Project serviceLabelAr"),
|
||||||
|
serviceLabelEn: requiredText("Project serviceLabelEn"),
|
||||||
|
serviceLabelDe: requiredText("Project serviceLabelDe"),
|
||||||
|
previewUrl: optionalTrimmedText.refine(
|
||||||
|
(value) => value === "" || /^https?:\/\//.test(value),
|
||||||
|
"Project previewUrl must be an absolute URL.",
|
||||||
|
),
|
||||||
|
currentCoverImagePath: optionalTrimmedText,
|
||||||
|
coverMedia: mediaFieldInputSchema.optional(),
|
||||||
|
sortOrder: z.coerce.number().int().min(0).max(9999),
|
||||||
|
isFeatured: z.boolean(),
|
||||||
|
isPublished: z.boolean(),
|
||||||
|
sections: z.array(sectionInputSchema),
|
||||||
|
assets: z.array(assetInputSchema),
|
||||||
|
});
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
import type { Category, PortfolioAsset, PortfolioProject, PortfolioSection } from "@prisma/client";
|
||||||
|
|
||||||
|
import { getPortfolioMediaBindings } from "@/lib/media";
|
||||||
|
import type { AppLocale } from "@/lib/locale";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
type CategoryRecord = Pick<
|
||||||
|
Category,
|
||||||
|
| "id"
|
||||||
|
| "slug"
|
||||||
|
| "nameAr"
|
||||||
|
| "nameEn"
|
||||||
|
| "nameDe"
|
||||||
|
| "descriptionAr"
|
||||||
|
| "descriptionEn"
|
||||||
|
| "descriptionDe"
|
||||||
|
| "sortOrder"
|
||||||
|
| "isActive"
|
||||||
|
>;
|
||||||
|
|
||||||
|
type SectionRecord = Pick<
|
||||||
|
PortfolioSection,
|
||||||
|
| "id"
|
||||||
|
| "type"
|
||||||
|
| "titleAr"
|
||||||
|
| "titleEn"
|
||||||
|
| "titleDe"
|
||||||
|
| "bodyAr"
|
||||||
|
| "bodyEn"
|
||||||
|
| "bodyDe"
|
||||||
|
| "imagePath"
|
||||||
|
| "linkUrl"
|
||||||
|
| "sortOrder"
|
||||||
|
>;
|
||||||
|
|
||||||
|
type AssetRecord = Pick<
|
||||||
|
PortfolioAsset,
|
||||||
|
"id" | "kind" | "filePath" | "altAr" | "altEn" | "altDe" | "sortOrder"
|
||||||
|
>;
|
||||||
|
|
||||||
|
type ProjectRecord = Pick<
|
||||||
|
PortfolioProject,
|
||||||
|
| "id"
|
||||||
|
| "slug"
|
||||||
|
| "titleAr"
|
||||||
|
| "titleEn"
|
||||||
|
| "titleDe"
|
||||||
|
| "summaryAr"
|
||||||
|
| "summaryEn"
|
||||||
|
| "summaryDe"
|
||||||
|
| "clientName"
|
||||||
|
| "projectYear"
|
||||||
|
| "serviceLabelAr"
|
||||||
|
| "serviceLabelEn"
|
||||||
|
| "serviceLabelDe"
|
||||||
|
| "previewUrl"
|
||||||
|
| "coverImagePath"
|
||||||
|
| "isFeatured"
|
||||||
|
| "isPublished"
|
||||||
|
| "publishedAt"
|
||||||
|
| "sortOrder"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type LocalizedContent = {
|
||||||
|
ar: string;
|
||||||
|
en: string;
|
||||||
|
de: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PortfolioCategoryView = {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: LocalizedContent;
|
||||||
|
description: LocalizedContent;
|
||||||
|
sortOrder: number;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PortfolioSectionView = {
|
||||||
|
id: string;
|
||||||
|
type: SectionRecord["type"];
|
||||||
|
title: LocalizedContent;
|
||||||
|
body: LocalizedContent;
|
||||||
|
imagePath: string | null;
|
||||||
|
mediaAssetId: string | null;
|
||||||
|
linkUrl: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PortfolioAssetView = {
|
||||||
|
id: string;
|
||||||
|
kind: AssetRecord["kind"];
|
||||||
|
filePath: string;
|
||||||
|
mediaAssetId: string | null;
|
||||||
|
alt: LocalizedContent;
|
||||||
|
sortOrder: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PortfolioProjectView = {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: LocalizedContent;
|
||||||
|
summary: LocalizedContent;
|
||||||
|
clientName: string;
|
||||||
|
projectYear: number;
|
||||||
|
serviceLabel: LocalizedContent;
|
||||||
|
previewUrl: string | null;
|
||||||
|
coverImagePath: string | null;
|
||||||
|
coverMediaAssetId: string | null;
|
||||||
|
isFeatured: boolean;
|
||||||
|
isPublished: boolean;
|
||||||
|
publishedAt: Date | null;
|
||||||
|
sortOrder: number;
|
||||||
|
category: PortfolioCategoryView;
|
||||||
|
sections: PortfolioSectionView[];
|
||||||
|
assets: PortfolioAssetView[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapLocalizedContent(record: Record<string, unknown>, prefix: string): LocalizedContent {
|
||||||
|
return {
|
||||||
|
ar: String(record[`${prefix}Ar`] ?? ""),
|
||||||
|
en: String(record[`${prefix}En`] ?? ""),
|
||||||
|
de: String(record[`${prefix}De`] ?? ""),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCategory(record: CategoryRecord): PortfolioCategoryView {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
slug: record.slug,
|
||||||
|
name: mapLocalizedContent(record, "name"),
|
||||||
|
description: mapLocalizedContent(record, "description"),
|
||||||
|
sortOrder: record.sortOrder,
|
||||||
|
isActive: record.isActive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSection(record: SectionRecord, mediaAssetId: string | null): PortfolioSectionView {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
type: record.type,
|
||||||
|
title: mapLocalizedContent(record, "title"),
|
||||||
|
body: mapLocalizedContent(record, "body"),
|
||||||
|
imagePath: record.imagePath,
|
||||||
|
mediaAssetId,
|
||||||
|
linkUrl: record.linkUrl,
|
||||||
|
sortOrder: record.sortOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapAsset(record: AssetRecord, mediaAssetId: string | null): PortfolioAssetView {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
kind: record.kind,
|
||||||
|
filePath: record.filePath,
|
||||||
|
mediaAssetId,
|
||||||
|
alt: mapLocalizedContent(record, "alt"),
|
||||||
|
sortOrder: record.sortOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapProject(
|
||||||
|
record: ProjectRecord & {
|
||||||
|
category: CategoryRecord;
|
||||||
|
sections: SectionRecord[];
|
||||||
|
assets: AssetRecord[];
|
||||||
|
},
|
||||||
|
mediaBindings?: {
|
||||||
|
coverAssetId: string | null;
|
||||||
|
sectionAssetIds: Record<string, string>;
|
||||||
|
assetIds: Record<string, string>;
|
||||||
|
},
|
||||||
|
): PortfolioProjectView {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
slug: record.slug,
|
||||||
|
title: mapLocalizedContent(record, "title"),
|
||||||
|
summary: mapLocalizedContent(record, "summary"),
|
||||||
|
clientName: record.clientName,
|
||||||
|
projectYear: record.projectYear,
|
||||||
|
serviceLabel: mapLocalizedContent(record, "serviceLabel"),
|
||||||
|
previewUrl: record.previewUrl,
|
||||||
|
coverImagePath: record.coverImagePath,
|
||||||
|
coverMediaAssetId: mediaBindings?.coverAssetId ?? null,
|
||||||
|
isFeatured: record.isFeatured,
|
||||||
|
isPublished: record.isPublished,
|
||||||
|
publishedAt: record.publishedAt,
|
||||||
|
sortOrder: record.sortOrder,
|
||||||
|
category: mapCategory(record.category),
|
||||||
|
sections: record.sections.map((section) =>
|
||||||
|
mapSection(section, mediaBindings?.sectionAssetIds[section.id] ?? null),
|
||||||
|
),
|
||||||
|
assets: record.assets.map((asset) => mapAsset(asset, mediaBindings?.assetIds[asset.id] ?? null)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocalizedValue(
|
||||||
|
content: LocalizedContent,
|
||||||
|
locale: AppLocale,
|
||||||
|
fallbackLocale: AppLocale = "de",
|
||||||
|
): string {
|
||||||
|
const direct = content[locale]?.trim();
|
||||||
|
|
||||||
|
if (direct) {
|
||||||
|
return direct;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = content[fallbackLocale]?.trim();
|
||||||
|
|
||||||
|
if (fallback) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return content.ar || content.en || content.de || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdminPortfolioCategories() {
|
||||||
|
const categories = await prisma.category.findMany({
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
projects: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return categories.map((category) => ({
|
||||||
|
...mapCategory(category),
|
||||||
|
projectCount: category._count.projects,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActivePortfolioCategories() {
|
||||||
|
const categories = await prisma.category.findMany({
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return categories.map(mapCategory);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdminPortfolioProjects(filters?: {
|
||||||
|
categoryId?: string;
|
||||||
|
status?: "all" | "draft" | "published";
|
||||||
|
}) {
|
||||||
|
const projects = await prisma.portfolioProject.findMany({
|
||||||
|
where: {
|
||||||
|
...(filters?.categoryId ? { categoryId: filters.categoryId } : {}),
|
||||||
|
...(filters?.status === "draft"
|
||||||
|
? { isPublished: false }
|
||||||
|
: filters?.status === "published"
|
||||||
|
? { isPublished: true }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
sections: {
|
||||||
|
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||||
|
},
|
||||||
|
assets: {
|
||||||
|
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return projects.map(mapProject);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPublishedPortfolioProjects(filters?: { categorySlug?: string }) {
|
||||||
|
const projects = await prisma.portfolioProject.findMany({
|
||||||
|
where: {
|
||||||
|
isPublished: true,
|
||||||
|
category: {
|
||||||
|
isActive: true,
|
||||||
|
...(filters?.categorySlug ? { slug: filters.categorySlug } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
sections: {
|
||||||
|
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||||
|
},
|
||||||
|
assets: {
|
||||||
|
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ sortOrder: "asc" }, { publishedAt: "desc" }, { createdAt: "desc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return projects.map(mapProject);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPublishedPortfolioProjectBySlug(slug: string) {
|
||||||
|
const project = await prisma.portfolioProject.findFirst({
|
||||||
|
where: {
|
||||||
|
slug,
|
||||||
|
isPublished: true,
|
||||||
|
category: {
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
sections: {
|
||||||
|
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||||
|
},
|
||||||
|
assets: {
|
||||||
|
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return project ? mapProject(project) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdminPortfolioProjectById(id: string) {
|
||||||
|
const project = await prisma.portfolioProject.findUnique({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
category: true,
|
||||||
|
sections: {
|
||||||
|
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||||
|
},
|
||||||
|
assets: {
|
||||||
|
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaBindings = await getPortfolioMediaBindings(project.id);
|
||||||
|
|
||||||
|
return mapProject(project, mediaBindings);
|
||||||
|
}
|
||||||
+56
-2
@@ -1,9 +1,20 @@
|
|||||||
import { LayoutDashboard, ShieldAlert, SwatchBook, type LucideIcon } from "lucide-react";
|
import {
|
||||||
|
FolderKanban,
|
||||||
|
ImageIcon,
|
||||||
|
LayoutDashboard,
|
||||||
|
PlusSquare,
|
||||||
|
ShieldAlert,
|
||||||
|
SwatchBook,
|
||||||
|
Tags,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
type RootNavigationCopy = {
|
type RootNavigationCopy = {
|
||||||
overview: string;
|
overview: string;
|
||||||
maintenance: string;
|
maintenance: string;
|
||||||
uiKit: string;
|
uiKit: string;
|
||||||
|
portfolio: string;
|
||||||
|
media: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RootNavItem = {
|
export type RootNavItem = {
|
||||||
@@ -11,11 +22,13 @@ export type RootNavItem = {
|
|||||||
href: string;
|
href: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
children?: RootNavItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getRootNavigation(
|
export function getRootNavigation(
|
||||||
copy: RootNavigationCopy,
|
copy: RootNavigationCopy,
|
||||||
active: "overview" | "maintenance" | "ui-kit",
|
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media",
|
||||||
|
portfolioChild?: "overview" | "projects" | "new-project" | "categories",
|
||||||
): RootNavItem[] {
|
): RootNavItem[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -36,5 +49,46 @@ export function getRootNavigation(
|
|||||||
icon: SwatchBook,
|
icon: SwatchBook,
|
||||||
active: active === "ui-kit",
|
active: active === "ui-kit",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: copy.media,
|
||||||
|
href: "/root/media",
|
||||||
|
icon: ImageIcon,
|
||||||
|
active: active === "media",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: copy.portfolio,
|
||||||
|
href: "/root/portfolio",
|
||||||
|
icon: FolderKanban,
|
||||||
|
active: active === "portfolio",
|
||||||
|
children:
|
||||||
|
active === "portfolio"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: "Overview",
|
||||||
|
href: "/root/portfolio",
|
||||||
|
icon: LayoutDashboard,
|
||||||
|
active: portfolioChild === "overview",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Add Project",
|
||||||
|
href: "/root/portfolio/projects/new",
|
||||||
|
icon: PlusSquare,
|
||||||
|
active: portfolioChild === "new-project",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Add Category",
|
||||||
|
href: "/root/portfolio/categories",
|
||||||
|
icon: Tags,
|
||||||
|
active: portfolioChild === "categories",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Projects",
|
||||||
|
href: "/root/portfolio/projects",
|
||||||
|
icon: FolderKanban,
|
||||||
|
active: portfolioChild === "projects",
|
||||||
|
},
|
||||||
|
].filter((item, index, array) => array.findIndex((entry) => entry.href === item.href) === index)
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,6 @@ import type { AppLocale } from "@/lib/locale";
|
|||||||
|
|
||||||
type LocalizedText = Record<AppLocale, string>;
|
type LocalizedText = Record<AppLocale, string>;
|
||||||
|
|
||||||
export type PortfolioItem = {
|
|
||||||
slug: string;
|
|
||||||
title: LocalizedText;
|
|
||||||
summary: LocalizedText;
|
|
||||||
category: LocalizedText;
|
|
||||||
year: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ProductItem = {
|
export type ProductItem = {
|
||||||
slug: string;
|
slug: string;
|
||||||
name: LocalizedText;
|
name: LocalizedText;
|
||||||
@@ -18,85 +10,6 @@ export type ProductItem = {
|
|||||||
price: LocalizedText;
|
price: LocalizedText;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const portfolioItems: PortfolioItem[] = [
|
|
||||||
{
|
|
||||||
slug: "brand-redesign",
|
|
||||||
title: {
|
|
||||||
de: "Brand Redesign",
|
|
||||||
en: "Brand Redesign",
|
|
||||||
ar: "إعادة تصميم الهوية",
|
|
||||||
},
|
|
||||||
summary: {
|
|
||||||
de: "Modernes Redesign fuer eine digitale Marke mit klarer Struktur.",
|
|
||||||
en: "Modern redesign for a digital brand with a clear system.",
|
|
||||||
ar: "إعادة تصميم حديثة لعلامة رقمية مع بنية واضحة.",
|
|
||||||
},
|
|
||||||
category: {
|
|
||||||
de: "Branding",
|
|
||||||
en: "Branding",
|
|
||||||
ar: "الهوية",
|
|
||||||
},
|
|
||||||
year: "2025",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: "commerce-relaunch",
|
|
||||||
title: {
|
|
||||||
de: "Commerce Relaunch",
|
|
||||||
en: "Commerce Relaunch",
|
|
||||||
ar: "إعادة إطلاق المتجر",
|
|
||||||
},
|
|
||||||
summary: {
|
|
||||||
de: "Relaunch eines Shops mit Fokus auf Performance und Conversion.",
|
|
||||||
en: "Store relaunch focused on performance and conversion.",
|
|
||||||
ar: "إعادة إطلاق متجر مع تركيز على الأداء والتحويل.",
|
|
||||||
},
|
|
||||||
category: {
|
|
||||||
de: "E-Commerce",
|
|
||||||
en: "E-Commerce",
|
|
||||||
ar: "التجارة الإلكترونية",
|
|
||||||
},
|
|
||||||
year: "2024",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: "saas-dashboard",
|
|
||||||
title: {
|
|
||||||
de: "SaaS Dashboard",
|
|
||||||
en: "SaaS Dashboard",
|
|
||||||
ar: "لوحة تحكم SaaS",
|
|
||||||
},
|
|
||||||
summary: {
|
|
||||||
de: "Admin Dashboard fuer Teams mit klaren KPIs und Reports.",
|
|
||||||
en: "Admin dashboard for teams with clear KPIs and reports.",
|
|
||||||
ar: "لوحة تحكم إدارية للفرق مع مؤشرات وتقارير واضحة.",
|
|
||||||
},
|
|
||||||
category: {
|
|
||||||
de: "Web App",
|
|
||||||
en: "Web App",
|
|
||||||
ar: "تطبيق ويب",
|
|
||||||
},
|
|
||||||
year: "2024",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: "campaign-site",
|
|
||||||
title: {
|
|
||||||
de: "Campaign Site",
|
|
||||||
en: "Campaign Site",
|
|
||||||
ar: "موقع حملة",
|
|
||||||
},
|
|
||||||
summary: {
|
|
||||||
de: "Landing Seite fuer Produktkampagnen mit schneller Iteration.",
|
|
||||||
en: "Landing experience for product campaigns and quick iteration.",
|
|
||||||
ar: "صفحة هبوط لحملات المنتجات مع تنفيذ سريع.",
|
|
||||||
},
|
|
||||||
category: {
|
|
||||||
de: "Marketing",
|
|
||||||
en: "Marketing",
|
|
||||||
ar: "التسويق",
|
|
||||||
},
|
|
||||||
year: "2023",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const productItems: ProductItem[] = [
|
export const productItems: ProductItem[] = [
|
||||||
{
|
{
|
||||||
slug: "starter-kit",
|
slug: "starter-kit",
|
||||||
@@ -173,10 +86,6 @@ export function pickText(text: LocalizedText, locale: AppLocale): string {
|
|||||||
return text[locale];
|
return text[locale];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPortfolioItem(slug: string) {
|
|
||||||
return portfolioItems.find((item) => item.slug === slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getProductItem(slug: string) {
|
export function getProductItem(slug: string) {
|
||||||
return productItems.find((item) => item.slug === slug);
|
return productItems.find((item) => item.slug === slug);
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-7
@@ -40,7 +40,9 @@
|
|||||||
"portfolioPage": {
|
"portfolioPage": {
|
||||||
"title": "الأعمال",
|
"title": "الأعمال",
|
||||||
"intro": "مجموعة مشاريع مختارة مع تركيز على الوضوح والنتائج.",
|
"intro": "مجموعة مشاريع مختارة مع تركيز على الوضوح والنتائج.",
|
||||||
"open": "فتح المشروع"
|
"open": "فتح المشروع",
|
||||||
|
"all": "كل التصنيفات",
|
||||||
|
"empty": "لا توجد مشاريع منشورة ضمن هذا التصنيف حالياً."
|
||||||
},
|
},
|
||||||
"productsPage": {
|
"productsPage": {
|
||||||
"title": "المنتجات",
|
"title": "المنتجات",
|
||||||
@@ -80,12 +82,10 @@
|
|||||||
},
|
},
|
||||||
"portfolioDetail": {
|
"portfolioDetail": {
|
||||||
"back": "العودة إلى الأعمال",
|
"back": "العودة إلى الأعمال",
|
||||||
"challenge": "التحدي",
|
"preview": "فتح المعاينة",
|
||||||
"solution": "الحل",
|
"openLink": "فتح الرابط",
|
||||||
"outcome": "النتيجة",
|
"gallery": "معرض المشروع",
|
||||||
"challengeText": "كان المشروع بحاجة إلى هيكل معلومات أوضح وأداء أسرع.",
|
"download": "فتح الملف"
|
||||||
"solutionText": "تم بناء التصميم والمكونات والمحتوى ضمن نظام مرن ومترابط.",
|
|
||||||
"outcomeText": "أصبح نشر المحتوى أسرع ووصول المستخدمين إلى أهدافهم أوضح."
|
|
||||||
},
|
},
|
||||||
"productDetail": {
|
"productDetail": {
|
||||||
"back": "العودة إلى المنتجات",
|
"back": "العودة إلى المنتجات",
|
||||||
|
|||||||
+7
-7
@@ -40,7 +40,9 @@
|
|||||||
"portfolioPage": {
|
"portfolioPage": {
|
||||||
"title": "Portfolio",
|
"title": "Portfolio",
|
||||||
"intro": "Eine Auswahl von Projekten mit Fokus auf Klarheit und Ergebnis.",
|
"intro": "Eine Auswahl von Projekten mit Fokus auf Klarheit und Ergebnis.",
|
||||||
"open": "Projekt oeffnen"
|
"open": "Projekt oeffnen",
|
||||||
|
"all": "Alle Kategorien",
|
||||||
|
"empty": "Fuer diesen Filter sind noch keine veroeffentlichten Projekte vorhanden."
|
||||||
},
|
},
|
||||||
"productsPage": {
|
"productsPage": {
|
||||||
"title": "Produkte",
|
"title": "Produkte",
|
||||||
@@ -80,12 +82,10 @@
|
|||||||
},
|
},
|
||||||
"portfolioDetail": {
|
"portfolioDetail": {
|
||||||
"back": "Zurueck zum Portfolio",
|
"back": "Zurueck zum Portfolio",
|
||||||
"challenge": "Herausforderung",
|
"preview": "Vorschau oeffnen",
|
||||||
"solution": "Loesung",
|
"openLink": "Link oeffnen",
|
||||||
"outcome": "Ergebnis",
|
"gallery": "Projektgalerie",
|
||||||
"challengeText": "Das Projekt brauchte eine klare Informationsarchitektur und schnellere Ladezeiten.",
|
"download": "Dokument oeffnen"
|
||||||
"solutionText": "Ich habe Design, Komponenten und Content in einem modularen System aufgebaut.",
|
|
||||||
"outcomeText": "Inhalte koennen schneller ausgerollt werden und Nutzer finden schneller zum Ziel."
|
|
||||||
},
|
},
|
||||||
"productDetail": {
|
"productDetail": {
|
||||||
"back": "Zurueck zu Produkten",
|
"back": "Zurueck zu Produkten",
|
||||||
|
|||||||
+7
-7
@@ -40,7 +40,9 @@
|
|||||||
"portfolioPage": {
|
"portfolioPage": {
|
||||||
"title": "Portfolio",
|
"title": "Portfolio",
|
||||||
"intro": "Selected projects with a focus on clarity and outcomes.",
|
"intro": "Selected projects with a focus on clarity and outcomes.",
|
||||||
"open": "Open project"
|
"open": "Open project",
|
||||||
|
"all": "All categories",
|
||||||
|
"empty": "No published projects are available for this filter yet."
|
||||||
},
|
},
|
||||||
"productsPage": {
|
"productsPage": {
|
||||||
"title": "Products",
|
"title": "Products",
|
||||||
@@ -80,12 +82,10 @@
|
|||||||
},
|
},
|
||||||
"portfolioDetail": {
|
"portfolioDetail": {
|
||||||
"back": "Back to portfolio",
|
"back": "Back to portfolio",
|
||||||
"challenge": "Challenge",
|
"preview": "Open preview",
|
||||||
"solution": "Solution",
|
"openLink": "Open link",
|
||||||
"outcome": "Outcome",
|
"gallery": "Project gallery",
|
||||||
"challengeText": "The project needed clearer information architecture and faster performance.",
|
"download": "Open document"
|
||||||
"solutionText": "I built design, components, and content in a modular system.",
|
|
||||||
"outcomeText": "Content ships faster and users reach goals more quickly."
|
|
||||||
},
|
},
|
||||||
"productDetail": {
|
"productDetail": {
|
||||||
"back": "Back to products",
|
"back": "Back to products",
|
||||||
|
|||||||
Generated
+1442
-1
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -7,6 +7,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
|
"test": "vitest run",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"db:migrate": "prisma migrate deploy",
|
"db:migrate": "prisma migrate deploy",
|
||||||
"db:migrate:dev": "prisma migrate dev",
|
"db:migrate:dev": "prisma migrate dev",
|
||||||
@@ -44,6 +45,7 @@
|
|||||||
"prisma": "^7.4.2",
|
"prisma": "^7.4.2",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
CREATE TYPE "PortfolioSectionType" AS ENUM (
|
||||||
|
'RICH_TEXT',
|
||||||
|
'GALLERY',
|
||||||
|
'STATS',
|
||||||
|
'DELIVERABLES',
|
||||||
|
'LINK'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TYPE "PortfolioAssetKind" AS ENUM (
|
||||||
|
'IMAGE',
|
||||||
|
'DOCUMENT'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "Category" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"nameAr" TEXT NOT NULL,
|
||||||
|
"nameEn" TEXT NOT NULL,
|
||||||
|
"nameDe" TEXT NOT NULL,
|
||||||
|
"descriptionAr" TEXT NOT NULL,
|
||||||
|
"descriptionEn" TEXT NOT NULL,
|
||||||
|
"descriptionDe" TEXT NOT NULL,
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "PortfolioProject" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"categoryId" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"titleAr" TEXT NOT NULL,
|
||||||
|
"titleEn" TEXT NOT NULL,
|
||||||
|
"titleDe" TEXT NOT NULL,
|
||||||
|
"summaryAr" TEXT NOT NULL,
|
||||||
|
"summaryEn" TEXT NOT NULL,
|
||||||
|
"summaryDe" TEXT NOT NULL,
|
||||||
|
"clientName" TEXT NOT NULL,
|
||||||
|
"projectYear" INTEGER NOT NULL,
|
||||||
|
"serviceLabelAr" TEXT NOT NULL,
|
||||||
|
"serviceLabelEn" TEXT NOT NULL,
|
||||||
|
"serviceLabelDe" TEXT NOT NULL,
|
||||||
|
"previewUrl" TEXT,
|
||||||
|
"coverImagePath" TEXT,
|
||||||
|
"isFeatured" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"isPublished" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"publishedAt" TIMESTAMP(3),
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "PortfolioProject_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "PortfolioSection" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"projectId" TEXT NOT NULL,
|
||||||
|
"type" "PortfolioSectionType" NOT NULL,
|
||||||
|
"titleAr" TEXT NOT NULL,
|
||||||
|
"titleEn" TEXT NOT NULL,
|
||||||
|
"titleDe" TEXT NOT NULL,
|
||||||
|
"bodyAr" TEXT NOT NULL,
|
||||||
|
"bodyEn" TEXT NOT NULL,
|
||||||
|
"bodyDe" TEXT NOT NULL,
|
||||||
|
"imagePath" TEXT,
|
||||||
|
"linkUrl" TEXT,
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "PortfolioSection_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "PortfolioAsset" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"projectId" TEXT NOT NULL,
|
||||||
|
"kind" "PortfolioAssetKind" NOT NULL,
|
||||||
|
"filePath" TEXT NOT NULL,
|
||||||
|
"altAr" TEXT NOT NULL,
|
||||||
|
"altEn" TEXT NOT NULL,
|
||||||
|
"altDe" TEXT NOT NULL,
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "PortfolioAsset_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "Category_slug_key" ON "Category"("slug");
|
||||||
|
CREATE UNIQUE INDEX "PortfolioProject_slug_key" ON "PortfolioProject"("slug");
|
||||||
|
CREATE INDEX "PortfolioProject_categoryId_isPublished_sortOrder_idx" ON "PortfolioProject"("categoryId", "isPublished", "sortOrder");
|
||||||
|
CREATE INDEX "PortfolioProject_isPublished_sortOrder_idx" ON "PortfolioProject"("isPublished", "sortOrder");
|
||||||
|
CREATE INDEX "PortfolioSection_projectId_sortOrder_idx" ON "PortfolioSection"("projectId", "sortOrder");
|
||||||
|
CREATE INDEX "PortfolioAsset_projectId_sortOrder_idx" ON "PortfolioAsset"("projectId", "sortOrder");
|
||||||
|
|
||||||
|
ALTER TABLE "PortfolioProject"
|
||||||
|
ADD CONSTRAINT "PortfolioProject_categoryId_fkey"
|
||||||
|
FOREIGN KEY ("categoryId") REFERENCES "Category"("id")
|
||||||
|
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "PortfolioSection"
|
||||||
|
ADD CONSTRAINT "PortfolioSection_projectId_fkey"
|
||||||
|
FOREIGN KEY ("projectId") REFERENCES "PortfolioProject"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "PortfolioAsset"
|
||||||
|
ADD CONSTRAINT "PortfolioAsset_projectId_fkey"
|
||||||
|
FOREIGN KEY ("projectId") REFERENCES "PortfolioProject"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -13,3 +13,149 @@ model AppConfig {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum PortfolioSectionType {
|
||||||
|
RICH_TEXT
|
||||||
|
GALLERY
|
||||||
|
STATS
|
||||||
|
DELIVERABLES
|
||||||
|
LINK
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PortfolioAssetKind {
|
||||||
|
IMAGE
|
||||||
|
DOCUMENT
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MediaSource {
|
||||||
|
UPLOAD
|
||||||
|
EXTERNAL
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MediaKind {
|
||||||
|
IMAGE
|
||||||
|
DOCUMENT
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MediaUsageType {
|
||||||
|
PORTFOLIO_COVER
|
||||||
|
PORTFOLIO_SECTION
|
||||||
|
PORTFOLIO_ASSET
|
||||||
|
GENERIC
|
||||||
|
}
|
||||||
|
|
||||||
|
model Category {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
slug String @unique
|
||||||
|
nameAr String
|
||||||
|
nameEn String
|
||||||
|
nameDe String
|
||||||
|
descriptionAr String
|
||||||
|
descriptionEn String
|
||||||
|
descriptionDe String
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
projects PortfolioProject[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model PortfolioProject {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
categoryId String
|
||||||
|
slug String @unique
|
||||||
|
titleAr String
|
||||||
|
titleEn String
|
||||||
|
titleDe String
|
||||||
|
summaryAr String
|
||||||
|
summaryEn String
|
||||||
|
summaryDe String
|
||||||
|
clientName String
|
||||||
|
projectYear Int
|
||||||
|
serviceLabelAr String
|
||||||
|
serviceLabelEn String
|
||||||
|
serviceLabelDe String
|
||||||
|
previewUrl String?
|
||||||
|
coverImagePath String?
|
||||||
|
isFeatured Boolean @default(false)
|
||||||
|
isPublished Boolean @default(false)
|
||||||
|
publishedAt DateTime?
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict)
|
||||||
|
sections PortfolioSection[]
|
||||||
|
assets PortfolioAsset[]
|
||||||
|
|
||||||
|
@@index([categoryId, isPublished, sortOrder])
|
||||||
|
@@index([isPublished, sortOrder])
|
||||||
|
}
|
||||||
|
|
||||||
|
model PortfolioSection {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
projectId String
|
||||||
|
type PortfolioSectionType
|
||||||
|
titleAr String
|
||||||
|
titleEn String
|
||||||
|
titleDe String
|
||||||
|
bodyAr String
|
||||||
|
bodyEn String
|
||||||
|
bodyDe String
|
||||||
|
imagePath String?
|
||||||
|
linkUrl String?
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
project PortfolioProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([projectId, sortOrder])
|
||||||
|
}
|
||||||
|
|
||||||
|
model PortfolioAsset {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
projectId String
|
||||||
|
kind PortfolioAssetKind
|
||||||
|
filePath String
|
||||||
|
altAr String
|
||||||
|
altEn String
|
||||||
|
altDe String
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
project PortfolioProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([projectId, sortOrder])
|
||||||
|
}
|
||||||
|
|
||||||
|
model MediaAsset {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
source MediaSource
|
||||||
|
kind MediaKind
|
||||||
|
url String
|
||||||
|
fileName String
|
||||||
|
label String
|
||||||
|
altText String?
|
||||||
|
mimeType String?
|
||||||
|
size Int?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
usages MediaUsage[]
|
||||||
|
|
||||||
|
@@index([kind, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model MediaUsage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
assetId String
|
||||||
|
usageType MediaUsageType
|
||||||
|
entityType String
|
||||||
|
entityId String
|
||||||
|
fieldKey String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
asset MediaAsset @relation(fields: [assetId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([usageType, entityType, entityId, fieldKey])
|
||||||
|
@@index([assetId])
|
||||||
|
@@index([entityType, entityId])
|
||||||
|
}
|
||||||
|
|||||||
+201
@@ -15,6 +15,207 @@ async function main() {
|
|||||||
update: { value: "moh-sass" },
|
update: { value: "moh-sass" },
|
||||||
create: { key: "siteName", value: "moh-sass" },
|
create: { key: "siteName", value: "moh-sass" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const brandCategory = await prisma.category.upsert({
|
||||||
|
where: { slug: "branding" },
|
||||||
|
update: {
|
||||||
|
nameAr: "الهوية البصرية",
|
||||||
|
nameEn: "Branding",
|
||||||
|
nameDe: "Branding",
|
||||||
|
descriptionAr: "مشاريع هوية بصرية وشعارات وأنظمة علامة.",
|
||||||
|
descriptionEn: "Brand identity, logo, and design system work.",
|
||||||
|
descriptionDe: "Branding, Logos und visuelle Systeme.",
|
||||||
|
sortOrder: 1,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
slug: "branding",
|
||||||
|
nameAr: "الهوية البصرية",
|
||||||
|
nameEn: "Branding",
|
||||||
|
nameDe: "Branding",
|
||||||
|
descriptionAr: "مشاريع هوية بصرية وشعارات وأنظمة علامة.",
|
||||||
|
descriptionEn: "Brand identity, logo, and design system work.",
|
||||||
|
descriptionDe: "Branding, Logos und visuelle Systeme.",
|
||||||
|
sortOrder: 1,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const webCategory = await prisma.category.upsert({
|
||||||
|
where: { slug: "web-experiences" },
|
||||||
|
update: {
|
||||||
|
nameAr: "تجارب الويب",
|
||||||
|
nameEn: "Web Experiences",
|
||||||
|
nameDe: "Web Experiences",
|
||||||
|
descriptionAr: "مواقع وصفحات هبوط وتجارب رقمية سريعة.",
|
||||||
|
descriptionEn: "Websites, landing pages, and digital experiences.",
|
||||||
|
descriptionDe: "Webseiten, Landingpages und digitale Erlebnisse.",
|
||||||
|
sortOrder: 2,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
slug: "web-experiences",
|
||||||
|
nameAr: "تجارب الويب",
|
||||||
|
nameEn: "Web Experiences",
|
||||||
|
nameDe: "Web Experiences",
|
||||||
|
descriptionAr: "مواقع وصفحات هبوط وتجارب رقمية سريعة.",
|
||||||
|
descriptionEn: "Websites, landing pages, and digital experiences.",
|
||||||
|
descriptionDe: "Webseiten, Landingpages und digitale Erlebnisse.",
|
||||||
|
sortOrder: 2,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const project = await prisma.portfolioProject.upsert({
|
||||||
|
where: { slug: "brand-redesign" },
|
||||||
|
update: {
|
||||||
|
categoryId: brandCategory.id,
|
||||||
|
titleAr: "إعادة تصميم الهوية",
|
||||||
|
titleEn: "Brand Redesign",
|
||||||
|
titleDe: "Brand Redesign",
|
||||||
|
summaryAr: "إعادة بناء لهوية رقمية مع نظام مرئي أوضح ومسارات استخدام أسرع.",
|
||||||
|
summaryEn: "A digital brand refresh with a clearer visual system and faster journeys.",
|
||||||
|
summaryDe: "Ein digitales Redesign mit klarerem visuellen System und schnelleren Journeys.",
|
||||||
|
clientName: "Studio Client",
|
||||||
|
projectYear: 2025,
|
||||||
|
serviceLabelAr: "هوية بصرية",
|
||||||
|
serviceLabelEn: "Brand Identity",
|
||||||
|
serviceLabelDe: "Brand Identity",
|
||||||
|
previewUrl: "https://example.com/preview/brand-redesign",
|
||||||
|
coverImagePath: "/uploads/portfolio/demo-cover.svg",
|
||||||
|
isFeatured: true,
|
||||||
|
isPublished: true,
|
||||||
|
publishedAt: new Date("2025-01-10T09:00:00.000Z"),
|
||||||
|
sortOrder: 1,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
categoryId: brandCategory.id,
|
||||||
|
slug: "brand-redesign",
|
||||||
|
titleAr: "إعادة تصميم الهوية",
|
||||||
|
titleEn: "Brand Redesign",
|
||||||
|
titleDe: "Brand Redesign",
|
||||||
|
summaryAr: "إعادة بناء لهوية رقمية مع نظام مرئي أوضح ومسارات استخدام أسرع.",
|
||||||
|
summaryEn: "A digital brand refresh with a clearer visual system and faster journeys.",
|
||||||
|
summaryDe: "Ein digitales Redesign mit klarerem visuellen System und schnelleren Journeys.",
|
||||||
|
clientName: "Studio Client",
|
||||||
|
projectYear: 2025,
|
||||||
|
serviceLabelAr: "هوية بصرية",
|
||||||
|
serviceLabelEn: "Brand Identity",
|
||||||
|
serviceLabelDe: "Brand Identity",
|
||||||
|
previewUrl: "https://example.com/preview/brand-redesign",
|
||||||
|
coverImagePath: "/uploads/portfolio/demo-cover.svg",
|
||||||
|
isFeatured: true,
|
||||||
|
isPublished: true,
|
||||||
|
publishedAt: new Date("2025-01-10T09:00:00.000Z"),
|
||||||
|
sortOrder: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.portfolioSection.deleteMany({
|
||||||
|
where: { projectId: project.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.portfolioAsset.deleteMany({
|
||||||
|
where: { projectId: project.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.portfolioSection.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
projectId: project.id,
|
||||||
|
type: "RICH_TEXT",
|
||||||
|
titleAr: "التحدي",
|
||||||
|
titleEn: "Challenge",
|
||||||
|
titleDe: "Herausforderung",
|
||||||
|
bodyAr: "كان المطلوب تحديث الهوية بدون خسارة التعرف البصري الحالي.",
|
||||||
|
bodyEn: "The brief required a refreshed identity without losing recognition.",
|
||||||
|
bodyDe: "Die Marke sollte modernisiert werden, ohne die Wiedererkennbarkeit zu verlieren.",
|
||||||
|
sortOrder: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
projectId: project.id,
|
||||||
|
type: "RICH_TEXT",
|
||||||
|
titleAr: "الحل",
|
||||||
|
titleEn: "Solution",
|
||||||
|
titleDe: "Loesung",
|
||||||
|
bodyAr: "تم بناء نظام مرئي أوضح مع قواعد استخدام قابلة للتوسع.",
|
||||||
|
bodyEn: "A clearer visual system with scalable usage rules was created.",
|
||||||
|
bodyDe: "Es wurde ein klareres visuelles System mit skalierbaren Regeln aufgebaut.",
|
||||||
|
sortOrder: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
projectId: project.id,
|
||||||
|
type: "LINK",
|
||||||
|
titleAr: "المعاينة",
|
||||||
|
titleEn: "Preview",
|
||||||
|
titleDe: "Vorschau",
|
||||||
|
bodyAr: "رابط مباشر لعرض المشروع.",
|
||||||
|
bodyEn: "Direct link for reviewing the work.",
|
||||||
|
bodyDe: "Direkter Link zur Projektansicht.",
|
||||||
|
linkUrl: "https://example.com/preview/brand-redesign",
|
||||||
|
sortOrder: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.portfolioAsset.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
projectId: project.id,
|
||||||
|
kind: "IMAGE",
|
||||||
|
filePath: "/uploads/portfolio/demo-cover.svg",
|
||||||
|
altAr: "غلاف مشروع إعادة تصميم الهوية",
|
||||||
|
altEn: "Brand redesign cover artwork",
|
||||||
|
altDe: "Titelgrafik fuer Brand Redesign",
|
||||||
|
sortOrder: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.portfolioProject.upsert({
|
||||||
|
where: { slug: "campaign-site" },
|
||||||
|
update: {
|
||||||
|
categoryId: webCategory.id,
|
||||||
|
titleAr: "موقع حملة",
|
||||||
|
titleEn: "Campaign Site",
|
||||||
|
titleDe: "Campaign Site",
|
||||||
|
summaryAr: "صفحة إطلاق مرنة لحملة رقمية مع تركيز على السرعة والتحويل.",
|
||||||
|
summaryEn: "A launch site built for speed, iteration, and conversion.",
|
||||||
|
summaryDe: "Eine Kampagnenseite mit Fokus auf Tempo, Iteration und Conversion.",
|
||||||
|
clientName: "Launch Client",
|
||||||
|
projectYear: 2024,
|
||||||
|
serviceLabelAr: "موقع تسويقي",
|
||||||
|
serviceLabelEn: "Marketing Website",
|
||||||
|
serviceLabelDe: "Marketing Website",
|
||||||
|
previewUrl: "https://example.com/preview/campaign-site",
|
||||||
|
coverImagePath: "/uploads/portfolio/demo-cover.svg",
|
||||||
|
isFeatured: false,
|
||||||
|
isPublished: true,
|
||||||
|
publishedAt: new Date("2024-09-05T09:00:00.000Z"),
|
||||||
|
sortOrder: 2,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
categoryId: webCategory.id,
|
||||||
|
slug: "campaign-site",
|
||||||
|
titleAr: "موقع حملة",
|
||||||
|
titleEn: "Campaign Site",
|
||||||
|
titleDe: "Campaign Site",
|
||||||
|
summaryAr: "صفحة إطلاق مرنة لحملة رقمية مع تركيز على السرعة والتحويل.",
|
||||||
|
summaryEn: "A launch site built for speed, iteration, and conversion.",
|
||||||
|
summaryDe: "Eine Kampagnenseite mit Fokus auf Tempo, Iteration und Conversion.",
|
||||||
|
clientName: "Launch Client",
|
||||||
|
projectYear: 2024,
|
||||||
|
serviceLabelAr: "موقع تسويقي",
|
||||||
|
serviceLabelEn: "Marketing Website",
|
||||||
|
serviceLabelDe: "Marketing Website",
|
||||||
|
previewUrl: "https://example.com/preview/campaign-site",
|
||||||
|
coverImagePath: "/uploads/portfolio/demo-cover.svg",
|
||||||
|
isFeatured: false,
|
||||||
|
isPublished: true,
|
||||||
|
publishedAt: new Date("2024-09-05T09:00:00.000Z"),
|
||||||
|
sortOrder: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<svg width="1600" height="900" viewBox="0 0 1600 900" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="1600" height="900" rx="48" fill="#0F172A"/>
|
||||||
|
<rect x="72" y="72" width="1456" height="756" rx="36" fill="url(#paint0_linear_1_2)"/>
|
||||||
|
<circle cx="1228" cy="248" r="146" fill="#F59E0B" fill-opacity="0.9"/>
|
||||||
|
<circle cx="344" cy="644" r="204" fill="#0EA5E9" fill-opacity="0.82"/>
|
||||||
|
<path d="M286 250H812C1001.47 250 1155 403.53 1155 593V593" stroke="white" stroke-width="48" stroke-linecap="round"/>
|
||||||
|
<path d="M946 504L1154 504L1154 712" stroke="white" stroke-width="48" stroke-linecap="round"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="paint0_linear_1_2" x1="72" y1="72" x2="1528" y2="828" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#111827"/>
|
||||||
|
<stop offset="1" stop-color="#1D4ED8"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 852 B |
@@ -0,0 +1,15 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { moveArrayItem } from "../lib/array";
|
||||||
|
|
||||||
|
describe("moveArrayItem", () => {
|
||||||
|
it("moves an item to a new position", () => {
|
||||||
|
expect(moveArrayItem(["a", "b", "c"], 0, 2)).toEqual(["b", "c", "a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the original order when the move is invalid", () => {
|
||||||
|
expect(moveArrayItem(["a", "b", "c"], -1, 2)).toEqual(["a", "b", "c"]);
|
||||||
|
expect(moveArrayItem(["a", "b", "c"], 1, 1)).toEqual(["a", "b", "c"]);
|
||||||
|
expect(moveArrayItem(["a", "b", "c"], 1, 5)).toEqual(["a", "b", "c"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { mkdir, stat, writeFile } from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
MEDIA_UPLOAD_ROOT,
|
||||||
|
isManagedMediaFilePath,
|
||||||
|
removeManagedMediaFile,
|
||||||
|
resolveMediaUploadPath,
|
||||||
|
sanitizeBaseName,
|
||||||
|
} from "../lib/media-storage";
|
||||||
|
|
||||||
|
const createdFiles: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
createdFiles.splice(0).map(async (filePath) => {
|
||||||
|
await removeManagedMediaFile(filePath);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("media storage helpers", () => {
|
||||||
|
it("sanitizes upload names safely", () => {
|
||||||
|
expect(sanitizeBaseName("Brand Redesign 2026!.svg")).toBe("brand-redesign-2026-svg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects managed media upload paths", () => {
|
||||||
|
expect(isManagedMediaFilePath("/uploads/media/covers/test.svg")).toBe(true);
|
||||||
|
expect(isManagedMediaFilePath("https://example.com/test.svg")).toBe(false);
|
||||||
|
expect(isManagedMediaFilePath("../test.svg")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves managed paths inside the upload root", () => {
|
||||||
|
const resolvedPath = resolveMediaUploadPath("/uploads/media/assets/test.svg");
|
||||||
|
|
||||||
|
expect(resolvedPath.startsWith(MEDIA_UPLOAD_ROOT)).toBe(true);
|
||||||
|
expect(resolvedPath.endsWith(path.join("assets", "test.svg"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a managed file from disk", async () => {
|
||||||
|
const relativePath = `/uploads/media/tests/${Date.now()}-temp.txt`;
|
||||||
|
const absolutePath = resolveMediaUploadPath(relativePath);
|
||||||
|
|
||||||
|
await mkdir(path.dirname(absolutePath), { recursive: true });
|
||||||
|
await writeFile(absolutePath, "temporary-test-file", "utf8");
|
||||||
|
createdFiles.push(relativePath);
|
||||||
|
|
||||||
|
await expect(stat(absolutePath)).resolves.toBeDefined();
|
||||||
|
await expect(removeManagedMediaFile(relativePath)).resolves.toBe(true);
|
||||||
|
await expect(stat(absolutePath)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
assetInputSchema,
|
||||||
|
categoryInputSchema,
|
||||||
|
projectInputSchema,
|
||||||
|
sectionInputSchema,
|
||||||
|
} from "../lib/portfolio-validation";
|
||||||
|
|
||||||
|
describe("portfolio validation", () => {
|
||||||
|
it("accepts a valid category payload", () => {
|
||||||
|
expect(
|
||||||
|
categoryInputSchema.parse({
|
||||||
|
slug: "branding",
|
||||||
|
nameAr: "الهوية",
|
||||||
|
nameEn: "Branding",
|
||||||
|
nameDe: "Branding",
|
||||||
|
descriptionAr: "وصف",
|
||||||
|
descriptionEn: "Description",
|
||||||
|
descriptionDe: "Beschreibung",
|
||||||
|
sortOrder: 1,
|
||||||
|
isActive: true,
|
||||||
|
}).slug,
|
||||||
|
).toBe("branding");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid project slugs", () => {
|
||||||
|
expect(() =>
|
||||||
|
projectInputSchema.parse({
|
||||||
|
categoryId: "cat_1",
|
||||||
|
slug: "Invalid Slug",
|
||||||
|
titleAr: "عنوان",
|
||||||
|
titleEn: "Title",
|
||||||
|
titleDe: "Titel",
|
||||||
|
summaryAr: "ملخص",
|
||||||
|
summaryEn: "Summary",
|
||||||
|
summaryDe: "Zusammenfassung",
|
||||||
|
clientName: "Client",
|
||||||
|
projectYear: 2025,
|
||||||
|
serviceLabelAr: "خدمة",
|
||||||
|
serviceLabelEn: "Service",
|
||||||
|
serviceLabelDe: "Service",
|
||||||
|
previewUrl: "https://example.com",
|
||||||
|
currentCoverImagePath: "",
|
||||||
|
coverMedia: {
|
||||||
|
mode: "external",
|
||||||
|
assetId: "",
|
||||||
|
url: "https://example.com/cover.jpg",
|
||||||
|
label: "Cover",
|
||||||
|
kind: "IMAGE",
|
||||||
|
},
|
||||||
|
sortOrder: 1,
|
||||||
|
isFeatured: false,
|
||||||
|
isPublished: true,
|
||||||
|
sections: [],
|
||||||
|
assets: [],
|
||||||
|
}),
|
||||||
|
).toThrow(/slug/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts valid section and asset payloads", () => {
|
||||||
|
expect(
|
||||||
|
sectionInputSchema.parse({
|
||||||
|
type: "RICH_TEXT",
|
||||||
|
titleAr: "العنوان",
|
||||||
|
titleEn: "Title",
|
||||||
|
titleDe: "Titel",
|
||||||
|
bodyAr: "النص",
|
||||||
|
bodyEn: "Body",
|
||||||
|
bodyDe: "Text",
|
||||||
|
imagePath: "/uploads/media/covers/example.svg",
|
||||||
|
media: {
|
||||||
|
mode: "library",
|
||||||
|
assetId: "asset_1",
|
||||||
|
url: "",
|
||||||
|
label: "Section Image",
|
||||||
|
kind: "IMAGE",
|
||||||
|
},
|
||||||
|
linkUrl: "https://example.com",
|
||||||
|
sortOrder: 0,
|
||||||
|
}).type,
|
||||||
|
).toBe("RICH_TEXT");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
assetInputSchema.parse({
|
||||||
|
kind: "IMAGE",
|
||||||
|
filePath: "/uploads/media/assets/example.svg",
|
||||||
|
fileFieldName: "",
|
||||||
|
media: {
|
||||||
|
mode: "external",
|
||||||
|
assetId: "",
|
||||||
|
url: "https://example.com/example.svg",
|
||||||
|
label: "Example",
|
||||||
|
kind: "IMAGE",
|
||||||
|
},
|
||||||
|
altAr: "بديل",
|
||||||
|
altEn: "Alt",
|
||||||
|
altDe: "Alt",
|
||||||
|
sortOrder: 0,
|
||||||
|
}).kind,
|
||||||
|
).toBe("IMAGE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid media payloads", () => {
|
||||||
|
expect(() =>
|
||||||
|
assetInputSchema.parse({
|
||||||
|
kind: "IMAGE",
|
||||||
|
filePath: "",
|
||||||
|
fileFieldName: "",
|
||||||
|
media: {
|
||||||
|
mode: "external",
|
||||||
|
assetId: "",
|
||||||
|
url: "not-a-url",
|
||||||
|
label: "Broken",
|
||||||
|
kind: "IMAGE",
|
||||||
|
},
|
||||||
|
altAr: "بديل",
|
||||||
|
altEn: "Alt",
|
||||||
|
altDe: "Alt",
|
||||||
|
sortOrder: 0,
|
||||||
|
}),
|
||||||
|
).toThrow(/url/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user