Implement portfolio admin management

This commit is contained in:
MOH
2026-03-07 16:06:20 +01:00
parent f983ce2203
commit ea64373853
35 changed files with 5126 additions and 178 deletions
+11 -5
View File
@@ -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")}
+127 -35
View File
@@ -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>
); );
} }
+60 -7
View File
@@ -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>
); );
+2
View File
@@ -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
View File
@@ -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>
+591
View File
@@ -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."));
}
}
+243
View File
@@ -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>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function RootPortfolioMediaRedirectPage() {
redirect("/root/media");
}
+168
View File
@@ -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>
);
}
+119
View File
@@ -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>
);
}
+79
View File
@@ -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>
);
}
+190
View File
@@ -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>
);
}
+2
View File
@@ -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",
}; };
+27 -1
View File
@@ -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>
+660
View File
@@ -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>
);
}
+46
View File
@@ -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>
);
}
+87
View File
@@ -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>
);
}
+18
View File
@@ -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;
}
+16
View File
@@ -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;
}
+85
View File
@@ -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),
});
+344
View File
@@ -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
View File
@@ -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,
},
]; ];
} }
-91
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+1442 -1
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -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;
+146
View File
@@ -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
View File
@@ -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()
+14
View File
@@ -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

+15
View File
@@ -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"]);
});
});
+54
View File
@@ -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();
});
});
+124
View File
@@ -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);
});
});