Refactor portfolio admin and views

This commit is contained in:
MOH
2026-03-11 17:46:35 +01:00
parent 3c53430b12
commit 7956b073e9
27 changed files with 1972 additions and 1779 deletions
+4 -223
View File
@@ -1,22 +1,16 @@
import type { Metadata } from "next";
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 { PageHero } from "@/components/layout/page-hero";
import { MotionFade } from "@/components/motion-fade";
import { PortfolioProjectDetail } from "@/components/site/portfolio-project-detail";
import { buildLocalizedMetadata } from "@/lib/metadata";
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
import { resolveLocale } from "@/lib/locale";
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";
type PortfolioItemPageProps = {
params: {
@@ -27,98 +21,6 @@ type PortfolioItemPageProps = {
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}
/>
);
}
function renderSectionContent(
section: NonNullable<Awaited<ReturnType<typeof getPublishedPortfolioProjectBySlug>>>["sections"][number],
localeKey: ReturnType<typeof resolveLocale>,
t: Awaited<ReturnType<typeof getTranslations>>,
) {
const title = getLocalizedValue(section.title, localeKey);
const body = getLocalizedValue(section.body, localeKey);
if (section.type === "GALLERY") {
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
{section.imagePath ? (
<PortfolioImage
src={section.imagePath}
alt={title}
width={1200}
height={720}
className="h-56 w-full rounded-md object-cover"
/>
) : (
<div className="rounded-md border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground">
No image configured.
</div>
)}
</div>
);
}
if (section.type === "LINK") {
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
{body ? (
<p className="whitespace-pre-line text-sm text-muted-foreground">{body}</p>
) : null}
{section.linkUrl ? (
<Button asChild variant="outline">
<Link href={section.linkUrl} target="_blank" rel="noreferrer">
{t("openLink")}
<ArrowUpRight className="h-4 w-4" />
</Link>
</Button>
) : null}
</div>
);
}
if (section.type === "STATS" || section.type === "DELIVERABLES") {
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
<div className="rounded-md border border-input bg-background p-4">
<p className="whitespace-pre-line text-sm text-muted-foreground">{body}</p>
</div>
</div>
);
}
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
<p className="whitespace-pre-line text-sm text-muted-foreground">{body}</p>
</div>
);
}
export async function generateMetadata({
params: { locale, slug },
}: PortfolioItemPageProps): Promise<Metadata> {
@@ -161,129 +63,8 @@ export default async function PortfolioItemPage({
description={getLocalizedValue(item.summary, localeKey)}
/>
<Container size="wide" className="flex flex-col gap-section pb-12 lg:pb-16">
<MotionFade>
<AppCard level={3}>
<CardContent className="p-6 lg:p-10">
<Button asChild variant="ghost" className="h-auto px-0 py-0 text-sm">
<Link href={getLocalizedPath(localeKey, "/portfolio")}>
<ArrowLeft className="h-4 w-4" />
{t("back")}
</Link>
</Button>
<h1 className="mt-5 text-3xl font-semibold text-foreground sm:text-4xl">
{getLocalizedValue(item.title, localeKey)}
</h1>
<p className="mt-4 text-base text-muted-foreground sm:text-lg">
{getLocalizedValue(item.summary, localeKey)}
</p>
{item.coverImagePath ? (
<div className="mt-6 overflow-hidden rounded-lg border border-border bg-card">
<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: getLocalizedValue(item.category.name, localeKey),
},
{
icon: CalendarDays,
label: String(item.projectYear),
},
{
icon: FolderKanban,
label: getLocalizedValue(item.serviceLabel, localeKey),
},
{
icon: UserRound,
label: item.clientName,
},
].map((meta) => {
const Icon = meta.icon;
return (
<AppCard key={meta.label} level={2}>
<CardContent className="flex items-center gap-2 p-3">
<Icon className="h-4 w-4 text-brand-primary" />
{meta.label}
</CardContent>
</AppCard>
);
})}
</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">
{item.sections.map((section, index) => (
<MotionFade key={section.id} delay={0.05 * (index + 1)}>
<AppCard>
<CardContent className="p-5">
{renderSectionContent(section, localeKey, t)}
</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-lg border border-border bg-card">
{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-muted/40 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 size="wide" className="pb-12 lg:pb-16">
<PortfolioProjectDetail item={item} locale={localeKey} t={t} />
</Container>
</>
);
-1
View File
@@ -46,7 +46,6 @@ export default async function RootMarqueePage() {
logoutAction={logoutAction}
headerTitle={copy.title}
headerDescription={copy.subtitle}
saveFormId="marquee-settings-form"
>
<div className="space-y-6">
<MotionFade delay={0.14}>
+3
View File
@@ -220,6 +220,7 @@ export async function saveProjectAction(formData: FormData) {
id: String(formData.get("id") ?? "").trim() || undefined,
categoryId: String(formData.get("categoryId") ?? ""),
slug: String(formData.get("slug") ?? ""),
viewMode: String(formData.get("viewMode") ?? "GRID"),
titleAr: String(formData.get("titleAr") ?? ""),
titleEn: String(formData.get("titleEn") ?? ""),
titleDe: String(formData.get("titleDe") ?? ""),
@@ -369,6 +370,7 @@ export async function saveProjectAction(formData: FormData) {
data: {
categoryId: parsed.categoryId,
slug: parsed.slug,
viewMode: parsed.viewMode,
titleAr: parsed.titleAr,
titleEn: parsed.titleEn,
titleDe: parsed.titleDe,
@@ -396,6 +398,7 @@ export async function saveProjectAction(formData: FormData) {
data: {
categoryId: parsed.categoryId,
slug: parsed.slug,
viewMode: parsed.viewMode,
titleAr: parsed.titleAr,
titleEn: parsed.titleEn,
titleDe: parsed.titleDe,
+29 -5
View File
@@ -6,6 +6,15 @@ 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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import { getMediaOptions } from "@/lib/media";
import {
@@ -72,8 +81,6 @@ export default async function RootPortfolioProjectPage({
logoutAction={logoutAction}
headerTitle={copy.title}
headerDescription={copy.subtitle}
saveFormId="portfolio-project-form"
saveButtonLabel={copy.saveProject}
>
<div className="space-y-6">
<MotionFade delay={0.15}>
@@ -88,20 +95,37 @@ export default async function RootPortfolioProjectPage({
</MotionFade>
<MotionFade delay={0.2}>
<AppCard>
<CardContent className="flex items-center justify-between gap-4 p-6">
<AppCard level={2}>
<CardContent className="flex flex-col gap-4 p-6 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-sm font-medium text-foreground">{copy.dangerZone}</p>
<p className="mt-1 text-sm text-muted-foreground">
{copy.dangerText}
</p>
</div>
<Dialog>
<DialogTrigger asChild>
<Button type="button" variant="destructive">
{copy.deleteProject}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{copy.deleteProject}</DialogTitle>
<DialogDescription>
This action permanently removes the project data from the database.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<form action={deleteProjectAction}>
<input type="hidden" name="id" value={project.id} />
<Button type="submit" variant="destructive">
{copy.deleteProject}
Confirm Delete
</Button>
</form>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</AppCard>
</MotionFade>
-1
View File
@@ -49,7 +49,6 @@ export default async function RootNewPortfolioProjectPage() {
logoutAction={logoutAction}
headerTitle={copy.title}
headerDescription={copy.subtitle}
saveFormId="portfolio-project-form"
>
<div className="space-y-6">
<MotionFade delay={0.15}>
-1
View File
@@ -54,7 +54,6 @@ export default async function RootSiteSettingsPage() {
logoutAction={logoutAction}
headerTitle={copy.title}
headerDescription={copy.subtitle}
saveFormId="site-settings-form"
>
<div className="space-y-6">
<MotionFade delay={0.16}>
@@ -47,7 +47,6 @@ export default async function RootSMTPProtectionPage() {
logoutAction={logoutAction}
headerTitle={copy.title}
headerDescription={copy.subtitle}
saveFormId="contact-protection-form"
>
<div className="space-y-6">
<MotionFade delay={0.16}>
-1
View File
@@ -48,7 +48,6 @@ export default async function RootSMTPPage() {
logoutAction={logoutAction}
headerTitle={copy.title}
headerDescription={copy.subtitle}
saveFormId="smtp-settings-form"
headerActions={(
<form action={sendTestEmailAction}>
<Button type="submit" variant="outline">
@@ -3,6 +3,7 @@
import { useState } from "react";
import { AppCard } from "@/components/ui/app-card";
import { Button } from "@/components/ui/button";
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
@@ -128,6 +129,9 @@ export function ContactProtectionForm({
</div>
</CardContent>
</AppCard>
<div className="xl:col-span-2 flex justify-end">
<Button type="submit">Save Protection</Button>
</div>
</form>
);
}
-304
View File
@@ -1,304 +0,0 @@
"use client";
import { LoaderCircle, Save } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
type FormSaveButtonProps = {
formId?: string;
formIds?: string[];
formSelector?: string;
formSelectors?: string[];
label?: string;
reloadDocumentOnSuccess?: boolean;
};
function serializeForm(form: HTMLFormElement) {
return JSON.stringify(
Array.from(new FormData(form).entries()).map(([key, value]) => [
key,
value instanceof File ? `${value.name}:${value.size}:${value.type}` : value,
]),
);
}
export function FormSaveButton({
formId,
formIds,
formSelector,
formSelectors,
label = "Speichern",
}: FormSaveButtonProps) {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const [activeFormId, setActiveFormId] = useState<string | null>(formId ?? null);
const [isDirty, setIsDirty] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const baselineRef = useRef<Map<string, string>>(new Map());
const dirtyFormsRef = useRef<Set<string>>(new Set());
const activeFormIdRef = useRef<string | null>(formId ?? null);
const frameRef = useRef<number | null>(null);
const pendingSubmissionRef = useRef<{
formId: string;
originUrl: string;
} | null>(null);
const currentUrl = `${pathname}?${searchParams.toString()}`;
useEffect(() => {
if (!formId && !formIds?.length && !formSelector && !formSelectors?.length) {
activeFormIdRef.current = null;
pendingSubmissionRef.current = null;
setActiveFormId(null);
setIsDirty(false);
setIsSubmitting(false);
dirtyFormsRef.current.clear();
}
}, [formId, formIds, formSelector, formSelectors]);
useEffect(() => {
const formsById = [formId, ...(formIds ?? [])]
.filter((value): value is string => Boolean(value))
.map((id) => document.getElementById(id))
.filter((form): form is HTMLFormElement => form instanceof HTMLFormElement);
const formsBySelector = [formSelector, ...(formSelectors ?? [])]
.filter((value): value is string => Boolean(value))
.flatMap((selector) => Array.from(document.querySelectorAll(selector)))
.filter((form): form is HTMLFormElement => form instanceof HTMLFormElement);
const forms = Array.from(new Map([...formsById, ...formsBySelector].map((form) => [form.id, form])).values());
if (forms.length === 0) {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
baselineRef.current.clear();
dirtyFormsRef.current.clear();
activeFormIdRef.current = formId ?? null;
setActiveFormId(formId ?? null);
setIsDirty(false);
setIsSubmitting(false);
return undefined;
}
const availableIds = forms.map((form) => form.id).filter(Boolean);
const fallbackFormId = availableIds[0] ?? null;
const selectDirtyFormId = () => {
const dirtyIds = availableIds.filter((id) => dirtyFormsRef.current.has(id));
if (dirtyIds.length === 0) {
return activeFormIdRef.current && availableIds.includes(activeFormIdRef.current)
? activeFormIdRef.current
: fallbackFormId;
}
if (activeFormIdRef.current && dirtyFormsRef.current.has(activeFormIdRef.current)) {
return activeFormIdRef.current;
}
return dirtyIds[0] ?? fallbackFormId;
};
const syncDirtyState = () => {
const nextActiveFormId = selectDirtyFormId();
activeFormIdRef.current = nextActiveFormId;
setActiveFormId(nextActiveFormId);
setIsDirty(dirtyFormsRef.current.size > 0);
};
const evaluateForm = (form: HTMLFormElement) => {
const baseline = baselineRef.current.get(form.id);
const current = serializeForm(form);
if (baseline === undefined) {
return;
}
if (current !== baseline) {
dirtyFormsRef.current.add(form.id);
return;
}
dirtyFormsRef.current.delete(form.id);
};
const scheduleSync = (nextActiveFormId?: string) => {
if (nextActiveFormId) {
activeFormIdRef.current = nextActiveFormId;
}
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
}
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null;
for (const form of forms) {
evaluateForm(form);
}
syncDirtyState();
});
};
const syncBaseline = (form: HTMLFormElement) => {
baselineRef.current.set(form.id, serializeForm(form));
dirtyFormsRef.current.delete(form.id);
syncDirtyState();
setIsSubmitting(false);
};
const handleFormActivity = (form: HTMLFormElement) => {
setIsSubmitting(false);
scheduleSync(form.id);
};
const handleSubmit = (form: HTMLFormElement, event: SubmitEvent) => {
if (pendingSubmissionRef.current) {
event.preventDefault();
event.stopPropagation();
return;
}
activeFormIdRef.current = form.id;
pendingSubmissionRef.current = {
formId: form.id,
originUrl: currentUrl,
};
setActiveFormId(form.id);
setIsSubmitting(true);
setIsDirty(false);
};
for (const form of forms) {
if (!form.id) {
continue;
}
syncBaseline(form);
const onFocusIn = () => handleFormActivity(form);
const onInput = () => handleFormActivity(form);
const onChange = () => handleFormActivity(form);
const onReset = () => syncBaseline(form);
const onSubmit = (event: Event) => handleSubmit(form, event as SubmitEvent);
form.addEventListener("focusin", onFocusIn);
form.addEventListener("input", onInput);
form.addEventListener("change", onChange);
form.addEventListener("reset", onReset);
form.addEventListener("submit", onSubmit);
(form as HTMLFormElement & {
__saveButtonHandlers?: {
onFocusIn: () => void;
onInput: () => void;
onChange: () => void;
onReset: () => void;
onSubmit: (event: Event) => void;
};
}).__saveButtonHandlers = { onFocusIn, onInput, onChange, onReset, onSubmit };
}
const nextActive =
activeFormIdRef.current && availableIds.includes(activeFormIdRef.current)
? activeFormIdRef.current
: fallbackFormId;
activeFormIdRef.current = nextActive;
setActiveFormId(nextActive);
for (const form of forms) {
evaluateForm(form);
}
syncDirtyState();
return () => {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
for (const form of forms) {
const handlers = (form as HTMLFormElement & {
__saveButtonHandlers?: {
onFocusIn: () => void;
onInput: () => void;
onChange: () => void;
onReset: () => void;
onSubmit: (event: Event) => void;
};
}).__saveButtonHandlers;
if (!handlers) {
continue;
}
form.removeEventListener("focusin", handlers.onFocusIn);
form.removeEventListener("input", handlers.onInput);
form.removeEventListener("change", handlers.onChange);
form.removeEventListener("reset", handlers.onReset);
form.removeEventListener("submit", handlers.onSubmit);
delete (
form as HTMLFormElement & {
__saveButtonHandlers?: {
onFocusIn: () => void;
onInput: () => void;
onChange: () => void;
onReset: () => void;
onSubmit: (event: Event) => void;
};
}
).__saveButtonHandlers;
}
};
}, [currentUrl, formId, formIds, formSelector, formSelectors, pathname, searchParams]);
useEffect(() => {
const pendingSubmission = pendingSubmissionRef.current;
if (!pendingSubmission) {
return;
}
const hasError = searchParams.has("error");
const hasSuccess = searchParams.has("success") || searchParams.has("__saved");
const navigated = currentUrl !== pendingSubmission.originUrl;
if (hasError) {
pendingSubmissionRef.current = null;
setIsSubmitting(false);
dirtyFormsRef.current.delete(pendingSubmission.formId);
return;
}
if (!hasSuccess && !navigated) {
return;
}
pendingSubmissionRef.current = null;
setIsSubmitting(false);
dirtyFormsRef.current.delete(pendingSubmission.formId);
if (!searchParams.has("__saved")) {
return;
}
const nextParams = new URLSearchParams(searchParams.toString());
nextParams.delete("__saved");
const nextQuery = nextParams.toString();
router.replace(nextQuery ? `${pathname}?${nextQuery}` : pathname, {
scroll: false,
});
}, [currentUrl, pathname, router, searchParams]);
return (
<Button type="submit" form={activeFormId ?? undefined} disabled={!activeFormId || !isDirty || isSubmitting}>
{isSubmitting ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{isSubmitting ? "Speichert..." : label}
</Button>
);
}
@@ -1,4 +1,5 @@
import { AppCard } from "@/components/ui/app-card";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import type { MarqueeSettings } from "@/lib/marquee-settings";
@@ -39,6 +40,9 @@ export function MarqueeSettingsForm({
</AppCard>
))}
</div>
<div className="flex justify-end">
<Button type="submit">Save Marquee</Button>
</div>
</form>
);
}
+95 -159
View File
@@ -3,14 +3,23 @@
/* eslint-disable @next/next/no-img-element */
import type { MediaKind } from "@prisma/client";
import { Check, Link2, Search, Type, Upload } from "lucide-react";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { Check, ImageIcon, Search, Trash2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { MediaOption } from "@/lib/media";
import { cn } from "@/lib/utils";
import { AppCard } from "@/components/ui/app-card";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { MediaOption } from "@/lib/media";
import { cn } from "@/lib/utils";
export type MediaFieldState = {
mode: "upload" | "external" | "library";
@@ -29,11 +38,6 @@ type MediaFieldPickerProps = {
hasInitialValue?: boolean;
inputName: string;
fileFieldName: string;
fileLabel?: string;
externalLabel?: string;
libraryLabel?: string;
accept?: string;
allowExternal?: boolean;
allowClear?: boolean;
clearLabel?: string;
emptyValue?: Partial<MediaFieldState>;
@@ -46,58 +50,32 @@ export function MediaFieldPicker({
options,
hasInitialValue = false,
inputName,
fileFieldName,
fileLabel,
externalLabel,
libraryLabel,
accept,
allowExternal = true,
allowClear = false,
clearLabel = "Remove",
emptyValue,
}: MediaFieldPickerProps) {
const hiddenInputRef = useRef<HTMLInputElement | null>(null);
const searchId = useId();
const [libraryQuery, setLibraryQuery] = useState("");
const modeOptions: Array<MediaFieldState["mode"]> = allowExternal
? ["upload", "external", "library"]
: ["upload", "library"];
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const filteredOptions = options.filter((option) => option.kind === value.kind);
const selectedOption = filteredOptions.find((option) => option.id === value.assetId) ?? null;
const visibleLibraryOptions = useMemo(() => {
const normalizedQuery = libraryQuery.trim().toLowerCase();
const visibleOptions = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) {
return filteredOptions;
}
return filteredOptions.filter((option) => option.label.toLowerCase().includes(normalizedQuery));
}, [filteredOptions, libraryQuery]);
const resolvedLabel =
value.mode === "library"
? selectedOption?.label ?? value.label
: value.label;
}, [filteredOptions, query]);
const serializedValue = JSON.stringify({
mode: value.mode,
assetId: value.assetId,
url: value.url,
label: resolvedLabel,
label: value.label,
kind: value.kind,
});
const previewUrl =
value.isCleared
? ""
: value.mode === "library"
? selectedOption?.url ?? ""
: value.mode === "external"
? value.url
: ""
;
const hasCurrentValue =
!value.isCleared &&
((value.mode === "library" && value.assetId.trim() !== "") ||
(value.mode === "external" && value.url.trim() !== ""));
const canClear = allowClear && (hasCurrentValue || (hasInitialValue && !value.isCleared));
const canClear = allowClear && (Boolean(value.assetId) || (hasInitialValue && !value.isCleared));
useEffect(() => {
const hiddenInput = hiddenInputRef.current;
@@ -111,174 +89,132 @@ export function MediaFieldPicker({
}, [serializedValue]);
return (
<AppCard padding="sm" className="space-y-3">
<div className="space-y-2">
<Label className="sr-only">{title}</Label>
<div className="flex flex-wrap gap-2">
{modeOptions.map((mode) => (
<button
key={mode}
type="button"
onClick={() =>
onChange({
...value,
mode,
assetId: mode === "library" ? value.assetId : "",
url: mode === "external" ? value.url : "",
isCleared: false,
})
}
className={cn(
"rounded-nested border px-4 py-2 text-sm transition-colors",
value.mode === mode
? "border-input bg-primary text-primary-foreground"
: "border-input bg-background text-foreground/75 hover:bg-accent hover:text-accent-foreground",
)}
>
{mode}
</button>
))}
<AppCard level={2} padding="sm" className="space-y-4">
<input ref={hiddenInputRef} type="hidden" name={inputName} value={serializedValue} />
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label className="text-sm font-semibold text-foreground">{title}</Label>
<p className="text-sm text-muted-foreground">
Media must be selected from the
{" "}
Media Library
.
</p>
</div>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={() => setOpen(true)}>
<ImageIcon className="h-4 w-4" />
Select from Media
</Button>
{canClear ? (
<button
<Button
type="button"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() =>
onChange({
...value,
mode: emptyValue?.mode ?? "upload",
assetId: emptyValue?.assetId ?? "",
url: emptyValue?.url ?? "",
label: emptyValue?.label ?? value.label,
label: emptyValue?.label ?? "",
isCleared: true,
})
}
className="rounded-nested border border-destructive/25 px-4 py-2 text-sm text-destructive transition-colors hover:bg-destructive/5"
>
<Trash2 className="h-4 w-4" />
{clearLabel}
</button>
</Button>
) : null}
</div>
</div>
<input
ref={hiddenInputRef}
type="hidden"
name={inputName}
value={serializedValue}
<AppCard padding="sm" className="rounded-nested border-border/70">
{selectedOption ? (
<div className="flex items-center gap-4">
<img
src={selectedOption.url}
alt={selectedOption.label}
className="h-16 w-16 rounded-nested object-cover"
/>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">{selectedOption.label}</p>
<p className="truncate text-xs text-muted-foreground">{selectedOption.source}</p>
</div>
</div>
) : (
<div className="rounded-nested border border-dashed border-border/70 px-4 py-6 text-sm text-muted-foreground">
No media selected.
</div>
)}
</AppCard>
{value.mode !== "library" ? (
<div className="space-y-2">
<Label className="sr-only">Label</Label>
<div className="relative">
<Type className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={value.label}
onChange={(event) => onChange({ ...value, label: event.target.value, isCleared: false })}
placeholder="Label"
className="pl-9"
/>
</div>
</div>
) : null}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>Select an existing item from the media library.</DialogDescription>
</DialogHeader>
{value.mode === "upload" ? (
<div className="space-y-2">
<Label className="sr-only">{fileLabel ?? fileFieldName}</Label>
<div className="relative">
<Upload className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input name={fileFieldName} type="file" accept={accept} className="pl-9 file:mr-3" />
</div>
</div>
) : null}
{value.mode === "external" ? (
<div className="space-y-2">
<Label className="sr-only">{externalLabel ?? "External URL"}</Label>
<div className="relative">
<Link2 className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={value.url}
onChange={(event) => onChange({ ...value, url: event.target.value, isCleared: false })}
placeholder={externalLabel ?? "External URL"}
className="pl-9"
/>
</div>
</div>
) : null}
{value.mode === "library" ? (
<div className="space-y-3">
<Label className="sr-only">{libraryLabel ?? "Media Library"}</Label>
<div className="space-y-2 rounded-nested border border-border/80 bg-card p-3">
<div className="space-y-4">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id={searchId}
value={libraryQuery}
onChange={(event) => setLibraryQuery(event.target.value)}
className="pl-9"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search media"
className="pl-9"
/>
</div>
<div className="max-h-64 space-y-2 overflow-y-auto">
{visibleLibraryOptions.length > 0 ? (
visibleLibraryOptions.map((option) => {
<div className="grid max-h-[55vh] gap-3 overflow-y-auto md:grid-cols-2 xl:grid-cols-3">
{visibleOptions.map((option) => {
const isActive = option.id === value.assetId;
return (
<button
key={option.id}
type="button"
onClick={() =>
onClick={() => {
onChange({
...value,
mode: "library",
assetId: option.id,
url: option.url,
label: option.label,
isCleared: false,
})
}
});
setOpen(false);
}}
className={cn(
"flex w-full items-center gap-3 rounded-nested border px-3 py-2 text-left transition-colors",
"overflow-hidden rounded-surface border text-left transition-colors",
isActive
? "border-input bg-accent/40"
: "border-input bg-background hover:bg-accent/20",
? "border-input bg-accent/20"
: "border-border/70 bg-card hover:border-input hover:bg-accent/10",
)}
>
{option.url ? (
<img src={option.url} alt={option.label} className="h-12 w-12 rounded-nested object-cover" />
) : (
<div className="h-12 w-12 rounded-nested border border-border bg-background" />
)}
<div className="min-w-0 flex-1">
<img src={option.url} alt={option.label} className="h-40 w-full object-cover" />
<div className="flex items-center justify-between gap-3 p-4">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">{option.label}</p>
<p className="truncate text-xs text-muted-foreground">{option.source}</p>
</div>
{isActive ? <Check className="h-4 w-4 text-brand-primary" /> : null}
</div>
</button>
);
})
) : (
<div className="rounded-nested border border-dashed border-input px-3 py-4 text-sm text-muted-foreground">
No media found.
</div>
)}
})}
</div>
</div>
</div>
) : null}
{previewUrl ? (
value.kind === "IMAGE" ? (
<AppCard className="overflow-hidden rounded-nested">
<img src={previewUrl} alt={value.label || title} className="h-40 w-full object-cover" />
</AppCard>
) : (
<AppCard className="rounded-nested px-4 py-3 text-sm text-muted-foreground">
{previewUrl}
</AppCard>
)
) : null}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</AppCard>
);
}
+188 -113
View File
@@ -2,7 +2,16 @@
import { useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { FileText, FolderPlus, Hash, Layers3, Pencil, Text, Trash2 } from "lucide-react";
import {
FileText,
FolderPlus,
Hash,
Layers3,
Pencil,
Sparkles,
Text,
Trash2,
} from "lucide-react";
import type { deleteCategoryAction, upsertCategoryAction } from "@/app/root/portfolio/actions";
import { StatsCard } from "@/components/dashboard/stats-card";
@@ -23,35 +32,48 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import type { PortfolioCategoryView } from "@/lib/portfolio";
const locales = [
{ key: "Ar", lowerKey: "ar" as const, label: "Arabic" },
{ key: "En", lowerKey: "en" as const, label: "English" },
{ key: "De", lowerKey: "de" as const, label: "German" },
{ key: "Ar", label: "Arabic" },
{ key: "En", label: "English" },
{ key: "De", label: "German" },
] as const;
const copy = {
addCategory: "Kategorie hinzufuegen",
saveCategory: "Kategorie speichern",
save: "Speichern",
delete: "Loeschen",
active: "Aktiv",
sortOrder: "Sortierung",
projects: "Projekte",
description: "Beschreibung",
currentCategories: "Aktuelle Kategorien",
modalDescription: "Neue Kategorie direkt im Popup anlegen.",
editDescription: "Kategorie im Popup aendern oder loeschen.",
empty: "Noch keine Kategorien vorhanden.",
deleteBlocked: "Loeschen erst moeglich, wenn keine Projekte mehr zugeordnet sind.",
editCategory: "Kategorie bearbeiten",
addCategory: "Add Category",
saveCategory: "Save Category",
save: "Save",
delete: "Delete",
active: "Active",
sortOrder: "Sort Order",
projects: "Projects",
description: "Description",
currentCategories: "Current Categories",
modalDescription: "Create a new category with a faster flow for basics, localization, and status.",
editDescription: "Update category content, change status, or remove the category if it has no assigned projects.",
empty: "No categories yet.",
deleteBlocked: "Delete becomes available only when no projects are assigned.",
editCategory: "Edit Category",
};
type CategoryAction = typeof upsertCategoryAction;
type CategoryDeleteAction = typeof deleteCategoryAction;
type CategoryFormValues = {
slug?: string;
sortOrder?: number;
isActive?: boolean;
nameAr?: string;
nameEn?: string;
nameDe?: string;
descriptionAr?: string;
descriptionEn?: string;
descriptionDe?: string;
};
type PortfolioCategoriesManagerProps = {
categories: Array<PortfolioCategoryView & { projectCount: number }>;
activeCount: number;
@@ -65,16 +87,17 @@ function CategoryLocaleFields({
values,
}: {
idPrefix: string;
values?: {
nameAr?: string;
nameEn?: string;
nameDe?: string;
descriptionAr?: string;
descriptionEn?: string;
descriptionDe?: string;
};
values?: CategoryFormValues;
}) {
return (
<div className="space-y-4">
<div className="space-y-1">
<p className="text-sm font-semibold text-foreground">Localized Content</p>
<p className="text-sm text-muted-foreground">
Keep names and descriptions ready in all supported locales.
</p>
</div>
<div className="grid gap-4 xl:grid-cols-3">
{locales.map((locale) => {
const nameKey = `name${locale.key}` as const;
@@ -82,12 +105,10 @@ function CategoryLocaleFields({
return (
<AppCard key={`${idPrefix}-${locale.key}`} level={2} padding="sm" className="space-y-4 rounded-nested">
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">{locale.label}</p>
</div>
<div className="space-y-2">
<Label htmlFor={`${idPrefix}-${nameKey}`} className="sr-only">{`Name ${locale.label}`}</Label>
<Label htmlFor={`${idPrefix}-${nameKey}`}>Name</Label>
<div className="relative">
<Text className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
@@ -102,7 +123,7 @@ function CategoryLocaleFields({
</div>
<div className="space-y-2">
<Label htmlFor={`${idPrefix}-${descriptionKey}`} className="sr-only">{`${copy.description} ${locale.label}`}</Label>
<Label htmlFor={`${idPrefix}-${descriptionKey}`}>{copy.description}</Label>
<div className="relative">
<FileText className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Textarea
@@ -120,6 +141,77 @@ function CategoryLocaleFields({
);
})}
</div>
</div>
);
}
function CategoryForm({
formId,
action,
values,
categoryId,
}: {
formId: string;
action: CategoryAction;
values?: CategoryFormValues;
categoryId?: string;
}) {
return (
<form id={formId} action={action} className="space-y-6">
{categoryId ? <input type="hidden" name="id" value={categoryId} /> : null}
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
<div className="space-y-4">
<div className="space-y-1">
<p className="text-sm font-semibold text-foreground">Basics</p>
<p className="text-sm text-muted-foreground">
Set the stable identifier and display order first.
</p>
</div>
<div className="grid gap-4 lg:grid-cols-3">
<div className="space-y-2">
<Label htmlFor={`${formId}-slug`}>Slug</Label>
<div className="relative">
<Text className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id={`${formId}-slug`}
name="slug"
defaultValue={values?.slug ?? ""}
required
placeholder="Slug"
className="pl-9"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`${formId}-sortOrder`}>{copy.sortOrder}</Label>
<div className="relative">
<Hash className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id={`${formId}-sortOrder`}
name="sortOrder"
type="number"
min="0"
defaultValue={values?.sortOrder ?? 0}
required
className="pl-9"
/>
</div>
</div>
<label className="flex items-center gap-3 rounded-nested border border-input bg-card px-4 py-3 text-sm">
<Checkbox name="isActive" defaultChecked={values?.isActive ?? true} />
{copy.active}
</label>
</div>
</div>
<Separator />
<CategoryLocaleFields idPrefix={formId} values={values} />
</form>
);
}
@@ -136,6 +228,8 @@ function EditCategoryDialog({
saveCategoryAction: CategoryAction;
removeCategoryAction: CategoryDeleteAction;
}) {
const formId = `portfolio-category-form-${category.id}`;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
@@ -144,44 +238,33 @@ function EditCategoryDialog({
<DialogDescription>{copy.editDescription}</DialogDescription>
</DialogHeader>
<form id={`portfolio-category-form-${category.id}`} action={saveCategoryAction} className="space-y-6">
<input type="hidden" name="id" value={category.id} />
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
<div className="grid gap-4 lg:grid-cols-3">
<div className="space-y-2">
<Label htmlFor={`slug-${category.id}`}>Slug</Label>
<div className="relative">
<Text className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input id={`slug-${category.id}`} name="slug" defaultValue={category.slug} required className="pl-9" />
<div className="grid gap-3 sm:grid-cols-3">
<AppCard level={2} padding="sm" className="rounded-nested">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Projects</p>
<p className="mt-2 text-lg font-semibold text-foreground">{category.projectCount}</p>
</AppCard>
<AppCard level={2} padding="sm" className="rounded-nested">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Status</p>
<div className="mt-2">
<Badge variant={category.isActive ? "success" : "warning"}>
{category.isActive ? "Active" : "Inactive"}
</Badge>
</div>
</AppCard>
<AppCard level={2} padding="sm" className="rounded-nested">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Order</p>
<p className="mt-2 text-lg font-semibold text-foreground">{category.sortOrder}</p>
</AppCard>
</div>
<div className="space-y-2">
<Label htmlFor={`sortOrder-${category.id}`}>{copy.sortOrder}</Label>
<div className="relative">
<Hash className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id={`sortOrder-${category.id}`}
name="sortOrder"
type="number"
min="0"
defaultValue={category.sortOrder}
required
className="pl-9"
/>
</div>
</div>
<label className="flex items-center gap-3 rounded-nested border border-input bg-card px-4 py-3 text-sm">
<Checkbox name="isActive" defaultChecked={category.isActive} />
{copy.active}
</label>
</div>
<CategoryLocaleFields
idPrefix={`category-${category.id}`}
<CategoryForm
formId={formId}
action={saveCategoryAction}
categoryId={category.id}
values={{
slug: category.slug,
sortOrder: category.sortOrder,
isActive: category.isActive,
nameAr: category.name.ar,
nameEn: category.name.en,
nameDe: category.name.de,
@@ -190,11 +273,10 @@ function EditCategoryDialog({
descriptionDe: category.description.de,
}}
/>
</form>
<DialogFooter className="items-center justify-between sm:flex-row">
<p className="text-sm text-muted-foreground">
{category.projectCount > 0 ? copy.deleteBlocked : "Kategorie kann geloescht werden."}
{category.projectCount > 0 ? copy.deleteBlocked : "Category can be deleted."}
</p>
<div className="flex w-full flex-col-reverse gap-2 sm:w-auto sm:flex-row">
<form action={removeCategoryAction}>
@@ -205,7 +287,7 @@ function EditCategoryDialog({
{copy.delete}
</Button>
</form>
<Button type="submit" form={`portfolio-category-form-${category.id}`}>
<Button type="submit" form={formId}>
{copy.save}
</Button>
</div>
@@ -256,38 +338,34 @@ export function PortfolioCategoriesManager({
<DialogDescription>{copy.modalDescription}</DialogDescription>
</DialogHeader>
<form id="portfolio-category-create-form" action={saveCategoryAction} className="space-y-6">
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
<div className="grid gap-4 lg:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="create-slug">Slug</Label>
<div className="relative">
<Text className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input id="create-slug" name="slug" required placeholder="Slug" className="pl-9" />
</div>
<div className="grid gap-3 sm:grid-cols-3">
<AppCard level={2} padding="sm" className="rounded-nested">
<Sparkles className="h-4 w-4 text-brand-primary" />
<p className="mt-3 text-sm font-medium text-foreground">Start with basics</p>
<p className="mt-1 text-sm text-muted-foreground">Slug and sort order first.</p>
</AppCard>
<AppCard level={2} padding="sm" className="rounded-nested">
<Layers3 className="h-4 w-4 text-brand-primary" />
<p className="mt-3 text-sm font-medium text-foreground">Fill all locales</p>
<p className="mt-1 text-sm text-muted-foreground">Keep names and descriptions complete.</p>
</AppCard>
<AppCard level={2} padding="sm" className="rounded-nested">
<Pencil className="h-4 w-4 text-brand-primary" />
<p className="mt-3 text-sm font-medium text-foreground">Publish when ready</p>
<p className="mt-1 text-sm text-muted-foreground">Categories stay manageable from day one.</p>
</AppCard>
</div>
<div className="space-y-2">
<Label htmlFor="create-sortOrder">{copy.sortOrder}</Label>
<div className="relative">
<Hash className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input id="create-sortOrder" name="sortOrder" type="number" min="0" defaultValue="0" required className="pl-9" />
</div>
</div>
<label className="flex items-center gap-3 rounded-nested border border-input bg-card px-4 py-3 text-sm">
<Checkbox name="isActive" defaultChecked />
{copy.active}
</label>
</div>
<CategoryLocaleFields idPrefix="create" />
<CategoryForm
formId="portfolio-category-create-form"
action={saveCategoryAction}
/>
<DialogFooter>
<Button type="submit">{copy.saveCategory}</Button>
<Button type="submit" form="portfolio-category-create-form">
{copy.saveCategory}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</CardContent>
@@ -307,54 +385,50 @@ export function PortfolioCategoriesManager({
{categories.map((category) => (
<AccordionItem key={category.id} value={category.id}>
<AccordionTrigger className="bg-surface-2 hover:no-underline">
<div className="flex min-w-0 flex-1 flex-col gap-2 text-left lg:flex-row lg:items-center lg:justify-between">
<div className="flex min-w-0 flex-1 flex-col gap-3 text-left lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-base font-semibold text-foreground">
{category.name.de || category.name.en || category.name.ar}
</span>
<Badge variant={category.isActive ? "success" : "warning"}>
{category.isActive ? "Aktiv" : "Inaktiv"}
{category.isActive ? "Active" : "Inactive"}
</Badge>
</div>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>{category.slug}</span>
<span></span>
<span>{copy.sortOrder} {category.sortOrder}</span>
<span>{copy.projects}: {category.projectCount}</span>
<span>{copy.sortOrder}: {category.sortOrder}</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{category.projectCount} {copy.projects}</Badge>
<Button
type="button"
variant="outline"
size="sm"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setEditingCategoryId(category.id);
}}
>
<Pencil className="h-3.5 w-3.5" />
{copy.editCategory}
<Pencil className="h-4 w-4" />
Edit
</Button>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="bg-background">
<div className="space-y-3">
<div className="grid gap-3 md:grid-cols-3">
<AccordionContent className="space-y-4">
<div className="grid gap-4 xl:grid-cols-3">
{locales.map((locale) => (
<AppCard key={`${category.id}-${locale.key}`} level={1} padding="sm" className="rounded-nested">
<AppCard key={`${category.id}-${locale.key}`} level={2} padding="sm" className="space-y-3 rounded-nested">
<p className="text-sm font-medium text-foreground">{locale.label}</p>
<p className="mt-3 text-sm text-foreground">{category.name[locale.lowerKey]}</p>
<p className="mt-2 text-sm text-muted-foreground">{category.description[locale.lowerKey]}</p>
<p className="text-sm font-semibold text-foreground">
{category.name[locale.key.toLowerCase() as "ar" | "en" | "de"]}
</p>
<p className="text-sm leading-6 text-muted-foreground">
{category.description[locale.key.toLowerCase() as "ar" | "en" | "de"]}
</p>
</AppCard>
))}
</div>
</div>
</AccordionContent>
<EditCategoryDialog
category={category}
@@ -363,6 +437,7 @@ export function PortfolioCategoriesManager({
saveCategoryAction={saveCategoryAction}
removeCategoryAction={removeCategoryAction}
/>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
@@ -0,0 +1,80 @@
"use client";
import { FolderKanban, MoreVertical, Trash2 } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { deleteProjectAction } from "@/app/root/portfolio/actions";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const copy = {
editProject: "Bearbeiten",
deleteProject: "Loeschen",
};
export function PortfolioProjectActions({ projectId }: { projectId: string }) {
const [confirmOpen, setConfirmOpen] = useState(false);
return (
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" size="icon">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/root/portfolio/projects/${projectId}`}>
<FolderKanban className="mr-2 h-4 w-4" />
{copy.editProject}
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={(event) => {
event.preventDefault();
setConfirmOpen(true);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
{copy.deleteProject}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DialogContent>
<DialogHeader>
<DialogTitle>{copy.deleteProject}</DialogTitle>
<DialogDescription>
This action permanently removes the project from the portfolio.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<form action={deleteProjectAction}>
<input type="hidden" name="id" value={projectId} />
<Button type="submit" variant="destructive">
Confirm Delete
</Button>
</form>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
File diff suppressed because it is too large Load Diff
+35 -94
View File
@@ -1,20 +1,13 @@
import { ExternalLink, Filter, FolderKanban, Plus, Tags } from "lucide-react";
import { ExternalLink, Plus, Tags } from "lucide-react";
import Link from "next/link";
import { StatsCard } from "@/components/dashboard/stats-card";
import { MotionFade } from "@/components/motion-fade";
import { PortfolioProjectActions } from "@/components/root/portfolio-project-actions";
import { AppCard } from "@/components/ui/app-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { CardContent } from "@/components/ui/card";
import { getLocalizedPath } from "@/lib/locale";
import { getLocalizedValue, type PortfolioCategoryView, type PortfolioProjectView } from "@/lib/portfolio";
@@ -26,16 +19,10 @@ type PortfolioProjectsOverviewProps = {
};
const copy = {
category: "Kategorie",
all: "Alle",
status: "Status",
draft: "Entwurf",
published: "Veroeffentlicht",
filter: "Filtern",
newProject: "Neues Projekt",
newCategory: "Neues Kategorie",
openProject: "Ansehen",
editProject: "Bearbeiten",
untitled: "Unbenanntes Projekt",
empty: "Noch keine Projekte vorhanden.",
};
@@ -44,14 +31,11 @@ export function PortfolioProjectsOverview({
categories,
projects,
selectedCategory,
selectedStatus,
}: PortfolioProjectsOverviewProps) {
return (
<div className="space-y-6">
<MotionFade delay={0.05}>
<AppCard level={3}>
<CardContent className="flex flex-col gap-6 p-6 lg:flex-row lg:items-end lg:justify-between lg:p-8">
<div className="grid gap-4 sm:grid-cols-3">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="grid gap-4 md:grid-cols-3 xl:min-w-[520px]">
<StatsCard title="Projects" value={String(projects.length)} />
<StatsCard title="Categories" value={String(categories.length)} />
<StatsCard
@@ -60,7 +44,7 @@ export function PortfolioProjectsOverview({
/>
</div>
<div className="flex flex-wrap gap-3">
<div className="flex flex-wrap gap-2">
<Button asChild>
<Link href="/root/portfolio/projects/new">
<Plus className="h-4 w-4" />
@@ -74,82 +58,44 @@ export function PortfolioProjectsOverview({
</Link>
</Button>
</div>
</CardContent>
</AppCard>
</MotionFade>
<MotionFade delay={0.1}>
<AppCard>
<CardContent className="p-6">
<form className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
<div className="space-y-2">
<Label htmlFor="portfolio-filter-category" className="sr-only">
{copy.category}
</Label>
<div className="relative">
<Tags className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Select name="category" defaultValue={selectedCategory || "__all__"}>
<SelectTrigger id="portfolio-filter-category" className="pl-9">
<SelectValue placeholder={copy.category} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">{copy.all}</SelectItem>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name.de}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="portfolio-filter-status" className="sr-only">
{copy.status}
</Label>
<div className="relative">
<Filter className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Select name="status" defaultValue={selectedStatus}>
<SelectTrigger id="portfolio-filter-status" className="pl-9">
<SelectValue placeholder={copy.status} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{copy.all}</SelectItem>
<SelectItem value="draft">{copy.draft}</SelectItem>
<SelectItem value="published">{copy.published}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-end">
<Button type="submit" variant="outline">
{copy.filter}
<div className="flex flex-wrap gap-2">
<Button asChild variant={selectedCategory === "" ? "default" : "outline"}>
<Link href="/root/portfolio">{copy.all}</Link>
</Button>
{categories.map((category) => (
<Button
key={category.id}
asChild
variant={selectedCategory === category.id ? "default" : "outline"}
>
<Link href={`/root/portfolio?category=${category.id}`}>
{category.name.de || category.name.en || category.name.ar}
</Link>
</Button>
))}
</div>
</form>
</CardContent>
</AppCard>
</MotionFade>
<div className="grid gap-4">
{projects.map((project, index) => (
<MotionFade key={project.id} delay={0.14 + index * 0.03}>
<MotionFade key={project.id} delay={0.06 + index * 0.03}>
<AppCard interactive>
<CardHeader className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1">
<CardContent className="flex flex-col gap-4 p-5 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-xl">
<p className="text-xl font-semibold text-foreground">
{getLocalizedValue(project.title, "de") || copy.untitled}
</CardTitle>
<Badge variant={project.isPublished ? "success" : "warning"}>
{project.isPublished ? copy.published : copy.draft}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{project.category.name.de}
</p>
<Badge variant={project.isPublished ? "success" : "warning"}>
{project.isPublished ? "Published" : "Draft"}
</Badge>
<Badge variant="outline">{project.viewMode}</Badge>
</div>
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
<span>{project.category.name.de || project.category.name.en || project.category.name.ar}</span>
<span>{project.projectYear}</span>
</div>
</div>
<div className="flex flex-wrap gap-3">
@@ -163,14 +109,9 @@ export function PortfolioProjectsOverview({
{copy.openProject}
</Link>
</Button>
<Button asChild>
<Link href={`/root/portfolio/projects/${project.id}`}>
<FolderKanban className="h-4 w-4" />
{copy.editProject}
</Link>
</Button>
<PortfolioProjectActions projectId={project.id} />
</div>
</CardHeader>
</CardContent>
</AppCard>
</MotionFade>
))}
+11 -32
View File
@@ -15,7 +15,6 @@ import Link from "next/link";
import { DashboardLayout } from "@/components/dashboard/dashboard-layout";
import { MotionFade } from "@/components/motion-fade";
import { FormSaveButton } from "@/components/root/form-save-button";
import { SidebarMaintenanceControl } from "@/components/root/sidebar-maintenance-control";
import { SoundToggle } from "@/components/sound-toggle";
import { ThemeToggle } from "@/components/theme-toggle";
@@ -50,10 +49,6 @@ type RootDashboardShellProps = {
logoutAction: () => Promise<void>;
headerTitle: string;
headerDescription: string;
saveFormId?: string;
saveFormSelector?: string;
saveButtonLabel?: string;
reloadDocumentOnSave?: boolean;
headerActions?: ReactNode;
sidebarTopContent?: ReactNode;
toolbar?: ReactNode;
@@ -68,10 +63,6 @@ export async function RootDashboardShell({
logoutAction,
headerTitle,
headerDescription,
saveFormId,
saveFormSelector,
saveButtonLabel,
reloadDocumentOnSave = false,
headerActions,
sidebarTopContent,
toolbar,
@@ -112,28 +103,6 @@ export async function RootDashboardShell({
: portfolioChild === "new-project"
? PlusSquare
: FolderKanban;
const sharedActions = (
<>
<FormSaveButton
formIds={saveFormId ? ["sidebar-maintenance-form", saveFormId] : ["sidebar-maintenance-form"]}
formSelectors={saveFormSelector ? [saveFormSelector] : undefined}
label={saveButtonLabel ?? "Speichern"}
reloadDocumentOnSuccess={reloadDocumentOnSave}
/>
<SoundToggle
ariaLabel="Mute sounds"
mutedAriaLabel="Unmute sounds"
mutedToastLabel="Sound muted"
unmutedToastLabel="Sound enabled"
/>
<ThemeToggle
ariaLabel="Theme wechseln"
lightToastLabel="Light mode enabled"
darkToastLabel="Dark mode enabled"
/>
</>
);
return (
<DashboardLayout
title={headerTitle}
@@ -175,7 +144,17 @@ export async function RootDashboardShell({
headerActions={
<div className="flex flex-wrap items-center justify-end gap-2">
{headerActions}
{sharedActions}
<SoundToggle
ariaLabel="Mute sounds"
mutedAriaLabel="Unmute sounds"
mutedToastLabel="Sound muted"
unmutedToastLabel="Sound enabled"
/>
<ThemeToggle
ariaLabel="Theme wechseln"
lightToastLabel="Light mode enabled"
darkToastLabel="Dark mode enabled"
/>
</div>
}
>
+3
View File
@@ -728,6 +728,9 @@ export function SiteSettingsForm({
</section>
</div>
</div>
<div className="flex justify-end">
<Button type="submit">Save Settings</Button>
</div>
</form>
);
}
+4
View File
@@ -4,6 +4,7 @@ import { KeyRound, Mail, Send, Server } from "lucide-react";
import { useState } from "react";
import { AppCard } from "@/components/ui/app-card";
import { Button } from "@/components/ui/button";
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
@@ -171,6 +172,9 @@ export function SMTPSettingsForm({
</CardContent>
</AppCard>
</div>
<div className="flex justify-end">
<Button type="submit">Save SMTP</Button>
</div>
</form>
);
}
@@ -0,0 +1,457 @@
import {
ArrowUpRight,
CalendarDays,
FolderKanban,
Tag,
UserRound,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { MotionFade } from "@/components/motion-fade";
import { AppCard } from "@/components/ui/app-card";
import { Button } from "@/components/ui/button";
import { CardContent } from "@/components/ui/card";
import { getLocalizedPath, type AppLocale } from "@/lib/locale";
import {
getLocalizedValue,
resolvePortfolioProjectViewMode,
type PortfolioAssetView,
type PortfolioProjectView,
type PortfolioSectionView,
} from "@/lib/portfolio";
type PortfolioProjectDetailProps = {
item: PortfolioProjectView;
locale: AppLocale;
t: (key: "back" | "preview" | "openLink" | "gallery" | "download") => string;
};
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}
/>
);
}
function ProjectMeta({
item,
locale,
}: {
item: PortfolioProjectView;
locale: AppLocale;
}) {
const metadata = [
{
icon: Tag,
label: getLocalizedValue(item.category.name, locale),
},
{
icon: CalendarDays,
label: String(item.projectYear),
},
{
icon: FolderKanban,
label: getLocalizedValue(item.serviceLabel, locale),
},
{
icon: UserRound,
label: item.clientName,
},
].filter((entry) => entry.label);
return (
<div className="flex flex-wrap gap-3 text-sm text-foreground/80">
{metadata.map((meta) => {
const Icon = meta.icon;
return (
<AppCard key={`${meta.label}-${Icon.name}`} level={2}>
<CardContent className="flex items-center gap-2 p-3">
<Icon className="h-4 w-4 text-brand-primary" />
{meta.label}
</CardContent>
</AppCard>
);
})}
</div>
);
}
function SectionBlock({
section,
locale,
t,
}: {
section: PortfolioSectionView;
locale: AppLocale;
t: PortfolioProjectDetailProps["t"];
}) {
const title = getLocalizedValue(section.title, locale);
const body = getLocalizedValue(section.body, locale);
if (section.type === "GALLERY") {
return (
<div className="space-y-4">
<h3 className="text-xl font-semibold text-foreground">{title}</h3>
{section.imagePath ? (
<PortfolioImage
src={section.imagePath}
alt={title}
width={1400}
height={880}
className="h-72 w-full rounded-surface object-cover"
/>
) : (
<div className="rounded-surface border border-dashed border-border px-4 py-12 text-center text-sm text-muted-foreground">
No image configured.
</div>
)}
</div>
);
}
if (section.type === "LINK") {
return (
<div className="space-y-4">
<h3 className="text-xl font-semibold text-foreground">{title}</h3>
{body ? <p className="whitespace-pre-line text-sm leading-7 text-muted-foreground">{body}</p> : null}
{section.linkUrl ? (
<Button asChild variant="outline">
<Link href={section.linkUrl} target="_blank" rel="noreferrer">
{t("openLink")}
<ArrowUpRight className="h-4 w-4" />
</Link>
</Button>
) : null}
</div>
);
}
return (
<div className="space-y-4">
<h3 className="text-xl font-semibold text-foreground">{title}</h3>
<p className="whitespace-pre-line text-sm leading-7 text-muted-foreground">{body}</p>
</div>
);
}
function AssetGallery({
assets,
locale,
t,
}: {
assets: PortfolioAssetView[];
locale: AppLocale;
t: PortfolioProjectDetailProps["t"];
}) {
if (assets.length === 0) {
return null;
}
return (
<MotionFade delay={0.12}>
<AppCard>
<CardContent className="p-6 lg:p-8">
<div className="flex items-end justify-between gap-4">
<div>
<p className="text-sm uppercase tracking-[0.18em] text-muted-foreground">Assets</p>
<h2 className="mt-2 text-2xl font-semibold text-foreground">{t("gallery")}</h2>
</div>
<BadgeCount count={assets.length} />
</div>
<div className="mt-6 grid gap-4 md:grid-cols-2">
{assets.map((asset) => (
<div key={asset.id} className="overflow-hidden rounded-surface border border-border bg-card">
{asset.kind === "IMAGE" ? (
<PortfolioImage
src={asset.filePath}
alt={getLocalizedValue(asset.alt, locale)}
width={1200}
height={760}
className="h-72 w-full object-cover"
/>
) : (
<div className="flex min-h-72 items-center justify-center bg-muted/30 p-6 text-center">
<div className="space-y-3">
<p className="text-sm font-medium text-foreground">
{getLocalizedValue(asset.alt, locale)}
</p>
<Button asChild variant="outline">
<Link href={asset.filePath} target="_blank" rel="noreferrer">
{t("download")}
</Link>
</Button>
</div>
</div>
)}
</div>
))}
</div>
</CardContent>
</AppCard>
</MotionFade>
);
}
function BadgeCount({ count }: { count: number }) {
return (
<div className="rounded-pill border border-border/70 bg-background px-4 py-2 text-sm text-muted-foreground">
{count}
</div>
);
}
function ProjectHeader({
item,
locale,
t,
}: {
item: PortfolioProjectView;
locale: AppLocale;
t: PortfolioProjectDetailProps["t"];
}) {
return (
<MotionFade>
<AppCard level={3}>
<CardContent className="p-6 lg:p-10">
<Button asChild variant="ghost" className="h-auto px-0 py-0 text-sm">
<Link href={getLocalizedPath(locale, "/portfolio")}>{t("back")}</Link>
</Button>
<h1 className="mt-5 text-3xl font-semibold text-foreground sm:text-4xl lg:text-5xl">
{getLocalizedValue(item.title, locale)}
</h1>
<p className="mt-4 max-w-3xl text-base leading-8 text-muted-foreground sm:text-lg">
{getLocalizedValue(item.summary, locale)}
</p>
<div className="mt-8">
<ProjectMeta item={item} locale={locale} />
</div>
{item.previewUrl ? (
<div className="mt-8">
<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>
);
}
function GridTemplate({
item,
locale,
t,
}: PortfolioProjectDetailProps) {
return (
<div className="space-y-6">
<ProjectHeader item={item} locale={locale} t={t} />
{item.coverImagePath ? (
<MotionFade delay={0.04}>
<AppCard>
<CardContent className="p-3 lg:p-4">
<PortfolioImage
src={item.coverImagePath}
alt={getLocalizedValue(item.title, locale)}
width={1800}
height={1000}
className="h-auto w-full rounded-surface object-cover"
/>
</CardContent>
</AppCard>
</MotionFade>
) : null}
<div className="grid gap-4 lg:grid-cols-2">
{item.sections.map((section, index) => (
<MotionFade key={section.id} delay={0.06 * (index + 1)}>
<AppCard>
<CardContent className="p-5 lg:p-6">
<SectionBlock section={section} locale={locale} t={t} />
</CardContent>
</AppCard>
</MotionFade>
))}
</div>
<AssetGallery assets={item.assets} locale={locale} t={t} />
</div>
);
}
function StoryTemplate({
item,
locale,
t,
}: PortfolioProjectDetailProps) {
return (
<div className="space-y-8">
<ProjectHeader item={item} locale={locale} t={t} />
{item.coverImagePath ? (
<MotionFade delay={0.04}>
<div className="overflow-hidden rounded-[32px] border border-border/70 bg-card p-3 shadow-card">
<PortfolioImage
src={item.coverImagePath}
alt={getLocalizedValue(item.title, locale)}
width={1800}
height={1100}
className="h-[440px] w-full rounded-[28px] object-cover"
/>
</div>
</MotionFade>
) : null}
<div className="space-y-4">
{item.sections.map((section, index) => (
<MotionFade key={section.id} delay={0.06 * (index + 1)}>
<div className="grid gap-4 lg:grid-cols-[120px_minmax(0,1fr)]">
<div className="pt-4">
<div className="inline-flex rounded-pill border border-border/70 bg-background px-4 py-2 text-xs uppercase tracking-[0.2em] text-muted-foreground">
{String(index + 1).padStart(2, "0")}
</div>
</div>
<AppCard className="overflow-hidden">
<CardContent className="p-6 lg:p-8">
<SectionBlock section={section} locale={locale} t={t} />
</CardContent>
</AppCard>
</div>
</MotionFade>
))}
</div>
<AssetGallery assets={item.assets} locale={locale} t={t} />
</div>
);
}
function CaseStudyTemplate({
item,
locale,
t,
}: PortfolioProjectDetailProps) {
const [challenge, solution, outcome, ...restSections] = item.sections;
const leadSections = [challenge, solution, outcome].filter(
(section): section is PortfolioSectionView => Boolean(section),
);
return (
<div className="space-y-8">
<ProjectHeader item={item} locale={locale} t={t} />
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_420px]">
<div className="space-y-6">
{item.coverImagePath ? (
<MotionFade delay={0.04}>
<AppCard className="overflow-hidden">
<CardContent className="p-3">
<PortfolioImage
src={item.coverImagePath}
alt={getLocalizedValue(item.title, locale)}
width={1800}
height={1100}
className="h-auto w-full rounded-surface object-cover"
/>
</CardContent>
</AppCard>
</MotionFade>
) : null}
{leadSections.map((section, index) => (
<MotionFade key={section.id} delay={0.06 * (index + 1)}>
<AppCard level={index === 1 ? 3 : 1}>
<CardContent className="space-y-4 p-6 lg:p-8">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
{index === 0 ? "Challenge" : index === 1 ? "Solution" : "Outcome"}
</p>
<SectionBlock section={section} locale={locale} t={t} />
</CardContent>
</AppCard>
</MotionFade>
))}
</div>
<div className="space-y-6">
<MotionFade delay={0.08}>
<AppCard level={2} className="sticky top-24">
<CardContent className="space-y-5 p-6">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Case Study Snapshot</p>
<h2 className="mt-2 text-2xl font-semibold text-foreground">
{getLocalizedValue(item.title, locale)}
</h2>
</div>
<p className="text-sm leading-7 text-muted-foreground">
{getLocalizedValue(item.summary, locale)}
</p>
<ProjectMeta item={item} locale={locale} />
</CardContent>
</AppCard>
</MotionFade>
</div>
</div>
{restSections.length > 0 ? (
<div className="grid gap-4 lg:grid-cols-2">
{restSections.map((section, index) => (
<MotionFade key={section.id} delay={0.1 + index * 0.04}>
<AppCard>
<CardContent className="p-5 lg:p-6">
<SectionBlock section={section} locale={locale} t={t} />
</CardContent>
</AppCard>
</MotionFade>
))}
</div>
) : null}
<AssetGallery assets={item.assets} locale={locale} t={t} />
</div>
);
}
export function PortfolioProjectDetail({
item,
locale,
t,
}: PortfolioProjectDetailProps) {
const viewMode = resolvePortfolioProjectViewMode(item.viewMode);
if (viewMode === "STORY") {
return <StoryTemplate item={item} locale={locale} t={t} />;
}
if (viewMode === "CASE_STUDY") {
return <CaseStudyTemplate item={item} locale={locale} t={t} />;
}
return <GridTemplate item={item} locale={locale} t={t} />;
}
+4 -2
View File
@@ -1,4 +1,4 @@
import { PortfolioAssetKind, PortfolioSectionType } from "@prisma/client";
import { PortfolioSectionType } from "@prisma/client";
import { z } from "zod";
import { mediaFieldInputSchema } from "./media-validation";
@@ -10,6 +10,7 @@ const requiredText = (label: string) =>
.min(1, `${label} is required.`);
const optionalTrimmedText = z.string().trim().optional().transform((value) => value ?? "");
const portfolioProjectViewModes = ["GRID", "STORY", "CASE_STUDY"] as const;
export const categoryInputSchema = z.object({
id: z.string().trim().optional(),
@@ -83,7 +84,7 @@ export const sectionInputSchema = z
export const assetInputSchema = z.object({
id: z.string().trim().optional(),
kind: z.nativeEnum(PortfolioAssetKind),
kind: z.literal("IMAGE"),
filePath: optionalTrimmedText,
fileFieldName: optionalTrimmedText,
media: mediaFieldInputSchema.optional(),
@@ -98,6 +99,7 @@ export const projectInputSchema = z.object({
categoryId: requiredText("Project categoryId"),
slug: requiredText("Project slug")
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Project slug must be lowercase and hyphenated."),
viewMode: z.enum(portfolioProjectViewModes).default("GRID"),
titleAr: requiredText("Project titleAr"),
titleEn: requiredText("Project titleEn"),
titleDe: requiredText("Project titleDe"),
+24 -1
View File
@@ -1,4 +1,10 @@
import type { Category, PortfolioAsset, PortfolioProject, PortfolioSection } from "@prisma/client";
import type {
Category,
PortfolioAsset,
PortfolioProject,
PortfolioProjectViewMode,
PortfolioSection,
} from "@prisma/client";
import { getPortfolioMediaBindings } from "@/lib/media";
import type { AppLocale } from "@/lib/locale";
@@ -42,6 +48,7 @@ type ProjectRecord = Pick<
PortfolioProject,
| "id"
| "slug"
| "viewMode"
| "titleAr"
| "titleEn"
| "titleDe"
@@ -99,6 +106,7 @@ export type PortfolioAssetView = {
export type PortfolioProjectView = {
id: string;
slug: string;
viewMode: PortfolioProjectViewMode;
title: LocalizedContent;
summary: LocalizedContent;
clientName: string;
@@ -116,6 +124,20 @@ export type PortfolioProjectView = {
assets: PortfolioAssetView[];
};
export function resolvePortfolioProjectViewMode(
value: string | null | undefined,
): PortfolioProjectViewMode {
switch (value) {
case "STORY":
return "STORY";
case "CASE_STUDY":
return "CASE_STUDY";
case "GRID":
default:
return "GRID";
}
}
function mapLocalizedContent(record: Record<string, unknown>, prefix: string): LocalizedContent {
return {
ar: String(record[`${prefix}Ar`] ?? ""),
@@ -174,6 +196,7 @@ function mapProject(
return {
id: record.id,
slug: record.slug,
viewMode: resolvePortfolioProjectViewMode(record.viewMode),
title: mapLocalizedContent(record, "title"),
summary: mapLocalizedContent(record, "summary"),
clientName: record.clientName,
@@ -0,0 +1,4 @@
CREATE TYPE "PortfolioProjectViewMode" AS ENUM ('GRID', 'STORY', 'CASE_STUDY');
ALTER TABLE "PortfolioProject"
ADD COLUMN "viewMode" "PortfolioProjectViewMode" NOT NULL DEFAULT 'GRID';
+7
View File
@@ -27,6 +27,12 @@ enum PortfolioAssetKind {
DOCUMENT
}
enum PortfolioProjectViewMode {
GRID
STORY
CASE_STUDY
}
enum MediaSource {
UPLOAD
EXTERNAL
@@ -64,6 +70,7 @@ model PortfolioProject {
id String @id @default(cuid())
categoryId String
slug String @unique
viewMode PortfolioProjectViewMode @default(GRID)
titleAr String
titleEn String
titleDe String
+429 -108
View File
@@ -9,7 +9,121 @@ const connectionString =
const pool = new Pool({ connectionString });
const prisma = new PrismaClient({ adapter: new PrismaPg(pool) });
async function upsertMediaAsset(input) {
const existing = await prisma.mediaAsset.findFirst({
where: {
label: input.label,
url: input.url,
},
});
if (existing) {
return prisma.mediaAsset.update({
where: { id: existing.id },
data: input,
});
}
return prisma.mediaAsset.create({
data: input,
});
}
async function syncProjectContent(projectId, sections, assets) {
await prisma.portfolioSection.deleteMany({
where: { projectId },
});
await prisma.portfolioAsset.deleteMany({
where: { projectId },
});
const createdSections = [];
for (const section of sections) {
const createdSection = await prisma.portfolioSection.create({
data: {
projectId,
...section,
},
});
createdSections.push(createdSection);
}
const createdAssets = [];
for (const asset of assets) {
const createdAsset = await prisma.portfolioAsset.create({
data: {
projectId,
...asset,
},
});
createdAssets.push(createdAsset);
}
return { createdSections, createdAssets };
}
async function syncProjectMediaUsages(projectId, mediaMap) {
await prisma.mediaUsage.deleteMany({
where: {
entityType: "portfolio-project",
entityId: projectId,
},
});
const usages = [];
if (mediaMap.coverAssetId) {
usages.push({
assetId: mediaMap.coverAssetId,
usageType: "PORTFOLIO_COVER",
entityType: "portfolio-project",
entityId: projectId,
fieldKey: "cover",
});
}
for (const sectionUsage of mediaMap.sectionUsages) {
usages.push({
assetId: sectionUsage.assetId,
usageType: "PORTFOLIO_SECTION",
entityType: "portfolio-project",
entityId: projectId,
fieldKey: sectionUsage.fieldKey,
});
}
for (const assetUsage of mediaMap.assetUsages) {
usages.push({
assetId: assetUsage.assetId,
usageType: "PORTFOLIO_ASSET",
entityType: "portfolio-project",
entityId: projectId,
fieldKey: assetUsage.fieldKey,
});
}
if (usages.length > 0) {
await prisma.mediaUsage.createMany({ data: usages });
}
}
async function main() {
await prisma.mediaUsage.deleteMany({
where: {
entityType: "portfolio-project",
},
});
await prisma.portfolioSection.deleteMany();
await prisma.portfolioAsset.deleteMany();
await prisma.portfolioProject.deleteMany();
await prisma.category.deleteMany();
await prisma.appConfig.upsert({
where: { key: "siteName" },
update: { value: "moh-sass" },
@@ -121,63 +235,226 @@ async function main() {
},
});
const project = await prisma.portfolioProject.upsert({
where: { slug: "brand-redesign" },
const commerceCategory = await prisma.category.upsert({
where: { slug: "commerce" },
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,
nameAr: "التجارة الرقمية",
nameEn: "Commerce",
nameDe: "Commerce",
descriptionAr: "متاجر وتجارب شراء رقمية مع تركيز على الوضوح والتحويل.",
descriptionEn: "Commerce experiences with a focus on clarity and conversion.",
descriptionDe: "Commerce-Projekte mit Fokus auf Klarheit und Conversion.",
sortOrder: 3,
isActive: true,
},
create: {
categoryId: brandCategory.id,
slug: "commerce",
nameAr: "التجارة الرقمية",
nameEn: "Commerce",
nameDe: "Commerce",
descriptionAr: "متاجر وتجارب شراء رقمية مع تركيز على الوضوح والتحويل.",
descriptionEn: "Commerce experiences with a focus on clarity and conversion.",
descriptionDe: "Commerce-Projekte mit Fokus auf Klarheit und Conversion.",
sortOrder: 3,
isActive: true,
},
});
const gridCover = await upsertMediaAsset({
source: "UPLOAD",
kind: "IMAGE",
url: "/uploads/portfolio/demo-cover.svg",
fileName: "demo-cover.svg",
label: "Portfolio Grid Cover",
altText: "Portfolio Grid Cover",
mimeType: "image/svg+xml",
size: 1024,
});
const storyCover = await upsertMediaAsset({
source: "UPLOAD",
kind: "IMAGE",
url: "/uploads/portfolio/demo-cover.svg",
fileName: "demo-cover.svg",
label: "Portfolio Story Cover",
altText: "Portfolio Story Cover",
mimeType: "image/svg+xml",
size: 1024,
});
const caseStudyCover = await upsertMediaAsset({
source: "UPLOAD",
kind: "IMAGE",
url: "/uploads/portfolio/demo-cover.svg",
fileName: "demo-cover.svg",
label: "Portfolio Case Study Cover",
altText: "Portfolio Case Study Cover",
mimeType: "image/svg+xml",
size: 1024,
});
const projects = [
{
slug: "grid-product-launch",
categoryId: commerceCategory.id,
viewMode: "GRID",
titleAr: "إطلاق منتج رقمي",
titleEn: "Grid Product Launch",
titleDe: "Grid Product Launch",
summaryAr: "مثال عرض شبكي لمشروع سريع مع أقسام قصيرة وأصول داعمة.",
summaryEn: "Grid view example for a fast product launch page.",
summaryDe: "Grid-Ansicht als Beispiel fuer einen schnellen Produktlaunch.",
clientName: "Launch Studio",
projectYear: 2026,
serviceLabelAr: "تجربة إطلاق",
serviceLabelEn: "Launch Experience",
serviceLabelDe: "Launch Experience",
previewUrl: "https://example.com/preview/grid-product-launch",
coverImagePath: gridCover.url,
isFeatured: true,
isPublished: true,
publishedAt: new Date("2026-01-12T09:00:00.000Z"),
sortOrder: 1,
coverAssetId: gridCover.id,
sections: [
{
type: "RICH_TEXT",
titleAr: "الفكرة",
titleEn: "Concept",
titleDe: "Konzept",
bodyAr: "واجهة سريعة لعرض المنتج والتركيز على الرسالة الأساسية.",
bodyEn: "A fast modular presentation focused on the main launch message.",
bodyDe: "Eine schnelle modulare Darstellung mit Fokus auf die Hauptbotschaft.",
imagePath: null,
linkUrl: null,
sortOrder: 0,
mediaAssetId: null,
},
{
type: "GALLERY",
titleAr: "الصورة الرئيسية",
titleEn: "Hero Visual",
titleDe: "Hero Visual",
bodyAr: "",
bodyEn: "",
bodyDe: "",
imagePath: gridCover.url,
linkUrl: null,
sortOrder: 1,
mediaAssetId: gridCover.id,
},
],
assets: [
{
kind: "IMAGE",
filePath: gridCover.url,
altAr: "غلاف مشروع Grid",
altEn: "Grid project cover",
altDe: "Grid Projekt Cover",
sortOrder: 0,
mediaAssetId: gridCover.id,
},
],
},
{
slug: "campaign-site",
categoryId: webCategory.id,
viewMode: "STORY",
titleAr: "موقع حملة",
titleEn: "Campaign Site",
titleDe: "Campaign Site",
summaryAr: "مثال عرض قصصي لمشروع ويب مع تسلسل سردي أوضح.",
summaryEn: "Story view example for a launch campaign website.",
summaryDe: "Story-Ansicht als Beispiel fuer eine Kampagnenseite.",
clientName: "Launch Client",
projectYear: 2024,
serviceLabelAr: "موقع تسويقي",
serviceLabelEn: "Marketing Website",
serviceLabelDe: "Marketing Website",
previewUrl: "https://example.com/preview/campaign-site",
coverImagePath: storyCover.url,
isFeatured: false,
isPublished: true,
publishedAt: new Date("2024-09-05T09:00:00.000Z"),
sortOrder: 2,
coverAssetId: storyCover.id,
sections: [
{
type: "RICH_TEXT",
titleAr: "السياق",
titleEn: "Context",
titleDe: "Kontext",
bodyAr: "الحملة احتاجت صفحة مرنة وسريعة تتبدل بين أكثر من مرحلة.",
bodyEn: "The campaign needed a flexible page that could adapt across phases.",
bodyDe: "Die Kampagne brauchte eine flexible Seite fuer mehrere Phasen.",
imagePath: null,
linkUrl: null,
sortOrder: 0,
mediaAssetId: null,
},
{
type: "GALLERY",
titleAr: "العرض البصري",
titleEn: "Visual Flow",
titleDe: "Visueller Ablauf",
bodyAr: "",
bodyEn: "",
bodyDe: "",
imagePath: storyCover.url,
linkUrl: null,
sortOrder: 1,
mediaAssetId: storyCover.id,
},
{
type: "LINK",
titleAr: "المعاينة",
titleEn: "Preview",
titleDe: "Vorschau",
bodyAr: "رابط العرض المباشر.",
bodyEn: "Direct preview link.",
bodyDe: "Direkter Vorschau-Link.",
imagePath: null,
linkUrl: "https://example.com/preview/campaign-site",
sortOrder: 2,
mediaAssetId: null,
},
],
assets: [
{
kind: "IMAGE",
filePath: storyCover.url,
altAr: "غلاف مشروع Story",
altEn: "Story project cover",
altDe: "Story Projekt Cover",
sortOrder: 0,
mediaAssetId: storyCover.id,
},
],
},
{
slug: "brand-redesign",
categoryId: brandCategory.id,
viewMode: "CASE_STUDY",
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.",
summaryAr: "مثال عرض دراسة حالة يركز على التحدي والحل والنتيجة.",
summaryEn: "Case study example focused on challenge, solution, and outcome.",
summaryDe: "Case-Study-Ansicht mit Fokus auf Herausforderung, Loesung und Ergebnis.",
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",
coverImagePath: caseStudyCover.url,
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: [
sortOrder: 3,
coverAssetId: caseStudyCover.id,
sections: [
{
projectId: project.id,
type: "RICH_TEXT",
titleAr: "التحدي",
titleEn: "Challenge",
@@ -185,10 +462,12 @@ async function main() {
bodyAr: "كان المطلوب تحديث الهوية بدون خسارة التعرف البصري الحالي.",
bodyEn: "The brief required a refreshed identity without losing recognition.",
bodyDe: "Die Marke sollte modernisiert werden, ohne die Wiedererkennbarkeit zu verlieren.",
sortOrder: 1,
imagePath: null,
linkUrl: null,
sortOrder: 0,
mediaAssetId: null,
},
{
projectId: project.id,
type: "RICH_TEXT",
titleAr: "الحل",
titleEn: "Solution",
@@ -196,81 +475,123 @@ async function main() {
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",
imagePath: null,
linkUrl: null,
sortOrder: 1,
mediaAssetId: null,
},
{
type: "GALLERY",
titleAr: "التنفيذ البصري",
titleEn: "Visual Execution",
titleDe: "Visuelle Umsetzung",
bodyAr: "",
bodyEn: "",
bodyDe: "",
imagePath: caseStudyCover.url,
linkUrl: null,
sortOrder: 2,
mediaAssetId: caseStudyCover.id,
},
],
});
assets: [
{
kind: "IMAGE",
filePath: caseStudyCover.url,
altAr: "غلاف مشروع Case Study",
altEn: "Case study project cover",
altDe: "Case Study Projekt Cover",
sortOrder: 0,
mediaAssetId: caseStudyCover.id,
},
],
},
];
await prisma.portfolioProject.upsert({
where: { slug: "campaign-site" },
for (const projectConfig of projects) {
const project = await prisma.portfolioProject.upsert({
where: { slug: projectConfig.slug },
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,
categoryId: projectConfig.categoryId,
viewMode: projectConfig.viewMode,
titleAr: projectConfig.titleAr,
titleEn: projectConfig.titleEn,
titleDe: projectConfig.titleDe,
summaryAr: projectConfig.summaryAr,
summaryEn: projectConfig.summaryEn,
summaryDe: projectConfig.summaryDe,
clientName: projectConfig.clientName,
projectYear: projectConfig.projectYear,
serviceLabelAr: projectConfig.serviceLabelAr,
serviceLabelEn: projectConfig.serviceLabelEn,
serviceLabelDe: projectConfig.serviceLabelDe,
previewUrl: projectConfig.previewUrl,
coverImagePath: projectConfig.coverImagePath,
isFeatured: projectConfig.isFeatured,
isPublished: projectConfig.isPublished,
publishedAt: projectConfig.publishedAt,
sortOrder: projectConfig.sortOrder,
},
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,
slug: projectConfig.slug,
categoryId: projectConfig.categoryId,
viewMode: projectConfig.viewMode,
titleAr: projectConfig.titleAr,
titleEn: projectConfig.titleEn,
titleDe: projectConfig.titleDe,
summaryAr: projectConfig.summaryAr,
summaryEn: projectConfig.summaryEn,
summaryDe: projectConfig.summaryDe,
clientName: projectConfig.clientName,
projectYear: projectConfig.projectYear,
serviceLabelAr: projectConfig.serviceLabelAr,
serviceLabelEn: projectConfig.serviceLabelEn,
serviceLabelDe: projectConfig.serviceLabelDe,
previewUrl: projectConfig.previewUrl,
coverImagePath: projectConfig.coverImagePath,
isFeatured: projectConfig.isFeatured,
isPublished: projectConfig.isPublished,
publishedAt: projectConfig.publishedAt,
sortOrder: projectConfig.sortOrder,
},
});
const created = await syncProjectContent(project.id, projectConfig.sections.map((section) => ({
type: section.type,
titleAr: section.titleAr,
titleEn: section.titleEn,
titleDe: section.titleDe,
bodyAr: section.bodyAr,
bodyEn: section.bodyEn,
bodyDe: section.bodyDe,
imagePath: section.imagePath,
linkUrl: section.linkUrl,
sortOrder: section.sortOrder,
})), projectConfig.assets.map((asset) => ({
kind: asset.kind,
filePath: asset.filePath,
altAr: asset.altAr,
altEn: asset.altEn,
altDe: asset.altDe,
sortOrder: asset.sortOrder,
})));
await syncProjectMediaUsages(project.id, {
coverAssetId: projectConfig.coverAssetId,
sectionUsages: created.createdSections
.map((sectionRow, index) => ({
fieldKey: sectionRow.id,
assetId: projectConfig.sections[index]?.mediaAssetId,
}))
.filter((entry) => entry.assetId),
assetUsages: created.createdAssets
.map((assetRow, index) => ({
fieldKey: assetRow.id,
assetId: projectConfig.assets[index]?.mediaAssetId,
}))
.filter((entry) => entry.assetId),
});
}
}
main()
+36
View File
@@ -29,6 +29,7 @@ describe("portfolio validation", () => {
projectInputSchema.parse({
categoryId: "cat_1",
slug: "Invalid Slug",
viewMode: "GRID",
titleAr: "عنوان",
titleEn: "Title",
titleDe: "Titel",
@@ -123,6 +124,41 @@ describe("portfolio validation", () => {
).toBe("IMAGE");
});
it("accepts valid project view modes", () => {
expect(
projectInputSchema.parse({
categoryId: "cat_1",
slug: "case-study-entry",
viewMode: "CASE_STUDY",
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: [],
}).viewMode,
).toBe("CASE_STUDY");
});
it("rejects invalid media payloads", () => {
expect(() =>
assetInputSchema.parse({
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { resolvePortfolioProjectViewMode } from "../lib/portfolio";
describe("portfolio helpers", () => {
it("falls back to GRID when view mode is missing", () => {
expect(resolvePortfolioProjectViewMode(undefined)).toBe("GRID");
expect(resolvePortfolioProjectViewMode("unexpected")).toBe("GRID");
});
it("keeps supported view modes", () => {
expect(resolvePortfolioProjectViewMode("STORY")).toBe("STORY");
expect(resolvePortfolioProjectViewMode("CASE_STUDY")).toBe("CASE_STUDY");
});
});