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:
moh
2026-09-20 17:16:52 +02:00
parent 40387c19d4
commit 2f8fd66812
+289 -266
View File
@@ -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 ? (