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
+171 -148
View File
@@ -9,7 +9,6 @@ import {
CalendarDays,
CheckCircle2,
CircleAlert,
Files,
FolderTree,
Grid2x2,
ImagePlus,
@@ -25,7 +24,12 @@ import { type ReactNode, useEffect, useRef, useState } from "react";
import { useFormStatus } from "react-dom";
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 { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -293,34 +297,32 @@ function LocaleInputs({
multiline?: boolean;
}) {
return (
<div className="space-y-3">
<div className="grid gap-3 xl:grid-cols-3">
<div className="space-y-2">
<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) => (
<AppCard
key={`${title}-${locale.suffix}`}
level={2}
layer="single"
padding="sm"
className="space-y-2 rounded-nested"
>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">{locale.label}</p>
<div key={`${title}-${locale.suffix}`} className="space-y-1.5">
<span className="block text-[11px] font-medium uppercase tracking-wide text-muted-foreground/70">
{locale.label}
</span>
{multiline ? (
<Textarea
name={namePrefix ? `${namePrefix}${locale.suffix}` : undefined}
rows={5}
placeholder={`${title} ${locale.label}`}
rows={3}
placeholder={locale.label}
value={values[locale.suffix]}
onChange={(event) => onChange(locale.suffix, event.target.value)}
className="resize-y"
/>
) : (
<Input
name={namePrefix ? `${namePrefix}${locale.suffix}` : undefined}
placeholder={`${title} ${locale.label}`}
placeholder={locale.label}
value={values[locale.suffix]}
onChange={(event) => onChange(locale.suffix, event.target.value)}
/>
)}
</AppCard>
</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 }) {
const { pending } = useFormStatus();
@@ -577,8 +604,8 @@ export function PortfolioProjectForm({
<input ref={assetsInputRef} type="hidden" name="assets" value={assetsPayload} />
<input ref={intentRef} type="hidden" name="intent" defaultValue="save" />
<AppCard level={3} padding="lg" contentClassName="space-y-6">
<div className="flex flex-col gap-6">
<AppCard level={3} padding="md" contentClassName="space-y-5">
<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="space-y-3">
<div className="flex flex-wrap items-center gap-2">
@@ -596,14 +623,14 @@ export function PortfolioProjectForm({
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<StatsCard title="Sections" value={String(sections.length)} icon={Files} />
<StatsCard title="Assets" value={String(assets.length)} icon={ImagePlus} />
<StatsCard
title="Progress"
value={`${progress.filter((step) => step.complete).length}/${progress.length}`}
icon={CheckCircle2}
/>
<div className="flex items-center gap-3 rounded-nested border border-border/70 bg-surface-2 px-4 py-2.5">
<CheckCircle2 className="h-5 w-5 shrink-0 text-brand-primary" />
<div className="min-w-0">
<p className="text-[11px] uppercase tracking-[0.14em] text-muted-foreground">Fortschritt</p>
<p className="text-sm font-semibold text-foreground">
{progress.filter((step) => step.complete).length}/{progress.length} bereit
</p>
</div>
</div>
</div>
@@ -625,8 +652,8 @@ export function PortfolioProjectForm({
<div className="space-y-6">
<div>
<AppCard level={3} padding="lg">
<section className="space-y-6">
<AppCard level={3} padding="md">
<section className="space-y-5">
<SectionHeader
title="Basic Information"
description="Choose the category and the stable project metadata first."
@@ -787,8 +814,8 @@ export function PortfolioProjectForm({
</div>
<div>
<AppCard level={3} padding="lg">
<section className="space-y-6">
<AppCard level={3} padding="md">
<section className="space-y-5">
<SectionHeader
title="Localized Content"
description="Finish the user-facing copy before building sections and media."
@@ -838,11 +865,11 @@ export function PortfolioProjectForm({
</div>
<div>
<AppCard level={3} padding="lg">
<section className="space-y-6">
<AppCard level={3} padding="md">
<section className="space-y-5">
<SectionHeader
title="Sections"
description="Each section keeps one clear role in the project story."
description="Optional — baue die Projektgeschichte aus einzelnen Abschnitten."
action={(
<Button
type="button"
@@ -855,46 +882,57 @@ export function PortfolioProjectForm({
)}
/>
<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={isPortfolioSectionReady({
type: section.type,
titleAr: section.titleAr,
titleEn: section.titleEn,
titleDe: section.titleDe,
bodyAr: section.bodyAr,
bodyEn: section.bodyEn,
bodyDe: section.bodyDe,
linkUrl: section.linkUrl,
mediaAssetId: section.media.assetId,
}) ? "success" : "outline"}>
{isPortfolioSectionReady({
type: section.type,
titleAr: section.titleAr,
titleEn: section.titleEn,
titleDe: section.titleDe,
bodyAr: section.bodyAr,
bodyEn: section.bodyEn,
bodyDe: section.bodyDe,
linkUrl: section.linkUrl,
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">
{sections.length === 0 ? (
<EmptyPanel
icon={Layers3}
title="Noch keine Abschnitte"
description="Abschnitte sind optional. Rich-Text, Galerie, Stats, Deliverables oder Links frei kombinierbar."
action={(
<Button
type="button"
variant="outline"
onClick={() => setSections((current) => [...current, createEmptySection(current.length)])}
>
<Plus className="h-4 w-4" />
Add Section
</Button>
)}
/>
) : (
<Accordion type="multiple" className="space-y-3">
{sections.map((section, index) => {
const ready = isPortfolioSectionReady({
type: section.type,
titleAr: section.titleAr,
titleEn: section.titleEn,
titleDe: section.titleDe,
bodyAr: section.bodyAr,
bodyEn: section.bodyEn,
bodyDe: section.bodyDe,
linkUrl: section.linkUrl,
mediaAssetId: section.media.assetId,
});
return (
<AccordionItem
key={section.id ?? `section-${index}`}
value={section.id ?? `section-${index}`}
className="border-border/70 bg-surface-2"
>
<AccordionTrigger className="hover:no-underline">
<span className="flex flex-wrap items-center gap-2.5">
<Badge variant={ready ? "success" : "outline"}>{ready ? "Ready" : "Open"}</Badge>
<span className="text-sm font-medium text-foreground">{`Section ${index + 1}`}</span>
<span className="text-xs text-muted-foreground">{sectionTypeLabels[section.type]}</span>
</span>
</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setSections((current) => moveArrayItem(current, index, index - 1))}
disabled={index === 0}
>
@@ -903,6 +941,7 @@ export function PortfolioProjectForm({
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setSections((current) => moveArrayItem(current, index, index + 1))}
disabled={index === sections.length - 1}
>
@@ -911,6 +950,7 @@ export function PortfolioProjectForm({
<Button
type="button"
variant="ghost"
size="icon"
className="text-destructive"
onClick={() =>
setSections((current) =>
@@ -921,7 +961,6 @@ export function PortfolioProjectForm({
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
@@ -1021,21 +1060,22 @@ export function PortfolioProjectForm({
/>
) : null}
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
)}
</section>
</AppCard>
</div>
<div>
<AppCard level={3} padding="lg">
<div className="space-y-8">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
<div className="space-y-6">
<section className="space-y-6">
<AppCard level={3} padding="md">
<section className="space-y-5">
<SectionHeader
title="Cover Media"
description="Choose the primary cover that represents the project in listings."
description="Wähle das Titelbild, das das Projekt in Listen repräsentiert."
/>
<MediaFieldPicker
@@ -1050,14 +1090,12 @@ export function PortfolioProjectForm({
clearLabel="Remove Cover"
emptyValue={{ mode: "upload", assetId: "", url: "", label: "" }}
/>
</section>
<Separator />
<section className="space-y-6">
<SectionHeader
title="Assets"
description="Assets stay ordered and ready for localized alt text."
description="Optional — Galeriebilder mit lokalisiertem Alt-Text."
action={(
<Button
type="button"
@@ -1070,36 +1108,51 @@ export function PortfolioProjectForm({
)}
/>
<div className="grid gap-4 md:grid-cols-2">
{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={isPortfolioAssetReady({
mediaAssetId: asset.media.assetId,
altAr: asset.altAr,
altEn: asset.altEn,
altDe: asset.altDe,
}) ? "success" : "outline"}>
{isPortfolioAssetReady({
mediaAssetId: asset.media.assetId,
altAr: asset.altAr,
altEn: asset.altEn,
altDe: asset.altDe,
})
? "Ready"
: "Open"}
</Badge>
<p className="text-sm font-medium text-foreground">{`Asset ${index + 1}`}</p>
</div>
<div className="flex gap-2">
{assets.length === 0 ? (
<EmptyPanel
icon={ImagePlus}
title="Noch keine Assets"
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)])}
>
<Plus className="h-4 w-4" />
Add Asset
</Button>
)}
/>
) : (
<Accordion type="multiple" className="space-y-3">
{assets.map((asset, index) => {
const ready = isPortfolioAssetReady({
mediaAssetId: asset.media.assetId,
altAr: asset.altAr,
altEn: asset.altEn,
altDe: asset.altDe,
});
return (
<AccordionItem
key={asset.id ?? `asset-${index}`}
value={asset.id ?? `asset-${index}`}
className="border-border/70 bg-surface-2"
>
<AccordionTrigger className="hover:no-underline">
<span className="flex flex-wrap items-center gap-2.5">
<Badge variant={ready ? "success" : "outline"}>{ready ? "Ready" : "Open"}</Badge>
<span className="text-sm font-medium text-foreground">{`Asset ${index + 1}`}</span>
</span>
</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setAssets((current) => moveArrayItem(current, index, index - 1))}
disabled={index === 0}
>
@@ -1108,6 +1161,7 @@ export function PortfolioProjectForm({
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setAssets((current) => moveArrayItem(current, index, index + 1))}
disabled={index === assets.length - 1}
>
@@ -1116,6 +1170,7 @@ export function PortfolioProjectForm({
<Button
type="button"
variant="ghost"
size="icon"
className="text-destructive"
onClick={() =>
setAssets((current) =>
@@ -1126,7 +1181,6 @@ export function PortfolioProjectForm({
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<MediaFieldPicker
title="Asset Image"
@@ -1161,49 +1215,18 @@ export function PortfolioProjectForm({
}
/>
</div>
))}
</div>
</section>
</div>
<div className="space-y-4 lg:sticky lg:top-4 lg:self-start">
<AppCard level={2} layer="single" padding="sm" className="space-y-4">
<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,
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
)}
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>
</section>
</AppCard>
</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-wrap items-center gap-3">
{firstIncompleteStep ? (