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,
|
||||
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 ? (
|
||||
|
||||
Reference in New Issue
Block a user