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 { 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<Metadata> {
@@ -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"
>
<p className="text-sm text-muted-foreground/80">
{pickText(item.category, localeKey)} - {item.year}
{getLocalizedValue(item.category.name, localeKey)} - {item.projectYear}
</p>
<h3 className="mt-2 text-lg font-semibold text-foreground">
{pickText(item.title, localeKey)}
{getLocalizedValue(item.title, localeKey)}
</h3>
<p className="mt-2 text-sm text-muted-foreground">
{pickText(item.summary, localeKey)}
{getLocalizedValue(item.summary, localeKey)}
</p>
<span className="mt-4 inline-flex items-center gap-2 text-sm font-medium text-foreground/80 group-hover:text-foreground">
{t("toPortfolio")}
+127 -35
View File
@@ -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 (
<Image
src={src}
alt={alt}
width={width}
height={height}
unoptimized
className={className}
/>
);
}
@@ -34,7 +55,7 @@ export async function generateMetadata({
params: { locale, slug },
}: PortfolioItemPageProps): Promise<Metadata> {
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({
</Button>
<h1 className="mt-5 text-3xl font-semibold text-foreground sm:text-4xl">
{pickText(item.title, localeKey)}
{getLocalizedValue(item.title, localeKey)}
</h1>
<p className="mt-4 text-base text-muted-foreground sm:text-lg">
{pickText(item.summary, localeKey)}
{getLocalizedValue(item.summary, localeKey)}
</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">
{[
{
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({
);
})}
</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>
</AppCard>
</MotionFade>
<div className="grid gap-4 md:grid-cols-3">
{[
{
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)}>
{item.sections.map((section, index) => (
<MotionFade key={section.id} delay={0.05 * (index + 1)}>
<AppCard>
<CardContent className="p-5">
<h2 className="text-lg font-semibold text-foreground">{section.title}</h2>
<p className="mt-2 text-sm text-muted-foreground">{section.text}</p>
<h2 className="text-lg font-semibold text-foreground">
{getLocalizedValue(section.title, localeKey)}
</h2>
<p className="mt-2 whitespace-pre-line text-sm text-muted-foreground">
{getLocalizedValue(section.body, localeKey)}
</p>
{section.imagePath ? (
<PortfolioImage
src={section.imagePath}
alt={getLocalizedValue(section.title, localeKey)}
width={1200}
height={720}
className="mt-4 h-48 w-full rounded-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>
</AppCard>
</MotionFade>
))}
</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>
);
}
+60 -7
View File
@@ -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<Metadata> {
@@ -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 (
<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>
</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">
{portfolioItems.map((item, index) => (
{projects.map((item, index) => (
<MotionFade key={item.slug} delay={index * 0.05}>
<AppCard interactive>
<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">
<p className="text-sm text-muted-foreground/80">
{pickText(item.category, localeKey)}
{getLocalizedValue(item.category.name, localeKey)}
</p>
<p className="inline-flex items-center gap-1 text-xs text-muted-foreground/80">
<CalendarDays className="h-3.5 w-3.5" />
{item.year}
{item.projectYear}
</p>
</div>
<h2 className="mt-3 text-xl font-semibold text-foreground">
{pickText(item.title, localeKey)}
{getLocalizedValue(item.title, localeKey)}
</h2>
<p className="mt-2 text-sm text-muted-foreground">
{pickText(item.summary, localeKey)}
{getLocalizedValue(item.summary, localeKey)}
</p>
<p className="mt-4 inline-flex items-center gap-2 text-sm font-medium text-foreground/80 group-hover:text-foreground">
{t("open")}
@@ -86,6 +131,14 @@ export default async function PortfolioPage({ params: { locale } }: PortfolioPag
</AppCard>
</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>
</Container>
);
+2
View File
@@ -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",
+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 { 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) {
}
>
<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}>
<AppCard level={2}>
<CardHeader>
@@ -244,6 +252,42 @@ export default async function RootPage({ searchParams }: RootPageProps) {
</CardContent>
</AppCard>
</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>
</div>
</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",
overview: "Uebersicht",
uiKit: "UI Kit",
media: "Media",
portfolio: "Portfolio",
logout: "Ausloggen",
backToSite: "Zur Website",
};