Files
sass-mohfarawati/components/admin/portfolio-project-form.tsx
T
moh 19e8fa8f19 STYLED - Two-column project form, drop the redundant header card
- Remove the project summary/header card (project name, badges, progress);
  it added no value while creating a project. Move the on-submit validation
  alert down to the action bar.
- Lay the whole form out in two columns: Basic Information + Localized
  Content, then Sections + Cover/Assets. Basic Information fields cap at two
  columns and the Cover/Assets card stacks inside its column.

All 375 tests pass; tsc and eslint clean.
2026-09-20 17:40:45 +02:00

1209 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import type { PortfolioProjectViewMode, PortfolioSectionType } from "@/lib/db/enums";
import {
ArrowDown,
ArrowUp,
BookOpenText,
BriefcaseBusiness,
CalendarDays,
CircleAlert,
FolderTree,
Grid2x2,
ImagePlus,
Layers3,
Link2,
Plus,
Text,
Trash2,
UserRound,
} from "lucide-react";
import Link from "next/link";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { useFormStatus } from "react-dom";
import { MediaFieldPicker, type MediaFieldState } from "@/components/admin/media-field-picker";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { AppCard } from "@/components/ui/app-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { moveArrayItem } from "@/lib/array";
import { getAdminAppPath } from "@/lib/admin-routing";
import {
getFirstIncompleteWizardStep,
getPortfolioWizardProgress,
isPortfolioAssetReady,
isPortfolioSectionReady,
} from "@/lib/portfolio-form-progress";
import type { MediaOption } from "@/lib/media";
import type { PortfolioCategoryView, PortfolioProjectView } from "@/lib/portfolio";
import { cn } from "@/lib/utils";
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: "IMAGE";
filePath: string;
fileFieldName: string;
media: MediaFieldState;
altAr: string;
altEn: string;
altDe: string;
sortOrder: number;
};
type ProjectFormState = {
categoryId: string;
slug: string;
clientName: string;
projectYear: string;
previewUrl: string;
sortOrder: string;
viewMode: PortfolioProjectViewMode;
isFeatured: boolean;
isPublished: boolean;
titleAr: string;
titleEn: string;
titleDe: string;
serviceLabelAr: string;
serviceLabelEn: string;
serviceLabelDe: string;
summaryAr: string;
summaryEn: string;
summaryDe: string;
};
type PortfolioProjectFormProps = {
action: (formData: FormData) => void | Promise<void>;
categories: PortfolioCategoryView[];
mediaOptions: MediaOption[];
project?: PortfolioProjectView | null;
formId: string;
redirectPath: string;
};
const locales = [
{ suffix: "Ar" as const, label: "Arabic" },
{ suffix: "En" as const, label: "English" },
{ suffix: "De" as const, label: "German" },
] as const;
const sectionTypeOptions: PortfolioSectionType[] = [
"RICH_TEXT",
"GALLERY",
"STATS",
"DELIVERABLES",
"LINK",
];
const sectionTypeLabels: Record<PortfolioSectionType, string> = {
RICH_TEXT: "Rich Text",
GALLERY: "Gallery (image)",
STATS: "Stats (text)",
DELIVERABLES: "Deliverables (text)",
LINK: "Link",
};
const viewModeOptions: Array<{
value: PortfolioProjectViewMode;
label: string;
description: string;
icon: typeof Grid2x2;
}> = [
{ value: "GRID", label: "Grid", description: "Balanced modular layout.", icon: Grid2x2 },
{ value: "STORY", label: "Story", description: "Narrative section flow.", icon: BookOpenText },
{ value: "CASE_STUDY", label: "Case Study", description: "Structured challenge/solution/outcome view. Sections 13 become the lead panels.", icon: BriefcaseBusiness },
];
function createMediaFieldState(params: {
kind: "IMAGE";
assetId?: string | null;
url?: string | null;
label?: string | null;
}): MediaFieldState {
return {
mode: params.assetId ? "library" : "upload",
assetId: params.assetId ?? "",
url: params.url ?? "",
label: params.label ?? "",
kind: params.kind,
};
}
function createAvailableCategories(
categories: PortfolioCategoryView[],
project: PortfolioProjectView | null | undefined,
) {
if (!project) {
return categories;
}
if (categories.some((category) => category.id === project.category.id)) {
return categories;
}
return [project.category, ...categories];
}
function createInitialState(
project: PortfolioProjectView | null | undefined,
categories: PortfolioCategoryView[],
): ProjectFormState {
return {
categoryId: project?.category.id ?? categories[0]?.id ?? "",
slug: project?.slug ?? "",
clientName: project?.clientName ?? "",
projectYear: String(project?.projectYear ?? new Date().getFullYear()),
previewUrl: project?.previewUrl ?? "",
sortOrder: String(project?.sortOrder ?? 0),
viewMode: project?.viewMode ?? "GRID",
isFeatured: project?.isFeatured ?? false,
isPublished: project?.isPublished ?? false,
titleAr: project?.title.ar ?? "",
titleEn: project?.title.en ?? "",
titleDe: project?.title.de ?? "",
serviceLabelAr: project?.serviceLabel.ar ?? "",
serviceLabelEn: project?.serviceLabel.en ?? "",
serviceLabelDe: project?.serviceLabel.de ?? "",
summaryAr: project?.summary.ar ?? "",
summaryEn: project?.summary.en ?? "",
summaryDe: project?.summary.de ?? "",
};
}
function createInitialSections(project: PortfolioProjectView | null | undefined): SectionFormValue[] {
if (!project?.sections.length) {
// Sections are optional — start empty so a project can be saved without them.
return [];
}
return 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,
}));
}
function createInitialAssets(project: PortfolioProjectView | null | undefined): AssetFormValue[] {
if (!project?.assets.length) {
// Gallery assets are optional — start empty so a project can be saved without them.
return [];
}
return project.assets.map((asset, index) => ({
id: asset.id,
kind: "IMAGE",
filePath: asset.filePath,
fileFieldName: `asset-upload-${index}`,
media: createMediaFieldState({
kind: "IMAGE",
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,
}));
}
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,
};
}
function LocaleInputs({
title,
namePrefix,
values,
onChange,
multiline = false,
}: {
title: string;
namePrefix?: string;
values: { Ar: string; En: string; De: string };
onChange: (key: "Ar" | "En" | "De", value: string) => void;
multiline?: boolean;
}) {
return (
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-[0.14em] text-muted-foreground">{title}</p>
<div className="grid gap-3 md:grid-cols-3">
{locales.map((locale) => (
<div key={`${title}-${locale.suffix}`} className="space-y-1.5">
<span className="block text-[11px] font-medium uppercase tracking-wide text-muted-foreground/70">
{locale.label}
</span>
{multiline ? (
<Textarea
name={namePrefix ? `${namePrefix}${locale.suffix}` : undefined}
rows={3}
placeholder={locale.label}
value={values[locale.suffix]}
onChange={(event) => onChange(locale.suffix, event.target.value)}
className="resize-y"
/>
) : (
<Input
name={namePrefix ? `${namePrefix}${locale.suffix}` : undefined}
placeholder={locale.label}
value={values[locale.suffix]}
onChange={(event) => onChange(locale.suffix, event.target.value)}
/>
)}
</div>
))}
</div>
</div>
);
}
function SectionHeader({
title,
description,
action,
}: {
title: string;
description: string;
action?: ReactNode;
}) {
return (
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="space-y-1">
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
{action}
</div>
);
}
function EmptyPanel({
icon: Icon,
title,
description,
action,
}: {
icon: typeof Grid2x2;
title: string;
description: string;
action?: ReactNode;
}) {
return (
<div className="flex flex-col items-center gap-3 rounded-surface border border-dashed border-border/70 bg-surface-2/40 px-6 py-10 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-pill border border-border/70 bg-surface-2 text-muted-foreground">
<Icon className="h-5 w-5" />
</div>
<div className="space-y-1">
<p className="text-sm font-semibold text-foreground">{title}</p>
<p className="max-w-sm text-sm text-muted-foreground">{description}</p>
</div>
{action}
</div>
);
}
function SubmitButton({ onSelect }: { onSelect: () => void }) {
const { pending } = useFormStatus();
return (
<Button type="submit" onClick={onSelect} disabled={pending}>
{pending ? "Saving..." : "Save Project"}
</Button>
);
}
function DraftButton({ onSelect }: { onSelect: () => void }) {
const { pending } = useFormStatus();
return (
<Button type="submit" onClick={onSelect} variant="outline" disabled={pending}>
Save as draft
</Button>
);
}
function ViewModeCard({
active,
label,
description,
icon: Icon,
onClick,
}: {
active: boolean;
label: string;
description: string;
icon: typeof Grid2x2;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"rounded-surface border px-4 py-4 text-left transition-colors",
active
? "border-primary bg-primary text-primary-foreground shadow-sm"
: "border-border/70 bg-background hover:border-input hover:bg-accent/10",
)}
>
<div className="flex items-start gap-3">
<div
className={cn(
"flex h-10 w-10 items-center justify-center rounded-nested border transition-colors",
active
? "border-primary-foreground/20 bg-primary-foreground/10 text-primary-foreground"
: "border-border/70 bg-surface-2 text-muted-foreground",
)}
>
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className={cn("text-sm font-semibold", active ? "text-primary-foreground" : "text-foreground")}>
{label}
</p>
<p className={cn("mt-1 text-sm", active ? "text-primary-foreground/80" : "text-muted-foreground")}>
{description}
</p>
</div>
</div>
</button>
);
}
function ToggleField({
id,
name,
checked,
title,
description,
onCheckedChange,
}: {
id: string;
name: string;
checked: boolean;
title: string;
description: string;
onCheckedChange: (checked: boolean) => void;
}) {
return (
<label
htmlFor={id}
className="flex items-start justify-between gap-3 rounded-nested border border-input bg-card px-4 py-3 transition-colors hover:bg-accent/20"
>
<div>
<p className="text-sm font-medium text-foreground">{title}</p>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
<Checkbox
id={id}
name={name}
checked={checked}
onCheckedChange={(value) => onCheckedChange(value === true)}
className="mt-0.5"
/>
</label>
);
}
export function PortfolioProjectForm({
action,
categories,
mediaOptions,
project,
formId,
redirectPath,
}: PortfolioProjectFormProps) {
const availableCategories = createAvailableCategories(categories, project);
const initialProjectState = createInitialState(project, availableCategories);
const initialSections = createInitialSections(project);
const initialAssets = createInitialAssets(project);
const [projectState, setProjectState] = useState<ProjectFormState>(initialProjectState);
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[]>(initialSections);
const [assets, setAssets] = useState<AssetFormValue[]>(initialAssets);
const [showValidation, setShowValidation] = useState(false);
const sectionsInputRef = useRef<HTMLInputElement | null>(null);
const assetsInputRef = useRef<HTMLInputElement | null>(null);
const intentRef = useRef<HTMLInputElement | null>(null);
const setIntent = (value: "save" | "draft") => {
if (intentRef.current) {
intentRef.current.value = value;
}
};
const sectionsPayload = JSON.stringify(sections.map((section, index) => ({ ...section, sortOrder: index })));
const assetsPayload = JSON.stringify(assets.map((asset, index) => ({ ...asset, sortOrder: index })));
const progress = getPortfolioWizardProgress({
basics: projectState,
content: projectState,
sections: sections.map((section) => ({
type: section.type,
titleAr: section.titleAr,
titleEn: section.titleEn,
titleDe: section.titleDe,
bodyAr: section.bodyAr,
bodyEn: section.bodyEn,
bodyDe: section.bodyDe,
linkUrl: section.linkUrl,
mediaAssetId: section.media.assetId,
})),
assets: assets.map((asset) => ({
mediaAssetId: asset.media.assetId,
altAr: asset.altAr,
altEn: asset.altEn,
altDe: asset.altDe,
})),
});
const firstIncompleteStep = getFirstIncompleteWizardStep(progress);
useEffect(() => {
sectionsInputRef.current?.dispatchEvent(new Event("input", { bubbles: true }));
sectionsInputRef.current?.dispatchEvent(new Event("change", { bubbles: true }));
}, [sectionsPayload]);
useEffect(() => {
assetsInputRef.current?.dispatchEvent(new Event("input", { bubbles: true }));
assetsInputRef.current?.dispatchEvent(new Event("change", { bubbles: true }));
}, [assetsPayload]);
if (!project && availableCategories.length === 0) {
return (
<AppCard level={3} padding="lg" className="rounded-surface">
<div className="space-y-4">
<div className="space-y-2">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Portfolio Setup</p>
<h2 className="text-2xl font-semibold text-foreground">Create a category before the first project.</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Projects require one active category. Start with category basics, then return here to build the project.
</p>
</div>
<div className="flex flex-wrap gap-3">
<Button asChild>
<Link href={getAdminAppPath("/portfolio/categories")}>Open Categories</Link>
</Button>
<Button asChild variant="outline">
<Link href={getAdminAppPath("/portfolio")}>Back to Portfolio</Link>
</Button>
</div>
</div>
</AppCard>
);
}
return (
<form
id={formId}
action={action}
className="space-y-8"
onSubmit={(event) => {
// A draft save skips the completeness gate entirely.
if (intentRef.current?.value === "draft") {
return;
}
if (!firstIncompleteStep) {
return;
}
event.preventDefault();
setShowValidation(true);
}}
>
<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="viewMode" value={projectState.viewMode} />
<input ref={sectionsInputRef} type="hidden" name="sections" value={sectionsPayload} />
<input ref={assetsInputRef} type="hidden" name="assets" value={assetsPayload} />
<input ref={intentRef} type="hidden" name="intent" defaultValue="save" />
<div className="grid gap-6 xl:grid-cols-2 xl:items-start">
<div>
<AppCard level={3} padding="md">
<section className="space-y-5">
<SectionHeader
title="Basic Information"
description="Choose the category and the stable project metadata first."
/>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="categoryId" className="sr-only">Category</Label>
<div className="relative">
<FolderTree 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="categoryId"
value={projectState.categoryId}
onValueChange={(value) => setProjectState((current) => ({ ...current, categoryId: value }))}
>
<SelectTrigger id="categoryId" className="pl-9">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
{availableCategories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name.de || category.name.en || category.name.ar}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="slug" className="sr-only">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"
name="slug"
placeholder="Slug"
value={projectState.slug}
onChange={(event) =>
setProjectState((current) => ({ ...current, slug: event.target.value }))
}
className="pl-9"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="clientName" className="sr-only">Client</Label>
<div className="relative">
<UserRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="clientName"
name="clientName"
placeholder="Client"
value={projectState.clientName}
onChange={(event) =>
setProjectState((current) => ({ ...current, clientName: event.target.value }))
}
className="pl-9"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="projectYear" className="sr-only">Year</Label>
<div className="relative">
<CalendarDays className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="projectYear"
name="projectYear"
placeholder="Year"
type="number"
min="2000"
max="2100"
value={projectState.projectYear}
onChange={(event) =>
setProjectState((current) => ({ ...current, projectYear: event.target.value }))
}
className="pl-9"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="previewUrl" className="sr-only">Preview 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
id="previewUrl"
name="previewUrl"
placeholder="Preview URL"
value={projectState.previewUrl}
onChange={(event) =>
setProjectState((current) => ({ ...current, previewUrl: event.target.value }))
}
className="pl-9"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="sortOrder" className="sr-only">Sort Order</Label>
<div className="relative">
<Layers3 className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="sortOrder"
name="sortOrder"
placeholder="Sort Order"
type="number"
min="0"
value={projectState.sortOrder}
onChange={(event) =>
setProjectState((current) => ({ ...current, sortOrder: event.target.value }))
}
className="pl-9"
/>
</div>
</div>
</div>
<div className="grid gap-3 xl:grid-cols-3">
{viewModeOptions.map((option) => (
<ViewModeCard
key={option.value}
active={projectState.viewMode === option.value}
label={option.label}
description={option.description}
icon={option.icon}
onClick={() => setProjectState((current) => ({ ...current, viewMode: option.value }))}
/>
))}
</div>
<div className="grid gap-3 md:grid-cols-2">
<ToggleField
id="isFeatured"
name="isFeatured"
checked={projectState.isFeatured}
title="Featured"
description="Highlight the project across the portfolio."
onCheckedChange={(checked) =>
setProjectState((current) => ({ ...current, isFeatured: checked }))
}
/>
<ToggleField
id="isPublished"
name="isPublished"
checked={projectState.isPublished}
title="Published"
description="Only published projects appear on public pages."
onCheckedChange={(checked) =>
setProjectState((current) => ({ ...current, isPublished: checked }))
}
/>
</div>
</section>
</AppCard>
</div>
<div>
<AppCard level={3} padding="md">
<section className="space-y-5">
<SectionHeader
title="Localized Content"
description="Finish the user-facing copy before building sections and media."
/>
<LocaleInputs
title="Project Title"
namePrefix="title"
values={{
Ar: projectState.titleAr,
En: projectState.titleEn,
De: projectState.titleDe,
}}
onChange={(key, value) =>
setProjectState((current) => ({ ...current, [`title${key}`]: value }))
}
/>
<LocaleInputs
title="Service Label"
namePrefix="serviceLabel"
values={{
Ar: projectState.serviceLabelAr,
En: projectState.serviceLabelEn,
De: projectState.serviceLabelDe,
}}
onChange={(key, value) =>
setProjectState((current) => ({ ...current, [`serviceLabel${key}`]: value }))
}
/>
<LocaleInputs
title="Summary"
namePrefix="summary"
values={{
Ar: projectState.summaryAr,
En: projectState.summaryEn,
De: projectState.summaryDe,
}}
onChange={(key, value) =>
setProjectState((current) => ({ ...current, [`summary${key}`]: value }))
}
multiline
/>
</section>
</AppCard>
</div>
<div>
<AppCard level={3} padding="md">
<section className="space-y-5">
<SectionHeader
title="Sections"
description="Optional — baue die Projektgeschichte aus einzelnen Abschnitten."
action={(
<Button
type="button"
variant="outline"
onClick={() => setSections((current) => [...current, createEmptySection(current.length)])}
>
<Plus className="h-4 w-4" />
Add Section
</Button>
)}
/>
{sections.length === 0 ? (
<EmptyPanel
icon={Layers3}
title="Noch keine Abschnitte"
description="Abschnitte sind optional. Rich-Text, Galerie, Stats, Deliverables oder Links frei kombinierbar."
action={(
<Button
type="button"
variant="outline"
onClick={() => setSections((current) => [...current, createEmptySection(current.length)])}
>
<Plus className="h-4 w-4" />
Add Section
</Button>
)}
/>
) : (
<Accordion type="multiple" className="space-y-3">
{sections.map((section, index) => {
const ready = isPortfolioSectionReady({
type: section.type,
titleAr: section.titleAr,
titleEn: section.titleEn,
titleDe: section.titleDe,
bodyAr: section.bodyAr,
bodyEn: section.bodyEn,
bodyDe: section.bodyDe,
linkUrl: section.linkUrl,
mediaAssetId: section.media.assetId,
});
return (
<AccordionItem
key={section.id ?? `section-${index}`}
value={section.id ?? `section-${index}`}
className="border-border/70 bg-surface-2"
>
<AccordionTrigger className="hover:no-underline">
<span className="flex flex-wrap items-center gap-2.5">
<Badge variant={ready ? "success" : "outline"}>{ready ? "Ready" : "Open"}</Badge>
<span className="text-sm font-medium text-foreground">{`Section ${index + 1}`}</span>
<span className="text-xs text-muted-foreground">{sectionTypeLabels[section.type]}</span>
</span>
</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setSections((current) => moveArrayItem(current, index, index - 1))}
disabled={index === 0}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="outline"
size="icon"
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"
size="icon"
className="text-destructive"
onClick={() =>
setSections((current) =>
current.filter((_, currentIndex) => currentIndex !== index),
)
}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label className="sr-only">Type</Label>
<Select
value={section.type}
onValueChange={(value) =>
setSections((current) =>
current.map((item, currentIndex) =>
currentIndex === index
? { ...item, type: value as PortfolioSectionType }
: item,
),
)
}
>
<SelectTrigger>
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
{sectionTypeOptions.map((type) => (
<SelectItem key={type} value={type}>
{sectionTypeLabels[type]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{section.type === "LINK" ? (
<div className="space-y-2">
<Label className="sr-only">Link URL</Label>
<Input
placeholder="Link URL"
value={section.linkUrl}
onChange={(event) =>
setSections((current) =>
current.map((item, currentIndex) =>
currentIndex === index
? { ...item, linkUrl: event.target.value }
: item,
),
)
}
/>
</div>
) : null}
</div>
{section.type === "GALLERY" ? (
<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}
hasInitialValue={Boolean(section.media.assetId || section.imagePath)}
inputName={`section-media-${index}`}
fileFieldName={`section-image-upload-${index}`}
allowClear
clearLabel="Remove Image"
emptyValue={{ mode: "upload", assetId: "", url: "", label: "" }}
/>
) : null}
<LocaleInputs
title="Section Title"
values={{ Ar: section.titleAr, En: section.titleEn, De: section.titleDe }}
onChange={(key, value) =>
setSections((current) =>
current.map((item, currentIndex) =>
currentIndex === index ? { ...item, [`title${key}`]: value } : item,
),
)
}
/>
{section.type !== "GALLERY" && section.type !== "LINK" ? (
<LocaleInputs
title="Section Body"
values={{ Ar: section.bodyAr, En: section.bodyEn, De: section.bodyDe }}
onChange={(key, value) =>
setSections((current) =>
current.map((item, currentIndex) =>
currentIndex === index ? { ...item, [`body${key}`]: value } : item,
),
)
}
multiline
/>
) : null}
</div>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
)}
</section>
</AppCard>
</div>
<div>
<AppCard level={3} padding="md">
<div className="space-y-6">
<section className="space-y-4">
<SectionHeader
title="Cover Media"
description="Wähle das Titelbild, das das Projekt in Listen repräsentiert."
/>
<MediaFieldPicker
title="Cover"
value={coverMedia}
onChange={setCoverMedia}
options={mediaOptions}
hasInitialValue={Boolean(project?.coverImagePath || project?.coverMediaAssetId)}
inputName="coverMedia"
fileFieldName="coverFile"
allowClear
clearLabel="Remove Cover"
emptyValue={{ mode: "upload", assetId: "", url: "", label: "" }}
/>
</section>
<section className="space-y-4">
<SectionHeader
title="Assets"
description="Optional — Galeriebilder mit lokalisiertem Alt-Text."
action={(
<Button
type="button"
variant="outline"
onClick={() => setAssets((current) => [...current, createEmptyAsset(current.length)])}
>
<Plus className="h-4 w-4" />
Add Asset
</Button>
)}
/>
{assets.length === 0 ? (
<EmptyPanel
icon={ImagePlus}
title="Noch keine Assets"
description="Assets sind optional. Füge Galeriebilder hinzu jedes braucht ein Medium und lokalisierten Alt-Text."
action={(
<Button
type="button"
variant="outline"
onClick={() => setAssets((current) => [...current, createEmptyAsset(current.length)])}
>
<Plus className="h-4 w-4" />
Add Asset
</Button>
)}
/>
) : (
<Accordion type="multiple" className="space-y-3">
{assets.map((asset, index) => {
const ready = isPortfolioAssetReady({
mediaAssetId: asset.media.assetId,
altAr: asset.altAr,
altEn: asset.altEn,
altDe: asset.altDe,
});
return (
<AccordionItem
key={asset.id ?? `asset-${index}`}
value={asset.id ?? `asset-${index}`}
className="border-border/70 bg-surface-2"
>
<AccordionTrigger className="hover:no-underline">
<span className="flex flex-wrap items-center gap-2.5">
<Badge variant={ready ? "success" : "outline"}>{ready ? "Ready" : "Open"}</Badge>
<span className="text-sm font-medium text-foreground">{`Asset ${index + 1}`}</span>
</span>
</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setAssets((current) => moveArrayItem(current, index, index - 1))}
disabled={index === 0}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="outline"
size="icon"
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"
size="icon"
className="text-destructive"
onClick={() =>
setAssets((current) =>
current.filter((_, currentIndex) => currentIndex !== index),
)
}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<MediaFieldPicker
title="Asset Image"
value={asset.media}
onChange={(media) =>
setAssets((current) =>
current.map((item, currentIndex) =>
currentIndex === index
? { ...item, media, filePath: media.url }
: item,
),
)
}
options={mediaOptions}
hasInitialValue={Boolean(asset.media.assetId || asset.filePath)}
inputName={`asset-media-${index}`}
fileFieldName={asset.fileFieldName}
allowClear
clearLabel="Remove Asset"
emptyValue={{ mode: "upload", assetId: "", url: "", label: "" }}
/>
<LocaleInputs
title="Alt Text"
values={{ Ar: asset.altAr, En: asset.altEn, De: asset.altDe }}
onChange={(key, value) =>
setAssets((current) =>
current.map((item, currentIndex) =>
currentIndex === index ? { ...item, [`alt${key}`]: value } : item,
),
)
}
/>
</div>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
)}
</section>
</div>
</AppCard>
</div>
</div>
<AppCard level={3} padding="md" contentClassName="space-y-4">
{showValidation && firstIncompleteStep ? (
<div className="rounded-nested border border-status-warning/30 bg-status-warning-soft px-4 py-3">
<div className="flex items-start gap-3">
<CircleAlert className="mt-0.5 h-4 w-4 text-status-warning" />
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">Some required fields are still missing.</p>
<p className="text-sm text-muted-foreground">
Fill the Basics and Localized Content fields, then save.
</p>
</div>
</div>
</div>
) : null}
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-end">
<div className="flex flex-wrap items-center gap-3">
{firstIncompleteStep ? (
<p className="text-sm text-muted-foreground">
Fill Basics and Localized Content to publish or save as a draft anytime.
</p>
) : (
<p className="text-sm text-muted-foreground">
Ready to save. Sections and assets are optional.
</p>
)}
<DraftButton onSelect={() => setIntent("draft")} />
<SubmitButton onSelect={() => setIntent("save")} />
</div>
</div>
</AppCard>
</form>
);
}