STYLED - Turn the project form into a step-by-step wizard journey
CI / quality (push) Canceled after 0s

Replace the flat two-column layout with a guided 4-step wizard
(Grundlagen -> Inhalte -> Abschnitte -> Medien) using the existing wizard
progress model:

- A top stepper shows each step's number/label and completion (check), and
  jumps to any step on click.
- One step is visible at a time in a single comfortable centered column; the
  others stay mounted but hidden, so every named field still submits.
- Footer navigation: Zurueck / Save as draft / Weiter, with Save Project on
  the final step.

No field-name or save-logic changes. All 375 tests pass; tsc and eslint clean.
This commit is contained in:
moh
2026-09-20 18:17:40 +02:00
parent d4b7f883e1
commit 369c0e24c6
+103 -14
View File
@@ -3,10 +3,13 @@
import type { PortfolioProjectViewMode, PortfolioSectionType } from "@/lib/db/enums"; import type { PortfolioProjectViewMode, PortfolioSectionType } from "@/lib/db/enums";
import { import {
ArrowDown, ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp, ArrowUp,
BookOpenText, BookOpenText,
BriefcaseBusiness, BriefcaseBusiness,
CalendarDays, CalendarDays,
Check,
CircleAlert, CircleAlert,
FolderTree, FolderTree,
Grid2x2, Grid2x2,
@@ -19,7 +22,7 @@ import {
UserRound, UserRound,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { type ReactNode, useEffect, useRef, useState } from "react"; import { Fragment, 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";
@@ -50,6 +53,8 @@ import {
getPortfolioWizardProgress, getPortfolioWizardProgress,
isPortfolioAssetReady, isPortfolioAssetReady,
isPortfolioSectionReady, isPortfolioSectionReady,
type PortfolioWizardStep,
type PortfolioWizardStepState,
} from "@/lib/portfolio-form-progress"; } from "@/lib/portfolio-form-progress";
import type { MediaOption } from "@/lib/media"; import type { MediaOption } from "@/lib/media";
import type { PortfolioCategoryView, PortfolioProjectView } from "@/lib/portfolio"; import type { PortfolioCategoryView, PortfolioProjectView } from "@/lib/portfolio";
@@ -145,6 +150,65 @@ const viewModeOptions: Array<{
{ value: "CASE_STUDY", label: "Case Study", description: "Structured challenge/solution/outcome view. Sections 13 become the lead panels.", icon: BriefcaseBusiness }, { value: "CASE_STUDY", label: "Case Study", description: "Structured challenge/solution/outcome view. Sections 13 become the lead panels.", icon: BriefcaseBusiness },
]; ];
const WIZARD_STEPS: Array<{ key: PortfolioWizardStep; label: string; description: string }> = [
{ key: "basics", label: "Grundlagen", description: "Kategorie & Metadaten" },
{ key: "content", label: "Inhalte", description: "Lokalisierte Texte" },
{ key: "sections", label: "Abschnitte", description: "Projektgeschichte" },
{ key: "assets", label: "Medien", description: "Cover & Galerie" },
];
function WizardStepper({
activeKey,
progress,
onSelect,
}: {
activeKey: PortfolioWizardStep;
progress: PortfolioWizardStepState[];
onSelect: (key: PortfolioWizardStep) => void;
}) {
return (
<div className="flex items-center gap-1 overflow-x-auto pb-1">
{WIZARD_STEPS.map((step, index) => {
const complete = progress.find((item) => item.key === step.key)?.complete ?? false;
const active = step.key === activeKey;
return (
<Fragment key={step.key}>
<button
type="button"
onClick={() => onSelect(step.key)}
className={cn(
"flex shrink-0 items-center gap-2.5 rounded-pill px-3 py-2 text-left transition-colors",
active ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-accent/40",
)}
>
<span
className={cn(
"flex h-7 w-7 items-center justify-center rounded-pill border text-xs font-semibold",
active
? "border-primary bg-primary text-primary-foreground"
: complete
? "border-status-success/40 bg-status-success/15 text-status-success"
: "border-border/70 text-muted-foreground",
)}
>
{complete && !active ? <Check className="h-4 w-4" /> : index + 1}
</span>
<span className="hidden sm:block">
<span className="block text-sm font-semibold leading-none">{step.label}</span>
<span className="mt-0.5 block text-xs text-muted-foreground">{step.description}</span>
</span>
</button>
{index < WIZARD_STEPS.length - 1 ? (
<div className="h-px w-5 shrink-0 bg-border/60 sm:w-8" />
) : null}
</Fragment>
);
})}
</div>
);
}
function createMediaFieldState(params: { function createMediaFieldState(params: {
kind: "IMAGE"; kind: "IMAGE";
assetId?: string | null; assetId?: string | null;
@@ -499,6 +563,7 @@ export function PortfolioProjectForm({
const [sections, setSections] = useState<SectionFormValue[]>(initialSections); const [sections, setSections] = useState<SectionFormValue[]>(initialSections);
const [assets, setAssets] = useState<AssetFormValue[]>(initialAssets); const [assets, setAssets] = useState<AssetFormValue[]>(initialAssets);
const [showValidation, setShowValidation] = useState(false); const [showValidation, setShowValidation] = useState(false);
const [activeStep, setActiveStep] = useState<PortfolioWizardStep>("basics");
const sectionsInputRef = useRef<HTMLInputElement | null>(null); const sectionsInputRef = useRef<HTMLInputElement | null>(null);
const assetsInputRef = useRef<HTMLInputElement | null>(null); const assetsInputRef = useRef<HTMLInputElement | null>(null);
const intentRef = useRef<HTMLInputElement | null>(null); const intentRef = useRef<HTMLInputElement | null>(null);
@@ -532,6 +597,12 @@ export function PortfolioProjectForm({
})), })),
}); });
const firstIncompleteStep = getFirstIncompleteWizardStep(progress); const firstIncompleteStep = getFirstIncompleteWizardStep(progress);
const activeStepIndex = WIZARD_STEPS.findIndex((step) => step.key === activeStep);
const isLastStep = activeStepIndex === WIZARD_STEPS.length - 1;
const goToStep = (index: number) => {
const target = WIZARD_STEPS[Math.min(Math.max(index, 0), WIZARD_STEPS.length - 1)];
setActiveStep(target.key);
};
useEffect(() => { useEffect(() => {
sectionsInputRef.current?.dispatchEvent(new Event("input", { bubbles: true })); sectionsInputRef.current?.dispatchEvent(new Event("input", { bubbles: true }));
@@ -594,7 +665,10 @@ 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" />
<div className="grid gap-6 xl:grid-cols-2 xl:items-start"> <WizardStepper activeKey={activeStep} progress={progress} onSelect={setActiveStep} />
<div className="mx-auto w-full max-w-3xl">
<div className={cn(activeStep !== "basics" && "hidden")}>
<section className="space-y-5"> <section className="space-y-5">
<SectionHeader <SectionHeader
title="Basic Information" title="Basic Information"
@@ -752,7 +826,9 @@ export function PortfolioProjectForm({
/> />
</div> </div>
</section> </section>
</div>
<div className={cn(activeStep !== "content" && "hidden")}>
<section className="space-y-5"> <section className="space-y-5">
<SectionHeader <SectionHeader
title="Localized Content" title="Localized Content"
@@ -799,7 +875,9 @@ export function PortfolioProjectForm({
multiline multiline
/> />
</section> </section>
</div>
<div className={cn(activeStep !== "sections" && "hidden")}>
<section className="space-y-5"> <section className="space-y-5">
<SectionHeader <SectionHeader
title="Sections" title="Sections"
@@ -1001,7 +1079,9 @@ export function PortfolioProjectForm({
</Accordion> </Accordion>
)} )}
</section> </section>
</div>
<div className={cn(activeStep !== "assets" && "hidden")}>
<div className="space-y-6"> <div className="space-y-6">
<section className="space-y-4"> <section className="space-y-4">
<SectionHeader <SectionHeader
@@ -1155,8 +1235,9 @@ export function PortfolioProjectForm({
</section> </section>
</div> </div>
</div> </div>
</div>
<div className="space-y-4 border-t border-border/60 pt-6"> <div className="mx-auto flex w-full max-w-3xl flex-col gap-4 border-t border-border/60 pt-6">
{showValidation && firstIncompleteStep ? ( {showValidation && firstIncompleteStep ? (
<div className="rounded-nested border border-status-warning/30 bg-status-warning-soft px-4 py-3"> <div className="rounded-nested border border-status-warning/30 bg-status-warning-soft px-4 py-3">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
@@ -1171,19 +1252,27 @@ export function PortfolioProjectForm({
</div> </div>
) : null} ) : null}
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-end"> <div className="flex items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-3"> <Button
{firstIncompleteStep ? ( type="button"
<p className="text-sm text-muted-foreground"> variant="outline"
Fill Basics and Localized Content to publish or save as a draft anytime. onClick={() => goToStep(activeStepIndex - 1)}
</p> disabled={activeStepIndex === 0}
) : ( >
<p className="text-sm text-muted-foreground"> <ArrowLeft className="h-4 w-4" />
Ready to save. Sections and assets are optional. Zurück
</p> </Button>
)}
<div className="flex items-center gap-2">
<DraftButton onSelect={() => setIntent("draft")} /> <DraftButton onSelect={() => setIntent("draft")} />
{isLastStep ? (
<SubmitButton onSelect={() => setIntent("save")} /> <SubmitButton onSelect={() => setIntent("save")} />
) : (
<Button type="button" onClick={() => goToStep(activeStepIndex + 1)}>
Weiter
<ArrowRight className="h-4 w-4" />
</Button>
)}
</div> </div>
</div> </div>
</div> </div>