Implement portfolio admin management
This commit is contained in:
@@ -0,0 +1,660 @@
|
||||
"use client";
|
||||
|
||||
import type { MediaKind, PortfolioAssetKind, PortfolioSectionType } from "@prisma/client";
|
||||
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { MediaFieldPicker, type MediaFieldState } from "@/components/root/media-field-picker";
|
||||
import { AppCard } from "@/components/ui/app-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { moveArrayItem } from "@/lib/array";
|
||||
import type { MediaOption } from "@/lib/media";
|
||||
import type { PortfolioCategoryView, PortfolioProjectView } from "@/lib/portfolio";
|
||||
|
||||
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: PortfolioAssetKind;
|
||||
filePath: string;
|
||||
fileFieldName: string;
|
||||
media: MediaFieldState;
|
||||
altAr: string;
|
||||
altEn: string;
|
||||
altDe: string;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
type PortfolioProjectFormProps = {
|
||||
action: (formData: FormData) => void | Promise<void>;
|
||||
categories: PortfolioCategoryView[];
|
||||
mediaOptions: MediaOption[];
|
||||
project?: PortfolioProjectView | null;
|
||||
redirectPath: string;
|
||||
submitLabel: string;
|
||||
};
|
||||
|
||||
const sectionTypeOptions: PortfolioSectionType[] = [
|
||||
"RICH_TEXT",
|
||||
"GALLERY",
|
||||
"STATS",
|
||||
"DELIVERABLES",
|
||||
"LINK",
|
||||
];
|
||||
|
||||
const assetKindOptions: PortfolioAssetKind[] = ["IMAGE", "DOCUMENT"];
|
||||
const localeFieldConfig = [
|
||||
{ key: "ar" as const, suffix: "Ar" as const, label: "AR" },
|
||||
{ key: "en" as const, suffix: "En" as const, label: "EN" },
|
||||
{ key: "de" as const, suffix: "De" as const, label: "DE" },
|
||||
];
|
||||
|
||||
function createMediaFieldState(params: {
|
||||
kind: MediaKind;
|
||||
assetId?: string | null;
|
||||
url?: string | null;
|
||||
label?: string | null;
|
||||
}): MediaFieldState {
|
||||
return {
|
||||
mode: params.assetId ? "library" : params.url ? "external" : "upload",
|
||||
assetId: params.assetId ?? "",
|
||||
url: params.url ?? "",
|
||||
label: params.label ?? "",
|
||||
kind: params.kind,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export function PortfolioProjectForm({
|
||||
action,
|
||||
categories,
|
||||
mediaOptions,
|
||||
project,
|
||||
redirectPath,
|
||||
submitLabel,
|
||||
}: PortfolioProjectFormProps) {
|
||||
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: asset.kind,
|
||||
filePath: asset.filePath,
|
||||
fileFieldName: `asset-upload-${index}`,
|
||||
media: createMediaFieldState({
|
||||
kind: asset.kind,
|
||||
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,
|
||||
}))
|
||||
: [],
|
||||
);
|
||||
|
||||
const sectionsPayload = JSON.stringify(
|
||||
sections.map((section, index) => ({
|
||||
...section,
|
||||
sortOrder: index,
|
||||
})),
|
||||
);
|
||||
|
||||
const assetsPayload = JSON.stringify(
|
||||
assets.map((asset, index) => ({
|
||||
...asset,
|
||||
sortOrder: index,
|
||||
})),
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={action} className="space-y-6">
|
||||
<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="sections" value={sectionsPayload} />
|
||||
<input type="hidden" name="assets" value={assetsPayload} />
|
||||
|
||||
<AppCard>
|
||||
<CardHeader>
|
||||
<CardTitle>Project Basics</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="categoryId">Category</Label>
|
||||
<select
|
||||
id="categoryId"
|
||||
name="categoryId"
|
||||
defaultValue={project?.category.id ?? categories[0]?.id ?? ""}
|
||||
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
|
||||
required
|
||||
>
|
||||
{categories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name.de} / {category.name.en}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slug">Slug</Label>
|
||||
<Input id="slug" name="slug" defaultValue={project?.slug ?? ""} required />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clientName">Client Name</Label>
|
||||
<Input
|
||||
id="clientName"
|
||||
name="clientName"
|
||||
defaultValue={project?.clientName ?? ""}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="projectYear">Project Year</Label>
|
||||
<Input
|
||||
id="projectYear"
|
||||
name="projectYear"
|
||||
type="number"
|
||||
min="2000"
|
||||
max="2100"
|
||||
defaultValue={project?.projectYear ?? new Date().getFullYear()}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="previewUrl">Preview URL</Label>
|
||||
<Input
|
||||
id="previewUrl"
|
||||
name="previewUrl"
|
||||
type="url"
|
||||
defaultValue={project?.previewUrl ?? ""}
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sortOrder">Sort Order</Label>
|
||||
<Input
|
||||
id="sortOrder"
|
||||
name="sortOrder"
|
||||
type="number"
|
||||
min="0"
|
||||
defaultValue={project?.sortOrder ?? 0}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<MediaFieldPicker
|
||||
title="Cover Image"
|
||||
value={coverMedia}
|
||||
onChange={setCoverMedia}
|
||||
options={mediaOptions}
|
||||
inputName="coverMedia"
|
||||
fileFieldName="coverFile"
|
||||
accept="image/*,.svg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 rounded-nested border border-border px-4 py-3 text-sm">
|
||||
<input type="checkbox" name="isFeatured" defaultChecked={project?.isFeatured ?? false} />
|
||||
Featured
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 rounded-nested border border-border px-4 py-3 text-sm">
|
||||
<input type="checkbox" name="isPublished" defaultChecked={project?.isPublished ?? false} />
|
||||
Published
|
||||
</label>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
|
||||
<AppCard>
|
||||
<CardHeader>
|
||||
<CardTitle>Localized Content</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="de">
|
||||
<TabsList>
|
||||
{localeFieldConfig.map((locale) => (
|
||||
<TabsTrigger key={locale.key} value={locale.key}>
|
||||
{locale.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{localeFieldConfig.map((locale) => (
|
||||
<TabsContent key={locale.key} value={locale.key}>
|
||||
<div className="grid gap-4 rounded-surface border border-border p-4 md:grid-cols-2">
|
||||
<div className="md:col-span-2 text-sm text-muted-foreground">
|
||||
{locale.key === "ar"
|
||||
? "Arabic content for the Arabic website."
|
||||
: locale.key === "en"
|
||||
? "English content for the English website."
|
||||
: "German content for the German website."}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`title${locale.suffix}`}>{`Title ${locale.label}`}</Label>
|
||||
<Input
|
||||
id={`title${locale.suffix}`}
|
||||
name={`title${locale.suffix}`}
|
||||
defaultValue={project?.title[locale.key] ?? ""}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`serviceLabel${locale.suffix}`}>{`Service Label ${locale.label}`}</Label>
|
||||
<Input
|
||||
id={`serviceLabel${locale.suffix}`}
|
||||
name={`serviceLabel${locale.suffix}`}
|
||||
defaultValue={project?.serviceLabel[locale.key] ?? ""}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor={`summary${locale.suffix}`}>{`Summary ${locale.label}`}</Label>
|
||||
<Textarea
|
||||
id={`summary${locale.suffix}`}
|
||||
name={`summary${locale.suffix}`}
|
||||
defaultValue={project?.summary[locale.key] ?? ""}
|
||||
rows={4}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
|
||||
<AppCard>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Sections</CardTitle>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setSections((current) => [...current, createEmptySection(current.length)])}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Section
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{sections.map((section, index) => (
|
||||
<AppCard key={section.id ?? `${section.type}-${index}`} level={2}>
|
||||
<CardContent className="space-y-4 p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-sm font-medium text-foreground">Section #{index + 1}</p>
|
||||
<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={() =>
|
||||
setSections((current) => current.filter((_, currentIndex) => currentIndex !== index))
|
||||
}
|
||||
disabled={sections.length === 1}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Type</Label>
|
||||
<select
|
||||
value={section.type}
|
||||
onChange={(event) =>
|
||||
setSections((current) =>
|
||||
current.map((item, currentIndex) =>
|
||||
currentIndex === index
|
||||
? { ...item, type: event.target.value as PortfolioSectionType }
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
|
||||
>
|
||||
{sectionTypeOptions.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<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}
|
||||
inputName={`section-media-${index}`}
|
||||
fileFieldName={`section-image-upload-${index}`}
|
||||
accept="image/*,.svg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:col-span-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>
|
||||
|
||||
{localeFieldConfig.map((locale) => (
|
||||
<div key={`${locale.key}-title-${index}`} className="space-y-2">
|
||||
<Label>{`Title ${locale.label}`}</Label>
|
||||
<Input
|
||||
value={section[`title${locale.suffix}` as keyof SectionFormValue] as string}
|
||||
onChange={(event) =>
|
||||
setSections((current) =>
|
||||
current.map((item, currentIndex) =>
|
||||
currentIndex === index
|
||||
? {
|
||||
...item,
|
||||
[`title${locale.suffix}`]: event.target.value,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{localeFieldConfig.map((locale) => (
|
||||
<div key={`${locale.key}-body-${index}`} className="space-y-2 md:col-span-2">
|
||||
<Label>{`Body ${locale.label}`}</Label>
|
||||
<Textarea
|
||||
rows={4}
|
||||
value={section[`body${locale.suffix}` as keyof SectionFormValue] as string}
|
||||
onChange={(event) =>
|
||||
setSections((current) =>
|
||||
current.map((item, currentIndex) =>
|
||||
currentIndex === index
|
||||
? {
|
||||
...item,
|
||||
[`body${locale.suffix}`]: event.target.value,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
))}
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
|
||||
<AppCard>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Assets</CardTitle>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setAssets((current) => [...current, createEmptyAsset(current.length)])}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Asset
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{assets.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No assets added yet.</p>
|
||||
) : null}
|
||||
|
||||
{assets.map((asset, index) => (
|
||||
<AppCard key={asset.id ?? `${asset.kind}-${index}`} level={2}>
|
||||
<CardContent className="space-y-4 p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-sm font-medium text-foreground">Asset #{index + 1}</p>
|
||||
<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={() =>
|
||||
setAssets((current) => current.filter((_, currentIndex) => currentIndex !== index))
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Kind</Label>
|
||||
<select
|
||||
value={asset.kind}
|
||||
onChange={(event) =>
|
||||
setAssets((current) =>
|
||||
current.map((item, currentIndex) =>
|
||||
currentIndex === index
|
||||
? {
|
||||
...item,
|
||||
kind: event.target.value as PortfolioAssetKind,
|
||||
media: {
|
||||
...item.media,
|
||||
kind: event.target.value as PortfolioAssetKind,
|
||||
},
|
||||
}
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
|
||||
>
|
||||
{assetKindOptions.map((kind) => (
|
||||
<option key={kind} value={kind}>
|
||||
{kind}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<MediaFieldPicker
|
||||
title="Asset File"
|
||||
value={asset.media}
|
||||
onChange={(media) =>
|
||||
setAssets((current) =>
|
||||
current.map((item, currentIndex) =>
|
||||
currentIndex === index
|
||||
? {
|
||||
...item,
|
||||
media,
|
||||
filePath: media.url,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}
|
||||
options={mediaOptions}
|
||||
inputName={`asset-media-${index}`}
|
||||
fileFieldName={asset.fileFieldName}
|
||||
accept="image/*,.svg,.pdf"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{localeFieldConfig.map((locale) => (
|
||||
<div key={`${locale.key}-asset-${index}`} className="space-y-2">
|
||||
<Label>{`Alt ${locale.label}`}</Label>
|
||||
<Input
|
||||
value={asset[`alt${locale.suffix}` as keyof AssetFormValue] as string}
|
||||
onChange={(event) =>
|
||||
setAssets((current) =>
|
||||
current.map((item, currentIndex) =>
|
||||
currentIndex === index
|
||||
? {
|
||||
...item,
|
||||
[`alt${locale.suffix}`]: event.target.value,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
))}
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit">{submitLabel}</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user