STYLED - Turn the project form into a step-by-step wizard journey
CI / quality (push) Canceled after 0s
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:
@@ -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 1–3 become the lead panels.", icon: BriefcaseBusiness },
|
{ value: "CASE_STUDY", label: "Case Study", description: "Structured challenge/solution/outcome view. Sections 1–3 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user