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