STYLED - Simplify and compact the add-project form
Reorganize the project form for a lighter, more professional flow without touching field names or the save action (all data preserved): - LocaleInputs: drop the per-locale card wrappers for a tight 3-column labelled grid; shorter textareas. - Sections and Assets: collapse each item into an accordion (closed by default) and show a proper empty state when there are none. - Slim the header to badges + title + a compact progress indicator instead of a stats grid; drop the assets stats sidebar. - Tighten section paddings/spacing (lg -> md) throughout. All 375 tests pass; tsc and eslint clean.
This commit is contained in:
@@ -9,7 +9,6 @@ import {
|
|||||||
CalendarDays,
|
CalendarDays,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
CircleAlert,
|
CircleAlert,
|
||||||
Files,
|
|
||||||
FolderTree,
|
FolderTree,
|
||||||
Grid2x2,
|
Grid2x2,
|
||||||
ImagePlus,
|
ImagePlus,
|
||||||
@@ -25,7 +24,12 @@ import { type ReactNode, useEffect, useRef, useState } from "react";
|
|||||||
import { useFormStatus } from "react-dom";
|
import { useFormStatus } from "react-dom";
|
||||||
|
|
||||||
import { MediaFieldPicker, type MediaFieldState } from "@/components/admin/media-field-picker";
|
import { MediaFieldPicker, type MediaFieldState } from "@/components/admin/media-field-picker";
|
||||||
import { StatsCard } from "@/components/dashboard/stats-card";
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
import { AppCard } from "@/components/ui/app-card";
|
import { AppCard } from "@/components/ui/app-card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -293,34 +297,32 @@ function LocaleInputs({
|
|||||||
multiline?: boolean;
|
multiline?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
<div className="grid gap-3 xl:grid-cols-3">
|
<p className="text-xs font-medium uppercase tracking-[0.14em] text-muted-foreground">{title}</p>
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
{locales.map((locale) => (
|
{locales.map((locale) => (
|
||||||
<AppCard
|
<div key={`${title}-${locale.suffix}`} className="space-y-1.5">
|
||||||
key={`${title}-${locale.suffix}`}
|
<span className="block text-[11px] font-medium uppercase tracking-wide text-muted-foreground/70">
|
||||||
level={2}
|
{locale.label}
|
||||||
layer="single"
|
</span>
|
||||||
padding="sm"
|
|
||||||
className="space-y-2 rounded-nested"
|
|
||||||
>
|
|
||||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">{locale.label}</p>
|
|
||||||
{multiline ? (
|
{multiline ? (
|
||||||
<Textarea
|
<Textarea
|
||||||
name={namePrefix ? `${namePrefix}${locale.suffix}` : undefined}
|
name={namePrefix ? `${namePrefix}${locale.suffix}` : undefined}
|
||||||
rows={5}
|
rows={3}
|
||||||
placeholder={`${title} ${locale.label}`}
|
placeholder={locale.label}
|
||||||
value={values[locale.suffix]}
|
value={values[locale.suffix]}
|
||||||
onChange={(event) => onChange(locale.suffix, event.target.value)}
|
onChange={(event) => onChange(locale.suffix, event.target.value)}
|
||||||
|
className="resize-y"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
name={namePrefix ? `${namePrefix}${locale.suffix}` : undefined}
|
name={namePrefix ? `${namePrefix}${locale.suffix}` : undefined}
|
||||||
placeholder={`${title} ${locale.label}`}
|
placeholder={locale.label}
|
||||||
value={values[locale.suffix]}
|
value={values[locale.suffix]}
|
||||||
onChange={(event) => onChange(locale.suffix, event.target.value)}
|
onChange={(event) => onChange(locale.suffix, event.target.value)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</AppCard>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -347,6 +349,31 @@ function SectionHeader({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EmptyPanel({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
icon: typeof Grid2x2;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
action?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-surface border border-dashed border-border/70 bg-surface-2/40 px-6 py-10 text-center">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-pill border border-border/70 bg-surface-2 text-muted-foreground">
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-semibold text-foreground">{title}</p>
|
||||||
|
<p className="max-w-sm text-sm text-muted-foreground">{description}</p>
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SubmitButton({ onSelect }: { onSelect: () => void }) {
|
function SubmitButton({ onSelect }: { onSelect: () => void }) {
|
||||||
const { pending } = useFormStatus();
|
const { pending } = useFormStatus();
|
||||||
|
|
||||||
@@ -577,8 +604,8 @@ export function PortfolioProjectForm({
|
|||||||
<input ref={assetsInputRef} type="hidden" name="assets" value={assetsPayload} />
|
<input ref={assetsInputRef} type="hidden" name="assets" value={assetsPayload} />
|
||||||
<input ref={intentRef} type="hidden" name="intent" defaultValue="save" />
|
<input ref={intentRef} type="hidden" name="intent" defaultValue="save" />
|
||||||
|
|
||||||
<AppCard level={3} padding="lg" contentClassName="space-y-6">
|
<AppCard level={3} padding="md" contentClassName="space-y-5">
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-5">
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
@@ -596,14 +623,14 @@ export function PortfolioProjectForm({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
<div className="flex items-center gap-3 rounded-nested border border-border/70 bg-surface-2 px-4 py-2.5">
|
||||||
<StatsCard title="Sections" value={String(sections.length)} icon={Files} />
|
<CheckCircle2 className="h-5 w-5 shrink-0 text-brand-primary" />
|
||||||
<StatsCard title="Assets" value={String(assets.length)} icon={ImagePlus} />
|
<div className="min-w-0">
|
||||||
<StatsCard
|
<p className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground">Fortschritt</p>
|
||||||
title="Progress"
|
<p className="text-sm font-semibold text-foreground">
|
||||||
value={`${progress.filter((step) => step.complete).length}/${progress.length}`}
|
{progress.filter((step) => step.complete).length}/{progress.length} bereit
|
||||||
icon={CheckCircle2}
|
</p>
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -625,8 +652,8 @@ export function PortfolioProjectForm({
|
|||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<AppCard level={3} padding="lg">
|
<AppCard level={3} padding="md">
|
||||||
<section className="space-y-6">
|
<section className="space-y-5">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Basic Information"
|
title="Basic Information"
|
||||||
description="Choose the category and the stable project metadata first."
|
description="Choose the category and the stable project metadata first."
|
||||||
@@ -787,8 +814,8 @@ export function PortfolioProjectForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<AppCard level={3} padding="lg">
|
<AppCard level={3} padding="md">
|
||||||
<section className="space-y-6">
|
<section className="space-y-5">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Localized Content"
|
title="Localized Content"
|
||||||
description="Finish the user-facing copy before building sections and media."
|
description="Finish the user-facing copy before building sections and media."
|
||||||
@@ -838,11 +865,11 @@ export function PortfolioProjectForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<AppCard level={3} padding="lg">
|
<AppCard level={3} padding="md">
|
||||||
<section className="space-y-6">
|
<section className="space-y-5">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Sections"
|
title="Sections"
|
||||||
description="Each section keeps one clear role in the project story."
|
description="Optional — baue die Projektgeschichte aus einzelnen Abschnitten."
|
||||||
action={(
|
action={(
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -855,73 +882,85 @@ export function PortfolioProjectForm({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-4">
|
{sections.length === 0 ? (
|
||||||
{sections.map((section, index) => (
|
<EmptyPanel
|
||||||
<div
|
icon={Layers3}
|
||||||
key={section.id ?? `section-${index}`}
|
title="Noch keine Abschnitte"
|
||||||
className="space-y-4 rounded-surface border border-border/70 bg-background p-4"
|
description="Abschnitte sind optional. Rich-Text, Galerie, Stats, Deliverables oder Links – frei kombinierbar."
|
||||||
>
|
action={(
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<Button
|
||||||
<div className="flex items-center gap-3">
|
type="button"
|
||||||
<Badge variant={isPortfolioSectionReady({
|
variant="outline"
|
||||||
type: section.type,
|
onClick={() => setSections((current) => [...current, createEmptySection(current.length)])}
|
||||||
titleAr: section.titleAr,
|
>
|
||||||
titleEn: section.titleEn,
|
<Plus className="h-4 w-4" />
|
||||||
titleDe: section.titleDe,
|
Add Section
|
||||||
bodyAr: section.bodyAr,
|
</Button>
|
||||||
bodyEn: section.bodyEn,
|
)}
|
||||||
bodyDe: section.bodyDe,
|
/>
|
||||||
linkUrl: section.linkUrl,
|
) : (
|
||||||
mediaAssetId: section.media.assetId,
|
<Accordion type="multiple" className="space-y-3">
|
||||||
}) ? "success" : "outline"}>
|
{sections.map((section, index) => {
|
||||||
{isPortfolioSectionReady({
|
const ready = isPortfolioSectionReady({
|
||||||
type: section.type,
|
type: section.type,
|
||||||
titleAr: section.titleAr,
|
titleAr: section.titleAr,
|
||||||
titleEn: section.titleEn,
|
titleEn: section.titleEn,
|
||||||
titleDe: section.titleDe,
|
titleDe: section.titleDe,
|
||||||
bodyAr: section.bodyAr,
|
bodyAr: section.bodyAr,
|
||||||
bodyEn: section.bodyEn,
|
bodyEn: section.bodyEn,
|
||||||
bodyDe: section.bodyDe,
|
bodyDe: section.bodyDe,
|
||||||
linkUrl: section.linkUrl,
|
linkUrl: section.linkUrl,
|
||||||
mediaAssetId: section.media.assetId,
|
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">
|
return (
|
||||||
<Button
|
<AccordionItem
|
||||||
type="button"
|
key={section.id ?? `section-${index}`}
|
||||||
variant="outline"
|
value={section.id ?? `section-${index}`}
|
||||||
onClick={() => setSections((current) => moveArrayItem(current, index, index - 1))}
|
className="border-border/70 bg-surface-2"
|
||||||
disabled={index === 0}
|
>
|
||||||
>
|
<AccordionTrigger className="hover:no-underline">
|
||||||
<ArrowUp className="h-4 w-4" />
|
<span className="flex flex-wrap items-center gap-2.5">
|
||||||
</Button>
|
<Badge variant={ready ? "success" : "outline"}>{ready ? "Ready" : "Open"}</Badge>
|
||||||
<Button
|
<span className="text-sm font-medium text-foreground">{`Section ${index + 1}`}</span>
|
||||||
type="button"
|
<span className="text-xs text-muted-foreground">{sectionTypeLabels[section.type]}</span>
|
||||||
variant="outline"
|
</span>
|
||||||
onClick={() => setSections((current) => moveArrayItem(current, index, index + 1))}
|
</AccordionTrigger>
|
||||||
disabled={index === sections.length - 1}
|
<AccordionContent>
|
||||||
>
|
<div className="space-y-4">
|
||||||
<ArrowDown className="h-4 w-4" />
|
<div className="flex justify-end gap-2">
|
||||||
</Button>
|
<Button
|
||||||
<Button
|
type="button"
|
||||||
type="button"
|
variant="outline"
|
||||||
variant="ghost"
|
size="icon"
|
||||||
className="text-destructive"
|
onClick={() => setSections((current) => moveArrayItem(current, index, index - 1))}
|
||||||
onClick={() =>
|
disabled={index === 0}
|
||||||
setSections((current) =>
|
>
|
||||||
current.filter((_, currentIndex) => currentIndex !== index),
|
<ArrowUp className="h-4 w-4" />
|
||||||
)
|
</Button>
|
||||||
}
|
<Button
|
||||||
>
|
type="button"
|
||||||
<Trash2 className="h-4 w-4" />
|
variant="outline"
|
||||||
</Button>
|
size="icon"
|
||||||
</div>
|
onClick={() => setSections((current) => moveArrayItem(current, index, index + 1))}
|
||||||
</div>
|
disabled={index === sections.length - 1}
|
||||||
|
>
|
||||||
|
<ArrowDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() =>
|
||||||
|
setSections((current) =>
|
||||||
|
current.filter((_, currentIndex) => currentIndex !== index),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -1020,190 +1059,174 @@ export function PortfolioProjectForm({
|
|||||||
multiline
|
multiline
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</AccordionContent>
|
||||||
</div>
|
</AccordionItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Accordion>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<AppCard level={3} padding="lg">
|
<AppCard level={3} padding="md">
|
||||||
<div className="space-y-8">
|
<section className="space-y-5">
|
||||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
|
<SectionHeader
|
||||||
<div className="space-y-6">
|
title="Cover Media"
|
||||||
<section className="space-y-6">
|
description="Wähle das Titelbild, das das Projekt in Listen repräsentiert."
|
||||||
<SectionHeader
|
/>
|
||||||
title="Cover Media"
|
|
||||||
description="Choose the primary cover that represents the project in listings."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MediaFieldPicker
|
<MediaFieldPicker
|
||||||
title="Cover"
|
title="Cover"
|
||||||
value={coverMedia}
|
value={coverMedia}
|
||||||
onChange={setCoverMedia}
|
onChange={setCoverMedia}
|
||||||
options={mediaOptions}
|
options={mediaOptions}
|
||||||
hasInitialValue={Boolean(project?.coverImagePath || project?.coverMediaAssetId)}
|
hasInitialValue={Boolean(project?.coverImagePath || project?.coverMediaAssetId)}
|
||||||
inputName="coverMedia"
|
inputName="coverMedia"
|
||||||
fileFieldName="coverFile"
|
fileFieldName="coverFile"
|
||||||
allowClear
|
allowClear
|
||||||
clearLabel="Remove Cover"
|
clearLabel="Remove Cover"
|
||||||
emptyValue={{ mode: "upload", assetId: "", url: "", label: "" }}
|
emptyValue={{ mode: "upload", assetId: "", url: "", label: "" }}
|
||||||
/>
|
/>
|
||||||
</section>
|
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<section className="space-y-6">
|
<SectionHeader
|
||||||
<SectionHeader
|
title="Assets"
|
||||||
title="Assets"
|
description="Optional — Galeriebilder mit lokalisiertem Alt-Text."
|
||||||
description="Assets stay ordered and ready for localized alt text."
|
action={(
|
||||||
action={(
|
<Button
|
||||||
<Button
|
type="button"
|
||||||
type="button"
|
variant="outline"
|
||||||
variant="outline"
|
onClick={() => setAssets((current) => [...current, createEmptyAsset(current.length)])}
|
||||||
onClick={() => setAssets((current) => [...current, createEmptyAsset(current.length)])}
|
>
|
||||||
>
|
<Plus className="h-4 w-4" />
|
||||||
<Plus className="h-4 w-4" />
|
Add Asset
|
||||||
Add Asset
|
</Button>
|
||||||
</Button>
|
)}
|
||||||
)}
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
{assets.length === 0 ? (
|
||||||
{assets.map((asset, index) => (
|
<EmptyPanel
|
||||||
<div
|
icon={ImagePlus}
|
||||||
key={asset.id ?? `asset-${index}`}
|
title="Noch keine Assets"
|
||||||
className="space-y-4 rounded-surface border border-border/70 bg-background p-4"
|
description="Assets sind optional. Füge Galeriebilder hinzu – jedes braucht ein Medium und lokalisierten Alt-Text."
|
||||||
|
action={(
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setAssets((current) => [...current, createEmptyAsset(current.length)])}
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<Plus className="h-4 w-4" />
|
||||||
<div className="flex items-center gap-3">
|
Add Asset
|
||||||
<Badge variant={isPortfolioAssetReady({
|
</Button>
|
||||||
mediaAssetId: asset.media.assetId,
|
)}
|
||||||
altAr: asset.altAr,
|
/>
|
||||||
altEn: asset.altEn,
|
) : (
|
||||||
altDe: asset.altDe,
|
<Accordion type="multiple" className="space-y-3">
|
||||||
}) ? "success" : "outline"}>
|
{assets.map((asset, index) => {
|
||||||
{isPortfolioAssetReady({
|
const ready = isPortfolioAssetReady({
|
||||||
mediaAssetId: asset.media.assetId,
|
mediaAssetId: asset.media.assetId,
|
||||||
altAr: asset.altAr,
|
altAr: asset.altAr,
|
||||||
altEn: asset.altEn,
|
altEn: asset.altEn,
|
||||||
altDe: asset.altDe,
|
altDe: asset.altDe,
|
||||||
})
|
});
|
||||||
? "Ready"
|
|
||||||
: "Open"}
|
|
||||||
</Badge>
|
|
||||||
<p className="text-sm font-medium text-foreground">{`Asset ${index + 1}`}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
return (
|
||||||
<Button
|
<AccordionItem
|
||||||
type="button"
|
key={asset.id ?? `asset-${index}`}
|
||||||
variant="outline"
|
value={asset.id ?? `asset-${index}`}
|
||||||
onClick={() => setAssets((current) => moveArrayItem(current, index, index - 1))}
|
className="border-border/70 bg-surface-2"
|
||||||
disabled={index === 0}
|
>
|
||||||
>
|
<AccordionTrigger className="hover:no-underline">
|
||||||
<ArrowUp className="h-4 w-4" />
|
<span className="flex flex-wrap items-center gap-2.5">
|
||||||
</Button>
|
<Badge variant={ready ? "success" : "outline"}>{ready ? "Ready" : "Open"}</Badge>
|
||||||
<Button
|
<span className="text-sm font-medium text-foreground">{`Asset ${index + 1}`}</span>
|
||||||
type="button"
|
</span>
|
||||||
variant="outline"
|
</AccordionTrigger>
|
||||||
onClick={() => setAssets((current) => moveArrayItem(current, index, index + 1))}
|
<AccordionContent>
|
||||||
disabled={index === assets.length - 1}
|
<div className="space-y-4">
|
||||||
>
|
<div className="flex justify-end gap-2">
|
||||||
<ArrowDown className="h-4 w-4" />
|
<Button
|
||||||
</Button>
|
type="button"
|
||||||
<Button
|
variant="outline"
|
||||||
type="button"
|
size="icon"
|
||||||
variant="ghost"
|
onClick={() => setAssets((current) => moveArrayItem(current, index, index - 1))}
|
||||||
className="text-destructive"
|
disabled={index === 0}
|
||||||
onClick={() =>
|
>
|
||||||
setAssets((current) =>
|
<ArrowUp className="h-4 w-4" />
|
||||||
current.filter((_, currentIndex) => currentIndex !== index),
|
</Button>
|
||||||
)
|
<Button
|
||||||
}
|
type="button"
|
||||||
>
|
variant="outline"
|
||||||
<Trash2 className="h-4 w-4" />
|
size="icon"
|
||||||
</Button>
|
onClick={() => setAssets((current) => moveArrayItem(current, index, index + 1))}
|
||||||
</div>
|
disabled={index === assets.length - 1}
|
||||||
</div>
|
>
|
||||||
|
<ArrowDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() =>
|
||||||
|
setAssets((current) =>
|
||||||
|
current.filter((_, currentIndex) => currentIndex !== index),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<MediaFieldPicker
|
<MediaFieldPicker
|
||||||
title="Asset Image"
|
title="Asset Image"
|
||||||
value={asset.media}
|
value={asset.media}
|
||||||
onChange={(media) =>
|
onChange={(media) =>
|
||||||
setAssets((current) =>
|
setAssets((current) =>
|
||||||
current.map((item, currentIndex) =>
|
current.map((item, currentIndex) =>
|
||||||
currentIndex === index
|
currentIndex === index
|
||||||
? { ...item, media, filePath: media.url }
|
? { ...item, media, filePath: media.url }
|
||||||
: item,
|
: item,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
options={mediaOptions}
|
options={mediaOptions}
|
||||||
hasInitialValue={Boolean(asset.media.assetId || asset.filePath)}
|
hasInitialValue={Boolean(asset.media.assetId || asset.filePath)}
|
||||||
inputName={`asset-media-${index}`}
|
inputName={`asset-media-${index}`}
|
||||||
fileFieldName={asset.fileFieldName}
|
fileFieldName={asset.fileFieldName}
|
||||||
allowClear
|
allowClear
|
||||||
clearLabel="Remove Asset"
|
clearLabel="Remove Asset"
|
||||||
emptyValue={{ mode: "upload", assetId: "", url: "", label: "" }}
|
emptyValue={{ mode: "upload", assetId: "", url: "", label: "" }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<LocaleInputs
|
<LocaleInputs
|
||||||
title="Alt Text"
|
title="Alt Text"
|
||||||
values={{ Ar: asset.altAr, En: asset.altEn, De: asset.altDe }}
|
values={{ Ar: asset.altAr, En: asset.altEn, De: asset.altDe }}
|
||||||
onChange={(key, value) =>
|
onChange={(key, value) =>
|
||||||
setAssets((current) =>
|
setAssets((current) =>
|
||||||
current.map((item, currentIndex) =>
|
current.map((item, currentIndex) =>
|
||||||
currentIndex === index ? { ...item, [`alt${key}`]: value } : item,
|
currentIndex === index ? { ...item, [`alt${key}`]: value } : item,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</AccordionContent>
|
||||||
</div>
|
</AccordionItem>
|
||||||
</section>
|
);
|
||||||
</div>
|
})}
|
||||||
|
</Accordion>
|
||||||
<div className="space-y-4 lg:sticky lg:top-4 lg:self-start">
|
)}
|
||||||
<AppCard level={2} layer="single" padding="sm" className="space-y-4">
|
</section>
|
||||||
<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>
|
</AppCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AppCard level={3} padding="lg">
|
<AppCard level={3} padding="md">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-end">
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-end">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{firstIncompleteStep ? (
|
{firstIncompleteStep ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user