Files
sass-mohfarawati/components/admin/portfolio-project-form.tsx
T
MOH 3eb9238268
CI / quality (push) Waiting to run
moh/admin-portfolio-ux-improvements
2026-03-15 04:42:09 +01:00

1235 lines
46 KiB
TypeScript

"use client";
import type { PortfolioProjectViewMode, PortfolioSectionType } from "@prisma/client";
import {
ArrowDown,
ArrowUp,
CalendarDays,
CheckCircle2,
ChevronLeft,
ChevronRight,
CircleAlert,
CircleDashed,
FolderTree,
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 { AppCard } from "@/components/ui/app-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { moveArrayItem } from "@/lib/array";
import { getAdminAppPath } from "@/lib/admin-routing";
import {
getFirstIncompleteWizardStep,
getPortfolioWizardProgress,
isPortfolioAssetReady,
isPortfolioSectionReady,
type PortfolioWizardStep,
} 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 viewModeOptions: Array<{
value: PortfolioProjectViewMode;
label: string;
description: string;
}> = [
{ value: "GRID", label: "Grid", description: "Balanced modular layout." },
{ value: "STORY", label: "Story", description: "Narrative section flow." },
{ value: "CASE_STUDY", label: "Case Study", description: "Structured challenge and result view." },
];
const wizardSteps: Array<{
key: PortfolioWizardStep;
label: string;
description: string;
}> = [
{
key: "basics",
label: "Basics",
description: "Category, slug, client, year, and status.",
},
{
key: "content",
label: "Localized Content",
description: "Title, service label, and summary in all locales.",
},
{
key: "sections",
label: "Sections",
description: "Build the project story with ordered content blocks.",
},
{
key: "assets",
label: "Assets & Media",
description: "Cover and gallery assets from the media library.",
},
];
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) {
return [createEmptySection(0)];
}
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) {
return [createEmptyAsset(0)];
}
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-3">
<p className="text-sm font-medium text-foreground">{title}</p>
<div className="grid gap-3 xl:grid-cols-3">
{locales.map((locale) => (
<AppCard key={`${title}-${locale.suffix}`} level={2} padding="sm" className="space-y-2 rounded-nested">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">{locale.label}</p>
{multiline ? (
<Textarea
name={namePrefix ? `${namePrefix}${locale.suffix}` : undefined}
rows={5}
value={values[locale.suffix]}
onChange={(event) => onChange(locale.suffix, event.target.value)}
/>
) : (
<Input
name={namePrefix ? `${namePrefix}${locale.suffix}` : undefined}
value={values[locale.suffix]}
onChange={(event) => onChange(locale.suffix, event.target.value)}
/>
)}
</AppCard>
))}
</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 StepStateBadge({
complete,
active,
showValidation,
}: {
complete: boolean;
active: boolean;
showValidation: boolean;
}) {
if (complete) {
return <Badge variant="success">Complete</Badge>;
}
if (showValidation) {
return <Badge variant="warning">Needs Attention</Badge>;
}
if (active) {
return <Badge variant="outline">Current</Badge>;
}
return <Badge variant="outline">Incomplete</Badge>;
}
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save Project"}
</Button>
);
}
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 initialProgress = getPortfolioWizardProgress({
basics: initialProjectState,
content: initialProjectState,
sections: initialSections.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: initialAssets.map((asset) => ({
mediaAssetId: asset.media.assetId,
altAr: asset.altAr,
altEn: asset.altEn,
altDe: asset.altDe,
})),
});
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 [currentStep, setCurrentStep] = useState<PortfolioWizardStep>(
getFirstIncompleteWizardStep(initialProgress) ?? "basics",
);
const [showValidation, setShowValidation] = useState(false);
const sectionsInputRef = useRef<HTMLInputElement | null>(null);
const assetsInputRef = useRef<HTMLInputElement | null>(null);
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 currentStepIndex = wizardSteps.findIndex((step) => step.key === currentStep);
const selectedCategory =
availableCategories.find((category) => category.id === projectState.categoryId) ?? null;
const projectLabel =
projectState.titleDe.trim() ||
projectState.titleEn.trim() ||
projectState.titleAr.trim() ||
projectState.slug.trim() ||
"Untitled Project";
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) => {
if (!firstIncompleteStep) {
return;
}
event.preventDefault();
setShowValidation(true);
setCurrentStep(firstIncompleteStep);
}}
>
<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} />
<AppCard level={3} padding="lg" className="sticky top-4 z-10">
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={projectState.isPublished ? "success" : "warning"}>
{projectState.isPublished ? "Published" : "Draft"}
</Badge>
{projectState.isFeatured ? <Badge variant="outline">Featured</Badge> : null}
{!selectedCategory?.isActive ? <Badge variant="warning">Inactive Category</Badge> : null}
</div>
<div>
<h2 className="text-2xl font-semibold text-foreground">{projectLabel}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{selectedCategory ? `${selectedCategory.name.de || selectedCategory.name.en || selectedCategory.name.ar}` : "No category selected"}
</p>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<AppCard level={2} padding="sm" className="rounded-nested">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Current Step</p>
<p className="mt-2 text-sm font-semibold text-foreground">
{wizardSteps[currentStepIndex]?.label ?? "Basics"}
</p>
</AppCard>
<AppCard level={2} padding="sm" className="rounded-nested">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Sections</p>
<p className="mt-2 text-sm font-semibold text-foreground">{sections.length}</p>
</AppCard>
<AppCard level={2} padding="sm" className="rounded-nested">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Assets</p>
<p className="mt-2 text-sm font-semibold text-foreground">{assets.length}</p>
</AppCard>
<AppCard level={2} padding="sm" className="rounded-nested">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Progress</p>
<p className="mt-2 text-sm font-semibold text-foreground">
{progress.filter((step) => step.complete).length}/{progress.length} ready
</p>
</AppCard>
</div>
</div>
{showValidation && firstIncompleteStep ? (
<AppCard level={2} padding="sm" className="rounded-nested border-status-warning/30">
<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">Complete the highlighted step before saving.</p>
<p className="text-sm text-muted-foreground">
The wizard moved to the first incomplete step so the missing fields are easier to find.
</p>
</div>
</div>
</AppCard>
) : null}
</div>
</AppCard>
<Tabs value={currentStep} onValueChange={(value) => setCurrentStep(value as PortfolioWizardStep)}>
<TabsList className="grid w-full gap-3 rounded-surface bg-transparent p-0 lg:grid-cols-4">
{wizardSteps.map((step, index) => {
const stepProgress = progress.find((entry) => entry.key === step.key);
const isActive = currentStep === step.key;
return (
<TabsTrigger
key={step.key}
value={step.key}
className={cn(
"w-full rounded-surface border border-border/80 bg-surface-2 p-0 text-left hover:bg-accent/30",
isActive && "border-input bg-accent/20 text-foreground shadow-xs",
)}
>
<div className="flex w-full flex-col gap-3 p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-border/80 bg-background text-sm font-semibold text-foreground">
{index + 1}
</span>
<div>
<p className="text-sm font-semibold text-foreground">{step.label}</p>
<p className="text-xs text-muted-foreground">{step.description}</p>
</div>
</div>
{stepProgress?.complete ? (
<CheckCircle2 className="h-4 w-4 text-status-success" />
) : (
<CircleDashed className="h-4 w-4 text-muted-foreground" />
)}
</div>
<div className="flex items-center justify-between gap-3">
<StepStateBadge
complete={Boolean(stepProgress?.complete)}
active={isActive}
showValidation={showValidation && !stepProgress?.complete}
/>
<p className="text-xs text-muted-foreground">{stepProgress?.summary}</p>
</div>
</div>
</TabsTrigger>
);
})}
</TabsList>
<TabsContent value="basics">
<AppCard level={3} padding="lg">
<section className="space-y-6">
<SectionHeader
title="Basic Information"
description="Choose the category and the stable project metadata first."
/>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="categoryId">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">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"
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">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"
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">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"
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">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"
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">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"
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) => (
<button
key={option.value}
type="button"
onClick={() => setProjectState((current) => ({ ...current, viewMode: option.value }))}
className={cn(
"rounded-surface border px-4 py-4 text-left transition-colors",
projectState.viewMode === option.value
? "border-input bg-accent/20"
: "border-border/70 bg-background hover:border-input hover:bg-accent/10",
)}
>
<p className="text-sm font-semibold text-foreground">{option.label}</p>
<p className="mt-1 text-sm text-muted-foreground">{option.description}</p>
</button>
))}
</div>
<div className="grid gap-3 md:grid-cols-2">
<label className="flex items-center justify-between rounded-nested border border-input bg-card px-4 py-3">
<div>
<p className="text-sm font-medium text-foreground">Featured</p>
<p className="text-xs text-muted-foreground">Highlight the project across the portfolio.</p>
</div>
<input
type="checkbox"
name="isFeatured"
checked={projectState.isFeatured}
onChange={(event) =>
setProjectState((current) => ({ ...current, isFeatured: event.target.checked }))
}
/>
</label>
<label className="flex items-center justify-between rounded-nested border border-input bg-card px-4 py-3">
<div>
<p className="text-sm font-medium text-foreground">Published</p>
<p className="text-xs text-muted-foreground">Only published projects appear on public pages.</p>
</div>
<input
type="checkbox"
name="isPublished"
checked={projectState.isPublished}
onChange={(event) =>
setProjectState((current) => ({ ...current, isPublished: event.target.checked }))
}
/>
</label>
</div>
</section>
</AppCard>
</TabsContent>
<TabsContent value="content">
<AppCard level={3} padding="lg">
<section className="space-y-6">
<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>
</TabsContent>
<TabsContent value="sections">
<AppCard level={3} padding="lg">
<section className="space-y-6">
<SectionHeader
title="Sections"
description="Each section keeps one clear role in the project story."
action={(
<Button
type="button"
variant="outline"
onClick={() => setSections((current) => [...current, createEmptySection(current.length)])}
>
<Plus className="h-4 w-4" />
Add Section
</Button>
)}
/>
<div className="space-y-4">
{sections.map((section, index) => (
<div
key={section.id ?? `section-${index}`}
className="space-y-4 rounded-surface border border-border/70 bg-background p-4"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Badge variant={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,
}) ? "success" : "outline"}>
{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,
})
? "Ready"
: "Open"}
</Badge>
<p className="text-sm font-medium text-foreground">{`Section ${index + 1}`}</p>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => setSections((current) => moveArrayItem(current, index, index - 1))}
disabled={index === 0}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="outline"
onClick={() => setSections((current) => moveArrayItem(current, index, index + 1))}
disabled={index === sections.length - 1}
>
<ArrowDown className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
className="text-destructive"
onClick={() =>
sections.length > 1 &&
setSections((current) =>
current.filter((_, currentIndex) => currentIndex !== index),
)
}
disabled={sections.length === 1}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>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}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{section.type === "LINK" ? (
<div className="space-y-2">
<Label>Link URL</Label>
<Input
value={section.linkUrl}
onChange={(event) =>
setSections((current) =>
current.map((item, currentIndex) =>
currentIndex === index
? { ...item, linkUrl: event.target.value }
: item,
),
)
}
/>
</div>
) : 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>
))}
</div>
</section>
</AppCard>
</TabsContent>
<TabsContent value="assets">
<div className="space-y-6">
<AppCard level={3} padding="lg">
<section className="space-y-6">
<SectionHeader
title="Cover Media"
description="Choose the primary cover that represents the project in listings."
/>
<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>
</AppCard>
<AppCard level={3} padding="lg">
<section className="space-y-6">
<SectionHeader
title="Assets"
description="Assets stay ordered and ready for localized alt text."
action={(
<Button
type="button"
variant="outline"
onClick={() => setAssets((current) => [...current, createEmptyAsset(current.length)])}
>
<Plus className="h-4 w-4" />
Add Asset
</Button>
)}
/>
<div className="space-y-4">
{assets.map((asset, index) => (
<div
key={asset.id ?? `asset-${index}`}
className="space-y-4 rounded-surface border border-border/70 bg-background p-4"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Badge variant={isPortfolioAssetReady({
mediaAssetId: asset.media.assetId,
altAr: asset.altAr,
altEn: asset.altEn,
altDe: asset.altDe,
}) ? "success" : "outline"}>
{isPortfolioAssetReady({
mediaAssetId: asset.media.assetId,
altAr: asset.altAr,
altEn: asset.altEn,
altDe: asset.altDe,
})
? "Ready"
: "Open"}
</Badge>
<p className="text-sm font-medium text-foreground">{`Asset ${index + 1}`}</p>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => setAssets((current) => moveArrayItem(current, index, index - 1))}
disabled={index === 0}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="outline"
onClick={() => setAssets((current) => moveArrayItem(current, index, index + 1))}
disabled={index === assets.length - 1}
>
<ArrowDown className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
className="text-destructive"
onClick={() =>
assets.length > 1 &&
setAssets((current) =>
current.filter((_, currentIndex) => currentIndex !== index),
)
}
disabled={assets.length === 1}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</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>
))}
</div>
</section>
</AppCard>
</div>
</TabsContent>
</Tabs>
<AppCard level={3} padding="lg">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={() => setCurrentStep(wizardSteps[Math.max(currentStepIndex - 1, 0)]!.key)}
disabled={currentStepIndex === 0}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
type="button"
variant="outline"
onClick={() =>
setCurrentStep(
wizardSteps[Math.min(currentStepIndex + 1, wizardSteps.length - 1)]!.key,
)
}
disabled={currentStepIndex === wizardSteps.length - 1}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-wrap items-center gap-3">
{firstIncompleteStep ? (
<p className="text-sm text-muted-foreground">
Save becomes available after all steps are complete.
</p>
) : (
<p className="text-sm text-muted-foreground">
All steps are ready. You can save now.
</p>
)}
<SubmitButton />
</div>
</div>
</AppCard>
</form>
);
}