From ea6437385300fe1027fec753a055fb5436ae1d8e Mon Sep 17 00:00:00 2001 From: MOH Date: Sat, 7 Mar 2026 16:06:20 +0100 Subject: [PATCH] Implement portfolio admin management --- app/[locale]/(site)/page.tsx | 16 +- app/[locale]/(site)/portfolio/[slug]/page.tsx | 162 +- app/[locale]/(site)/portfolio/page.tsx | 67 +- app/root/maintenance/page.tsx | 2 + app/root/page.tsx | 48 +- app/root/portfolio/actions.ts | 591 +++++++ app/root/portfolio/categories/page.tsx | 243 +++ app/root/portfolio/media/page.tsx | 5 + app/root/portfolio/page.tsx | 168 ++ app/root/portfolio/projects/[id]/page.tsx | 119 ++ app/root/portfolio/projects/new/page.tsx | 79 + app/root/portfolio/projects/page.tsx | 190 +++ app/root/ui-kit/page.tsx | 2 + components/layout/app-sidebar.tsx | 52 +- components/root/portfolio-project-form.tsx | 660 ++++++++ components/root/portfolio-subnav.tsx | 46 + components/root/root-dashboard-shell.tsx | 87 + lib/array.ts | 18 + lib/portfolio-storage.ts | 16 + lib/portfolio-validation.ts | 85 + lib/portfolio.ts | 344 ++++ lib/root-navigation.ts | 58 +- lib/site-data.ts | 91 -- messages/ar.json | 14 +- messages/de.json | 14 +- messages/en.json | 14 +- package-lock.json | 1443 ++++++++++++++++- package.json | 4 +- .../migration.sql | 112 ++ prisma/schema.prisma | 146 ++ prisma/seed.js | 201 +++ public/uploads/portfolio/demo-cover.svg | 14 + tests/array.test.ts | 15 + tests/portfolio-storage.test.ts | 54 + tests/portfolio-validation.test.ts | 124 ++ 35 files changed, 5126 insertions(+), 178 deletions(-) create mode 100644 app/root/portfolio/actions.ts create mode 100644 app/root/portfolio/categories/page.tsx create mode 100644 app/root/portfolio/media/page.tsx create mode 100644 app/root/portfolio/page.tsx create mode 100644 app/root/portfolio/projects/[id]/page.tsx create mode 100644 app/root/portfolio/projects/new/page.tsx create mode 100644 app/root/portfolio/projects/page.tsx create mode 100644 components/root/portfolio-project-form.tsx create mode 100644 components/root/portfolio-subnav.tsx create mode 100644 components/root/root-dashboard-shell.tsx create mode 100644 lib/array.ts create mode 100644 lib/portfolio-storage.ts create mode 100644 lib/portfolio-validation.ts create mode 100644 lib/portfolio.ts create mode 100644 prisma/migrations/20260307152159_add_portfolio_management/migration.sql create mode 100644 public/uploads/portfolio/demo-cover.svg create mode 100644 tests/array.test.ts create mode 100644 tests/portfolio-storage.test.ts create mode 100644 tests/portfolio-validation.test.ts diff --git a/app/[locale]/(site)/page.tsx b/app/[locale]/(site)/page.tsx index 7042f43..f3463d2 100644 --- a/app/[locale]/(site)/page.tsx +++ b/app/[locale]/(site)/page.tsx @@ -16,7 +16,11 @@ import { AppCard } from "@/components/ui/app-card"; import { Button } from "@/components/ui/button"; import { CardContent } from "@/components/ui/card"; 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 = { params: { @@ -24,6 +28,8 @@ type HomePageProps = { }; }; +export const dynamic = "force-dynamic"; + export async function generateMetadata({ params: { locale }, }: HomePageProps): Promise { @@ -40,7 +46,7 @@ export async function generateMetadata({ export default async function HomePage({ params: { locale } }: HomePageProps) { const localeKey = resolveLocale(locale); - const featuredProjects = portfolioItems.slice(0, 3); + const featuredProjects = (await getPublishedPortfolioProjects()).slice(0, 3); const featuredProducts = productItems.slice(0, 3); const t = await getTranslations({ locale: localeKey, namespace: "homepage" }); @@ -109,13 +115,13 @@ export default async function HomePage({ params: { locale } }: HomePageProps) { className="group block" >

- {pickText(item.category, localeKey)} - {item.year} + {getLocalizedValue(item.category.name, localeKey)} - {item.projectYear}

- {pickText(item.title, localeKey)} + {getLocalizedValue(item.title, localeKey)}

- {pickText(item.summary, localeKey)} + {getLocalizedValue(item.summary, localeKey)}

{t("toPortfolio")} diff --git a/app/[locale]/(site)/portfolio/[slug]/page.tsx b/app/[locale]/(site)/portfolio/[slug]/page.tsx index 2d85794..1e5403e 100644 --- a/app/[locale]/(site)/portfolio/[slug]/page.tsx +++ b/app/[locale]/(site)/portfolio/[slug]/page.tsx @@ -1,15 +1,18 @@ 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 Image from "next/image"; import { getTranslations } from "next-intl/server"; import { notFound } from "next/navigation"; import { Container } from "@/components/layout/container"; import { MotionFade } from "@/components/motion-fade"; -import { routing } from "@/i18n/routing"; import { buildLocalizedMetadata } from "@/lib/metadata"; 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 { Button } from "@/components/ui/button"; import { CardContent } from "@/components/ui/card"; @@ -21,12 +24,30 @@ type PortfolioItemPageProps = { }; }; -export function generateStaticParams() { - return routing.locales.flatMap((locale) => - portfolioItems.map((item) => ({ - locale, - slug: item.slug, - })), +export const dynamic = "force-dynamic"; + +function PortfolioImage({ + src, + alt, + className, + width, + height, +}: { + src: string; + alt: string; + className: string; + width: number; + height: number; +}) { + return ( + {alt} ); } @@ -34,7 +55,7 @@ export async function generateMetadata({ params: { locale, slug }, }: PortfolioItemPageProps): Promise { const localeKey = resolveLocale(locale); - const item = getPortfolioItem(slug); + const item = await getPublishedPortfolioProjectBySlug(slug); if (!item) { return buildLocalizedMetadata({ @@ -48,8 +69,8 @@ export async function generateMetadata({ return buildLocalizedMetadata({ locale: localeKey, pathname: `/portfolio/${slug}`, - title: pickText(item.title, localeKey), - description: pickText(item.summary, localeKey), + title: getLocalizedValue(item.title, localeKey), + description: getLocalizedValue(item.summary, localeKey), }); } @@ -57,7 +78,7 @@ export default async function PortfolioItemPage({ params: { locale, slug }, }: PortfolioItemPageProps) { const localeKey = resolveLocale(locale); - const item = getPortfolioItem(slug); + const item = await getPublishedPortfolioProjectBySlug(slug); if (!item) { notFound(); @@ -78,25 +99,41 @@ export default async function PortfolioItemPage({

- {pickText(item.title, localeKey)} + {getLocalizedValue(item.title, localeKey)}

- {pickText(item.summary, localeKey)} + {getLocalizedValue(item.summary, localeKey)}

+ {item.coverImagePath ? ( +
+ +
+ ) : null} +
{[ { icon: Tag, - label: pickText(item.category, localeKey), + label: getLocalizedValue(item.category.name, localeKey), }, { icon: CalendarDays, - label: item.year, + label: String(item.projectYear), }, { icon: FolderKanban, - label: item.slug, + label: getLocalizedValue(item.serviceLabel, localeKey), + }, + { + icon: UserRound, + label: item.clientName, }, ].map((meta) => { const Icon = meta.icon; @@ -111,35 +148,90 @@ export default async function PortfolioItemPage({ ); })}
+ + {item.previewUrl ? ( +
+ +
+ ) : null}
- {[ - { - title: t("challenge"), - text: t("challengeText"), - }, - { - title: t("solution"), - text: t("solutionText"), - }, - { - title: t("outcome"), - text: t("outcomeText"), - }, - ].map((section, index) => ( - + {item.sections.map((section, index) => ( + -

{section.title}

-

{section.text}

+

+ {getLocalizedValue(section.title, localeKey)} +

+

+ {getLocalizedValue(section.body, localeKey)} +

+ {section.imagePath ? ( + + ) : null} + {section.linkUrl ? ( + + ) : null}
))}
+ + {item.assets.length > 0 ? ( + + + +

{t("gallery")}

+
+ {item.assets.map((asset) => ( +
+ {asset.kind === "IMAGE" ? ( + + ) : ( +
+
+

{getLocalizedValue(asset.alt, localeKey)}

+ +
+
+ )} +
+ ))} +
+
+
+
+ ) : null} ); } diff --git a/app/[locale]/(site)/portfolio/page.tsx b/app/[locale]/(site)/portfolio/page.tsx index c09fc1d..82646b3 100644 --- a/app/[locale]/(site)/portfolio/page.tsx +++ b/app/[locale]/(site)/portfolio/page.tsx @@ -9,14 +9,23 @@ import { buildLocalizedMetadata } from "@/lib/metadata"; import { AppCard } from "@/components/ui/app-card"; import { CardContent } from "@/components/ui/card"; import { getLocalizedPath, resolveLocale } from "@/lib/locale"; -import { pickText, portfolioItems } from "@/lib/site-data"; +import { + getActivePortfolioCategories, + getLocalizedValue, + getPublishedPortfolioProjects, +} from "@/lib/portfolio"; type PortfolioPageProps = { params: { locale: string; }; + searchParams?: { + category?: string; + }; }; +export const dynamic = "force-dynamic"; + export async function generateMetadata({ params: { locale }, }: PortfolioPageProps): Promise { @@ -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 t = await getTranslations({ locale: localeKey, namespace: "portfolioPage" }); + const selectedCategory = searchParams?.category ?? ""; + const [categories, projects] = await Promise.all([ + getActivePortfolioCategories(), + getPublishedPortfolioProjects({ + categorySlug: selectedCategory || undefined, + }), + ]); return ( @@ -53,8 +72,34 @@ export default async function PortfolioPage({ params: { locale } }: PortfolioPag +
+ + {t("all")} + + {categories.map((category) => ( + + {getLocalizedValue(category.name, localeKey)} + + ))} +
+
- {portfolioItems.map((item, index) => ( + {projects.map((item, index) => ( @@ -64,18 +109,18 @@ export default async function PortfolioPage({ params: { locale } }: PortfolioPag >

- {pickText(item.category, localeKey)} + {getLocalizedValue(item.category.name, localeKey)}

- {item.year} + {item.projectYear}

- {pickText(item.title, localeKey)} + {getLocalizedValue(item.title, localeKey)}

- {pickText(item.summary, localeKey)} + {getLocalizedValue(item.summary, localeKey)}

{t("open")} @@ -86,6 +131,14 @@ export default async function PortfolioPage({ params: { locale } }: PortfolioPag ))} + + {projects.length === 0 ? ( + + + {t("empty")} + + + ) : null}

); diff --git a/app/root/maintenance/page.tsx b/app/root/maintenance/page.tsx index cef1b9d..123662a 100644 --- a/app/root/maintenance/page.tsx +++ b/app/root/maintenance/page.tsx @@ -25,6 +25,8 @@ const copy = { overview: "Uebersicht", maintenance: "Wartungsmodus", uiKit: "UI Kit", + media: "Media", + portfolio: "Portfolio", maintenanceText: "Wenn aktiv, werden alle Seiten auf die Coming Soon Seite weitergeleitet.", maintenanceOn: "Aktiv", maintenanceOff: "Inaktiv", diff --git a/app/root/page.tsx b/app/root/page.tsx index d92b7f2..8a46aa4 100644 --- a/app/root/page.tsx +++ b/app/root/page.tsx @@ -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 { redirect } from "next/navigation"; @@ -49,6 +49,14 @@ const copy = { uiKitTitle: "UI Kit", uiKitDescription: "Globale Referenz fuer Cards, Buttons, Inputs und Surface Levels.", 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", loginText: "Nur autorisierte Nutzer duerfen diesen Bereich verwenden.", passwordLabel: "Passwort", @@ -204,7 +212,7 @@ export default async function RootPage({ searchParams }: RootPageProps) { } >
-
+
@@ -244,6 +252,42 @@ export default async function RootPage({ searchParams }: RootPageProps) { + + + + + {copy.portfolioTitle} + + +

{copy.portfolioTitle}

+

{copy.portfolioDescription}

+ +
+
+
+ + + + + {copy.mediaTitle} + + +

{copy.mediaTitle}

+

{copy.mediaDescription}

+ +
+
+
diff --git a/app/root/portfolio/actions.ts b/app/root/portfolio/actions.ts new file mode 100644 index 0000000..1893a32 --- /dev/null +++ b/app/root/portfolio/actions.ts @@ -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.")); + } +} diff --git a/app/root/portfolio/categories/page.tsx b/app/root/portfolio/categories/page.tsx new file mode 100644 index 0000000..2e0786c --- /dev/null +++ b/app/root/portfolio/categories/page.tsx @@ -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 ( + +
+ + + {searchParams?.success ? ( +

+ {searchParams.success} +

+ ) : null} + + {searchParams?.error ? ( +

+ {searchParams.error} +

+ ) : null} + + + + Neue Kategorie + Eine Kategorie wird genau einem oder mehreren Projekten zugeordnet. + + +
+ +
+ + +
+
+ + +
+ +
+ + + {locales.map((locale) => ( + + {locale.label} + + ))} + + + {locales.map((locale) => ( + +
+
{locale.hint}
+
+ + +
+
+ +