Files
MOH 0a5f77d8de REFACTORED - migrate the data layer from Prisma to Drizzle (unify the stack)
- Add lib/db (Drizzle schema: 7 tables + 6 enums + relations, postgres.js client),
  drizzle.config.ts, lib/db/enums.ts (Prisma-compatible enum objects)
- Rewrite all 14 app consumers + 4 admin components to Drizzle
- Move the test DB layer to Drizzle + in-process PGlite; convert all 8 integration
  test files + factories (371 tests green)
- Remove Prisma: deps, prisma/ schema+migrations, lib/prisma.ts, Dockerfile prisma
  generate; wire db:generate/migrate/push/studio to drizzle-kit; update Makefile
- Preserve the old seed as scripts/legacy-prisma-seed.cjs (needs a Drizzle rewrite)
2026-08-07 14:18:41 +02:00

1364 lines
50 KiB
TypeScript
Raw Permalink 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,
CheckCircle2,
ChevronLeft,
ChevronRight,
CircleAlert,
CircleDashed,
Files,
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 { StatsCard } from "@/components/dashboard/stats-card";
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 { Separator } from "@/components/ui/separator";
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 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 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">
<div className="grid gap-3 xl:grid-cols-3">
{locales.map((locale) => (
<AppCard
key={`${title}-${locale.suffix}`}
level={2}
layer="single"
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}
placeholder={`${title} ${locale.label}`}
value={values[locale.suffix]}
onChange={(event) => onChange(locale.suffix, event.target.value)}
/>
) : (
<Input
name={namePrefix ? `${namePrefix}${locale.suffix}` : undefined}
placeholder={`${title} ${locale.label}`}
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>
);
}
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 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} layer="single" 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} layer="single" padding="lg" className="space-y-6">
<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">
<StatsCard
title="Step"
value={wizardSteps[currentStepIndex]?.label ?? "Basics"}
icon={Layers3}
/>
<StatsCard title="Sections" value={String(sections.length)} icon={Files} />
<StatsCard title="Assets" value={String(assets.length)} icon={ImagePlus} />
<StatsCard
title="Progress"
value={`${progress.filter((step) => step.complete).length}/${progress.length}`}
icon={CheckCircle2}
/>
</div>
</div>
{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">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>
</div>
) : null}
</div>
</AppCard>
<Tabs value={currentStep} onValueChange={(value) => setCurrentStep(value as PortfolioWizardStep)}>
<div className="grid gap-6 xl:grid-cols-[300px_minmax(0,1fr)] xl:items-start">
<AppCard level={2} layer="single" padding="sm" className="xl:sticky xl:top-4">
<TabsList className="grid h-auto w-full gap-3 rounded-none bg-transparent p-0">
{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-nested border border-border/80 bg-background 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-center 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-surface-2 text-sm font-semibold text-foreground">
{index + 1}
</span>
<div>
<p className="text-sm font-semibold text-foreground">{step.label}</p>
</div>
</div>
{stepProgress?.complete ? (
<CheckCircle2 className="h-4 w-4 text-status-success" />
) : (
<CircleDashed className="h-4 w-4 text-muted-foreground" />
)}
</div>
<StepStateBadge
complete={Boolean(stepProgress?.complete)}
active={isActive}
showValidation={showValidation && !stepProgress?.complete}
/>
</div>
</TabsTrigger>
);
})}
</TabsList>
</AppCard>
<div className="space-y-6">
<TabsContent value="basics" className="mt-0">
<AppCard level={3} layer="single" 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" 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>
</TabsContent>
<TabsContent value="content" className="mt-0">
<AppCard level={3} layer="single" 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" className="mt-0">
<AppCard level={3} layer="single" 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 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>
))}
</div>
</section>
</AppCard>
</TabsContent>
<TabsContent value="assets" className="mt-0">
<AppCard level={3} layer="single" padding="lg">
<div className="space-y-8">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
<div className="space-y-6">
<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>
<Separator />
<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="grid gap-4 md:grid-cols-2">
{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>
</div>
<div className="space-y-4 lg:sticky lg:top-4 lg:self-start">
<AppCard level={2} layer="single" padding="sm" className="space-y-4">
<div className="flex items-center gap-2">
<ImagePlus className="h-4 w-4 text-brand-primary" />
<p className="text-sm font-semibold text-foreground">Assets Overview</p>
</div>
<div className="grid gap-3">
<StatsCard title="Cover" value={coverMedia.assetId ? "Selected" : "Missing"} icon={ImagePlus} />
<StatsCard title="Assets" value={String(assets.length)} icon={Files} />
<StatsCard
title="Ready"
value={String(
assets.filter((asset) =>
isPortfolioAssetReady({
mediaAssetId: asset.media.assetId,
altAr: asset.altAr,
altEn: asset.altEn,
altDe: asset.altDe,
}),
).length,
)}
icon={CheckCircle2}
/>
</div>
<div className="rounded-nested border border-dashed border-border/70 bg-background px-4 py-3 text-sm text-muted-foreground">
Start with the cover, then add gallery assets. Each asset needs media plus localized alt text.
</div>
</AppCard>
</div>
</div>
</div>
</AppCard>
</TabsContent>
</div>
</div>
</Tabs>
<AppCard level={3} layer="single" 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>
);
}