Files
sass-mohfarawati/components/admin/portfolio-project-form.tsx
T
moh 369c0e24c6
CI / quality (push) Canceled after 0s
STYLED - Turn the project form into a step-by-step wizard journey
Replace the flat two-column layout with a guided 4-step wizard
(Grundlagen -> Inhalte -> Abschnitte -> Medien) using the existing wizard
progress model:

- A top stepper shows each step's number/label and completion (check), and
  jumps to any step on click.
- One step is visible at a time in a single comfortable centered column; the
  others stay mounted but hidden, so every named field still submits.
- Footer navigation: Zurueck / Save as draft / Weiter, with Save Project on
  the final step.

No field-name or save-logic changes. All 375 tests pass; tsc and eslint clean.
2026-09-20 18:17:40 +02:00

1282 lines
47 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,
ArrowLeft,
ArrowRight,
ArrowUp,
BookOpenText,
BriefcaseBusiness,
CalendarDays,
Check,
CircleAlert,
FolderTree,
Grid2x2,
ImagePlus,
Layers3,
Link2,
Plus,
Text,
Trash2,
UserRound,
} from "lucide-react";
import Link from "next/link";
import { Fragment, 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,
type PortfolioWizardStep,
type PortfolioWizardStepState,
} 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 },
];
const WIZARD_STEPS: Array<{ key: PortfolioWizardStep; label: string; description: string }> = [
{ key: "basics", label: "Grundlagen", description: "Kategorie & Metadaten" },
{ key: "content", label: "Inhalte", description: "Lokalisierte Texte" },
{ key: "sections", label: "Abschnitte", description: "Projektgeschichte" },
{ key: "assets", label: "Medien", description: "Cover & Galerie" },
];
function WizardStepper({
activeKey,
progress,
onSelect,
}: {
activeKey: PortfolioWizardStep;
progress: PortfolioWizardStepState[];
onSelect: (key: PortfolioWizardStep) => void;
}) {
return (
<div className="flex items-center gap-1 overflow-x-auto pb-1">
{WIZARD_STEPS.map((step, index) => {
const complete = progress.find((item) => item.key === step.key)?.complete ?? false;
const active = step.key === activeKey;
return (
<Fragment key={step.key}>
<button
type="button"
onClick={() => onSelect(step.key)}
className={cn(
"flex shrink-0 items-center gap-2.5 rounded-pill px-3 py-2 text-left transition-colors",
active ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-accent/40",
)}
>
<span
className={cn(
"flex h-7 w-7 items-center justify-center rounded-pill border text-xs font-semibold",
active
? "border-primary bg-primary text-primary-foreground"
: complete
? "border-status-success/40 bg-status-success/15 text-status-success"
: "border-border/70 text-muted-foreground",
)}
>
{complete && !active ? <Check className="h-4 w-4" /> : index + 1}
</span>
<span className="hidden sm:block">
<span className="block text-sm font-semibold leading-none">{step.label}</span>
<span className="mt-0.5 block text-xs text-muted-foreground">{step.description}</span>
</span>
</button>
{index < WIZARD_STEPS.length - 1 ? (
<div className="h-px w-5 shrink-0 bg-border/60 sm:w-8" />
) : null}
</Fragment>
);
})}
</div>
);
}
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 border-b border-border/60 pb-3 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 [activeStep, setActiveStep] = useState<PortfolioWizardStep>("basics");
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);
const activeStepIndex = WIZARD_STEPS.findIndex((step) => step.key === activeStep);
const isLastStep = activeStepIndex === WIZARD_STEPS.length - 1;
const goToStep = (index: number) => {
const target = WIZARD_STEPS[Math.min(Math.max(index, 0), WIZARD_STEPS.length - 1)];
setActiveStep(target.key);
};
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" />
<WizardStepper activeKey={activeStep} progress={progress} onSelect={setActiveStep} />
<div className="mx-auto w-full max-w-3xl">
<div className={cn(activeStep !== "basics" && "hidden")}>
<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>
</div>
<div className={cn(activeStep !== "content" && "hidden")}>
<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>
</div>
<div className={cn(activeStep !== "sections" && "hidden")}>
<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>
</div>
<div className={cn(activeStep !== "assets" && "hidden")}>
<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>
</div>
</div>
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4 border-t border-border/60 pt-6">
{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 items-center justify-between gap-3">
<Button
type="button"
variant="outline"
onClick={() => goToStep(activeStepIndex - 1)}
disabled={activeStepIndex === 0}
>
<ArrowLeft className="h-4 w-4" />
Zurück
</Button>
<div className="flex items-center gap-2">
<DraftButton onSelect={() => setIntent("draft")} />
{isLastStep ? (
<SubmitButton onSelect={() => setIntent("save")} />
) : (
<Button type="button" onClick={() => goToStep(activeStepIndex + 1)}>
Weiter
<ArrowRight className="h-4 w-4" />
</Button>
)}
</div>
</div>
</div>
</form>
);
}