This commit is contained in:
@@ -0,0 +1,670 @@
|
||||
"use client";
|
||||
|
||||
import type { PortfolioProjectViewMode, PortfolioSectionType } from "@prisma/client";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
CalendarDays,
|
||||
FolderTree,
|
||||
Layers3,
|
||||
Link2,
|
||||
Plus,
|
||||
Text,
|
||||
Trash2,
|
||||
UserRound,
|
||||
} from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
|
||||
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 { Textarea } from "@/components/ui/textarea";
|
||||
import { moveArrayItem } from "@/lib/array";
|
||||
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." },
|
||||
];
|
||||
|
||||
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 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 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 hasText(value: string) {
|
||||
return value.trim().length > 0;
|
||||
}
|
||||
|
||||
function sectionReady(section: SectionFormValue) {
|
||||
if (!hasText(section.titleAr) || !hasText(section.titleEn) || !hasText(section.titleDe)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (section.type === "GALLERY") {
|
||||
return hasText(section.media.assetId);
|
||||
}
|
||||
|
||||
if (section.type === "LINK") {
|
||||
return hasText(section.linkUrl);
|
||||
}
|
||||
|
||||
return hasText(section.bodyAr) && hasText(section.bodyEn) && hasText(section.bodyDe);
|
||||
}
|
||||
|
||||
function assetReady(asset: AssetFormValue) {
|
||||
return hasText(asset.media.assetId) && hasText(asset.altAr) && hasText(asset.altEn) && hasText(asset.altDe);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function PortfolioProjectForm({
|
||||
action,
|
||||
categories,
|
||||
mediaOptions,
|
||||
project,
|
||||
formId,
|
||||
redirectPath,
|
||||
}: PortfolioProjectFormProps) {
|
||||
const [projectState, setProjectState] = useState<ProjectFormState>(createInitialState(project, categories));
|
||||
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[]>(
|
||||
project?.sections.length
|
||||
? 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,
|
||||
}))
|
||||
: [createEmptySection(0)],
|
||||
);
|
||||
const [assets, setAssets] = useState<AssetFormValue[]>(
|
||||
project?.assets.length
|
||||
? 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,
|
||||
}))
|
||||
: [createEmptyAsset(0)],
|
||||
);
|
||||
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 })));
|
||||
|
||||
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]);
|
||||
|
||||
return (
|
||||
<form id={formId} action={action} className="space-y-10">
|
||||
<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} />
|
||||
|
||||
<section className="space-y-5">
|
||||
<SectionHeader
|
||||
title="Basic Information"
|
||||
description="Core data, visibility, and chosen project view."
|
||||
/>
|
||||
|
||||
<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>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name.de} / {category.name.en}
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
<input type="checkbox" name="isPublished" checked={projectState.isPublished} onChange={(event) => setProjectState((current) => ({ ...current, isPublished: event.target.checked }))} />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-5">
|
||||
<SectionHeader
|
||||
title="Localized Content"
|
||||
description="Project title, service label, and summary in all locales."
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<section className="space-y-5">
|
||||
<SectionHeader
|
||||
title="Cover Media"
|
||||
description="Select the cover only from media library."
|
||||
/>
|
||||
|
||||
<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-5">
|
||||
<SectionHeader
|
||||
title="Sections"
|
||||
description="Every section stays inline and simple."
|
||||
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={sectionReady(section) ? "success" : "outline"}>
|
||||
{sectionReady(section) ? "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>
|
||||
|
||||
<section className="space-y-5">
|
||||
<SectionHeader
|
||||
title="Assets"
|
||||
description="Images from media library only."
|
||||
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={assetReady(asset) ? "success" : "outline"}>
|
||||
{assetReady(asset) ? "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 className="flex justify-end">
|
||||
<Button type="submit">
|
||||
Save Project
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user