Files
sass-mohfarawati/components/root/portfolio-project-form.tsx
T
2026-03-07 17:36:45 +01:00

661 lines
24 KiB
TypeScript

"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>Projekt Basisdaten</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="categoryId">Kategorie</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">Kundenname</Label>
<Input
id="clientName"
name="clientName"
defaultValue={project?.clientName ?? ""}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="projectYear">Projektjahr</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">Sortierung</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 Bild"
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} />
Hervorgehoben
</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} />
Veroeffentlicht
</label>
</CardContent>
</AppCard>
<AppCard>
<CardHeader>
<CardTitle>Lokalisierte Inhalte</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"
? "Arabische Inhalte fuer die arabische Website."
: locale.key === "en"
? "Englische Inhalte fuer die englische Website."
: "Deutsche Inhalte fuer die deutsche Website."}
</div>
<div className="space-y-2">
<Label htmlFor={`title${locale.suffix}`}>{`Titel ${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}`}>{`Leistungslabel ${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}`}>{`Kurzbeschreibung ${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>Abschnitte</CardTitle>
<Button
type="button"
variant="outline"
onClick={() => setSections((current) => [...current, createEmptySection(current.length)])}
>
<Plus className="h-4 w-4" />
Abschnitt hinzufuegen
</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">{`Abschnitt #${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" />
Entfernen
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Typ</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="Abschnitt Bild"
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>{`Titel ${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>{`Text ${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>Dateien</CardTitle>
<Button
type="button"
variant="outline"
onClick={() => setAssets((current) => [...current, createEmptyAsset(current.length)])}
>
<Plus className="h-4 w-4" />
Datei hinzufuegen
</Button>
</CardHeader>
<CardContent className="space-y-4">
{assets.length === 0 ? (
<p className="text-sm text-muted-foreground">Noch keine Dateien hinzugefuegt.</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">{`Datei #${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" />
Entfernen
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Typ</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="Datei"
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>
);
}