Compare commits

..
14 Commits
Author SHA1 Message Date
moh 369c0e24c6 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.
2026-09-20 18:17:40 +02:00
moh d4b7f883e1 STYLED - Drop section cards in the project form, lay content flat on the page
Experiment: remove the AppCard wrappers around Basic Information, Localized
Content, Sections and Cover/Assets, plus the action bar, so the form content
sits directly on the page. Section headers now carry a thin underline and the
action bar a top divider to keep structure without card chrome. Two-column
layout preserved. No field-name or save-logic changes.
2026-09-20 18:01:03 +02:00
moh 19e8fa8f19 STYLED - Two-column project form, drop the redundant header card
- Remove the project summary/header card (project name, badges, progress);
  it added no value while creating a project. Move the on-submit validation
  alert down to the action bar.
- Lay the whole form out in two columns: Basic Information + Localized
  Content, then Sections + Cover/Assets. Basic Information fields cap at two
  columns and the Cover/Assets card stacks inside its column.

All 375 tests pass; tsc and eslint clean.
2026-09-20 17:40:45 +02:00
moh 077e1c5836 STYLED - Flatten media picker and use a two-column media layout
Fix the deeply nested cards in the project form (screenshot: Cover Media >
Cover > preview card, with duplicate labels):

- MediaFieldPicker no longer wraps itself in AppCards. It renders one flat
  bordered box laid out in two columns (preview | controls), dropping two
  nested card layers and the duplicate title.
- Cover Media and Assets now sit side by side in a two-column grid instead
  of stacked with a separator.

All 375 tests pass; tsc and eslint clean.
2026-09-20 17:30:44 +02:00
moh 2f8fd66812 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.
2026-09-20 17:16:52 +02:00
moh 40387c19d4 STYLED - Turn the admin portfolio overview into a professional table
Replace the card grid with a compact data table (cover, project, category,
year, status, layout, actions) and a proper empty state that adapts to an
active category filter. Actions expose a public-view link plus the existing
edit/delete menu. No data-layer or save changes.
2026-09-20 17:08:20 +02:00
moh d1245204f1 STYLED - Roll out the layered card system to the admin workspace
The double-layer AppCard look showcased in the UI Kit was only applied on
the public frontend; every admin surface was pinned to layer=single and
stayed flat. Convert the admin section/container cards to the default
layered look, keeping compact metric tiles, toolbars, image tiles and
nested leaf cards single.

Add AppCard contentClassName so content utilities (space-y-*, grid, flex)
keep applying to the inner shell after the switch to double layer.
2026-09-20 17:01:20 +02:00
moh 4b535694bc ADDED - Save an unfinished project as a draft
- Add a Save as draft button that bypasses the completeness gate: the user
  can save partial work and finish later
- projectDraftInputSchema makes the user-facing copy (title, summary, service
  label, client) optional; the action auto-generates a slug from the title
  (or draft-<timestamp>), defaults the year to the current year, and forces
  the project unpublished
- Drafts drop not-yet-valid sections/assets instead of failing the save
- Pass intent via a hidden field set on click so the server reliably sees it
- Tests: draft schema accepts empty copy a strict save rejects, and still
  requires a slug and a valid year
2026-09-20 16:52:13 +02:00
moh 39e459fa09 REVERTED - Drop body suppressHydrationWarning
- The hydration warning came from a browser extension, not the app; handling
  it at the extension level instead of masking it in the layout
2026-09-20 16:44:03 +02:00
moh 80b1365ebc FIXED - Suppress body hydration warning from browser extensions
- Browser extensions (e.g. ColorZilla's cz-shortcut-listen) inject attributes
  on <body> before React hydrates, causing a benign hydration mismatch warning
- Add suppressHydrationWarning to <body>, matching the existing <html>
2026-09-20 16:42:58 +02:00
moh 3c2421fb8b STYLED - Convert the add-project form from a 4-tab wizard to one page
- Remove the tab navigation, per-step sidebar, and Previous/Next buttons;
  render Basics, Localized Content, Sections and Assets stacked on a single
  scrollable page
- Drop the now-unused wizard step machinery (currentStep state, wizardSteps,
  StepStateBadge, step stat card) and related imports
- Keep the same fields, validation, and save gating (Basics + Localized
  Content required; sections/assets optional)
2026-09-20 16:39:59 +02:00
moh 22cd3f8645 STYLED - Rework project detail into three real layouts per viewMode
- CASE_STUDY -> Web: two columns, stacked full-width screenshots with a
  browser frame plus a sticky project info panel
- GRID -> Print: mixed-size gallery grid for logos/posters/etc.
- STORY -> Editorial: full-bleed hero with title overlay, then alternating
  text/media sections
- Add a prominent title/summary intro (was missing from the header)
- Use PortfolioCover for all media so empty projects show the branded
  placeholder instead of broken images; gallery sections render even
  before an image is uploaded
2026-09-20 16:27:05 +02:00
moh 26969e25b2 FIXED - Load .env in drizzle.config so drizzle-kit targets the right DB
- drizzle-kit does not read .env like the Next.js app, so DATABASE_URL was
  undefined and fell back to the default host, causing migrate to hang and
  push to fail against the wrong database
- Load .env via native process.loadEnvFile for local dev, guarded so
  production (env already set, no .env file) is unaffected
2026-09-20 02:05:54 +02:00
moh d78450fc47 FIXED - Make adding a portfolio project simpler and actually saveable
- Fix critical bug: TabsContent unmounted inactive panels, so the project
  form only submitted the active tab's fields and could never save; add an
  opt-in forceMount that keeps panels mounted (hidden) and use it on all
  four project-form tabs
- Make Sections and Assets optional: a project saves with just Basics and
  Localized Content; new projects start with no sections/assets, and the
  last one can now be removed
- Update wizard progress so empty sections/assets steps count as complete
- Update save hint copy and step labels to reflect the optional steps
- Correct .claude/launch.json dev port to 3014
- Update portfolio-form-progress tests for the optional-steps behaviour
2026-09-20 01:56:50 +02:00
21 changed files with 1176 additions and 978 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
"name": "dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3000
"port": 3014
}
]
}
+1 -1
View File
@@ -60,7 +60,7 @@ export default async function AdminMaintenancePage({
headerDescription={copy.subtitle}
>
<MotionFade delay={0.1}>
<AppCard layer="single">
<AppCard>
<CardContent className="space-y-4 p-6">
<p className="text-sm text-muted-foreground">{copy.maintenanceText}</p>
<div className="flex flex-wrap items-center gap-3">
+60 -10
View File
@@ -30,10 +30,21 @@ import { getSiteSettings } from "@/lib/app-config";
import {
assetInputSchema,
categoryInputSchema,
projectDraftInputSchema,
projectInputSchema,
sectionInputSchema,
} from "@/lib/portfolio-validation";
/** Build a URL-safe slug from a title, falling back to a unique draft slug. */
function slugifyForDraft(input: string): string {
const base = input
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return base || `draft-${Date.now()}`;
}
async function ensureAdmin() {
if (!(await isAdminAuthenticated())) {
await clearAdminSessionCookie();
@@ -204,28 +215,67 @@ export async function saveProjectAction(formData: FormData) {
const createdMediaAssetIds: string[] = [];
try {
const sections = parseJsonArray(formData.get("sections"), "sections").map((section, index) =>
const intent = String(formData.get("intent") ?? "save");
const isDraft = intent === "draft";
const parseSection = (section: Record<string, unknown>, index: number) =>
sectionInputSchema.parse({
...section,
media: section.media ? mediaFieldInputSchema.parse(section.media) : undefined,
sortOrder: section.sortOrder ?? index,
}),
);
});
const assets = parseJsonArray(formData.get("assets"), "assets").map((asset, index) =>
const parseAsset = (asset: Record<string, unknown>, index: number) =>
assetInputSchema.parse({
...asset,
media: asset.media ? mediaFieldInputSchema.parse(asset.media) : undefined,
sortOrder: asset.sortOrder ?? index,
}),
);
});
// A draft keeps only the entries that are already valid; a full save
// validates every entry strictly.
const sections = parseJsonArray(formData.get("sections"), "sections").flatMap((section, index) => {
if (!isDraft) {
return [parseSection(section, index)];
}
try {
return [parseSection(section, index)];
} catch {
return [];
}
});
const assets = parseJsonArray(formData.get("assets"), "assets").flatMap((asset, index) => {
if (!isDraft) {
return [parseAsset(asset, index)];
}
try {
return [parseAsset(asset, index)];
} catch {
return [];
}
});
const coverMedia = parseJsonObject(formData.get("coverMedia"), "coverMedia");
const parsed = projectInputSchema.parse({
const rawSlug = String(formData.get("slug") ?? "").trim();
const slug =
isDraft && !rawSlug
? slugifyForDraft(
String(formData.get("titleDe") ?? "") ||
String(formData.get("titleEn") ?? "") ||
String(formData.get("titleAr") ?? ""),
)
: rawSlug;
const rawYear = String(formData.get("projectYear") ?? "").trim();
const projectYear = isDraft && !rawYear ? String(new Date().getFullYear()) : rawYear;
const parsed = (isDraft ? projectDraftInputSchema : projectInputSchema).parse({
id: String(formData.get("id") ?? "").trim() || undefined,
categoryId: String(formData.get("categoryId") ?? ""),
slug: String(formData.get("slug") ?? ""),
slug,
viewMode: String(formData.get("viewMode") ?? "GRID"),
titleAr: String(formData.get("titleAr") ?? ""),
titleEn: String(formData.get("titleEn") ?? ""),
@@ -234,7 +284,7 @@ export async function saveProjectAction(formData: FormData) {
summaryEn: String(formData.get("summaryEn") ?? ""),
summaryDe: String(formData.get("summaryDe") ?? ""),
clientName: String(formData.get("clientName") ?? ""),
projectYear: String(formData.get("projectYear") ?? ""),
projectYear,
serviceLabelAr: String(formData.get("serviceLabelAr") ?? ""),
serviceLabelEn: String(formData.get("serviceLabelEn") ?? ""),
serviceLabelDe: String(formData.get("serviceLabelDe") ?? ""),
@@ -243,7 +293,7 @@ export async function saveProjectAction(formData: FormData) {
coverMedia: coverMedia ? mediaFieldInputSchema.parse(coverMedia) : undefined,
sortOrder: String(formData.get("sortOrder") ?? "0"),
isFeatured: normalizeCheckboxValue(formData, "isFeatured"),
isPublished: normalizeCheckboxValue(formData, "isPublished"),
isPublished: isDraft ? false : normalizeCheckboxValue(formData, "isPublished"),
sections,
assets,
});
+1 -1
View File
@@ -104,7 +104,7 @@ export default async function AdminPortfolioProjectPage({
</MotionFade>
<MotionFade delay={0.2}>
<AppCard level={2} layer="single">
<AppCard level={2}>
<CardContent className="flex flex-col gap-4 p-6 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-sm font-medium text-foreground">{copy.dangerZone}</p>
+1 -1
View File
@@ -29,7 +29,7 @@ export function MarqueeSettingsForm({
<div className="grid gap-4 xl:grid-cols-4">
{rowMeta.map((row) => (
<AppCard key={`de-${row.key}`} level={2} layer="single" padding="sm" className="space-y-2">
<AppCard key={`de-${row.key}`} level={2} padding="sm" contentClassName="space-y-2">
<Label htmlFor={`${row.key}-de`} className="text-sm font-semibold text-foreground">{row.label}</Label>
<Textarea
id={`${row.key}-de`}
+31 -37
View File
@@ -6,7 +6,6 @@ import type { MediaKind } from "@/lib/db/enums";
import { Check, ImageIcon, Search, Trash2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { AppCard } from "@/components/ui/app-card";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -17,7 +16,6 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { MediaOption } from "@/lib/media";
import { cn } from "@/lib/utils";
@@ -89,29 +87,45 @@ export function MediaFieldPicker({
}, [serializedValue]);
return (
<AppCard level={2} layer="single" padding="sm" className="space-y-4">
<div className="space-y-2">
<input ref={hiddenInputRef} type="hidden" name={inputName} value={serializedValue} />
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label className="text-sm font-semibold text-foreground">{title}</Label>
<p className="text-sm text-muted-foreground">
Media must be selected from the
{" "}
Media Library
.
</p>
<div className="flex flex-col gap-3 rounded-nested border border-border/70 bg-background p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-3">
{selectedOption ? (
<>
<img
src={selectedOption.url}
alt={selectedOption.label}
className="h-14 w-14 shrink-0 rounded-nested border border-border/60 object-cover"
/>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">{selectedOption.label}</p>
<p className="truncate text-xs text-muted-foreground">{selectedOption.source}</p>
</div>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={() => setOpen(true)}>
</>
) : (
<div className="flex items-center gap-3 text-muted-foreground">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-nested border border-dashed border-border/70">
<ImageIcon className="h-5 w-5" />
</div>
<span className="text-sm">Kein Medium ausgewählt</span>
</div>
)}
</div>
<div className="flex shrink-0 gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
<ImageIcon className="h-4 w-4" />
Select from Media
{selectedOption ? "Ändern" : "Auswählen"}
</Button>
{canClear ? (
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
title={clearLabel}
onClick={() =>
onChange({
...value,
@@ -124,32 +138,12 @@ export function MediaFieldPicker({
}
>
<Trash2 className="h-4 w-4" />
{clearLabel}
<span className="sr-only">{clearLabel}</span>
</Button>
) : null}
</div>
</div>
<AppCard layer="single" padding="sm" className="rounded-nested border-border/70">
{selectedOption ? (
<div className="flex items-center gap-4">
<img
src={selectedOption.url}
alt={selectedOption.label}
className="h-16 w-16 rounded-nested object-cover"
/>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">{selectedOption.label}</p>
<p className="truncate text-xs text-muted-foreground">{selectedOption.source}</p>
</div>
</div>
) : (
<div className="rounded-nested border border-dashed border-border/70 px-4 py-6 text-sm text-muted-foreground">
No media selected.
</div>
)}
</AppCard>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-4xl">
<DialogHeader>
@@ -215,6 +209,6 @@ export function MediaFieldPicker({
</DialogFooter>
</DialogContent>
</Dialog>
</AppCard>
</div>
);
}
+1 -1
View File
@@ -486,7 +486,7 @@ export function MediaLibraryManager({
)
) : (
<MotionFade delay={0.18}>
<AppCard layer="single">
<AppCard>
<CardContent className="p-6 text-sm text-muted-foreground">
{copy.empty}
</CardContent>
@@ -110,7 +110,7 @@ function CategoryLocaleFields({
const descriptionKey = `description${locale.key}` as const;
return (
<AppCard key={`${idPrefix}-${locale.key}`} level={2} layer="single" padding="sm" className="space-y-4 rounded-nested">
<AppCard key={`${idPrefix}-${locale.key}`} level={2} padding="sm" className="rounded-nested" contentClassName="space-y-4">
<p className="text-sm font-medium text-foreground">{locale.label}</p>
<div className="space-y-2">
@@ -168,7 +168,7 @@ function CategoryStatusFields({
</div>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
<AppCard level={2} layer="single" padding="sm" className="rounded-nested">
<AppCard level={2} padding="sm" className="rounded-nested">
<div className="flex items-start gap-3">
{isActive ? (
<ShieldCheck className="mt-0.5 h-4 w-4 text-status-success" />
@@ -395,7 +395,7 @@ export function PortfolioCategoriesManager({
<DialogDescription>{copy.modalDescription}</DialogDescription>
</DialogHeader>
<AppCard level={2} layer="single" padding="sm" className="rounded-nested">
<AppCard level={2} padding="sm" className="rounded-nested">
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">1. Basics</p>
@@ -426,7 +426,7 @@ export function PortfolioCategoriesManager({
</Dialog>
</div>
<AppCard level={3} layer="single">
<AppCard level={3}>
<CardContent className="space-y-4 p-6">
<div className="flex items-center gap-3">
<Layers3 className="h-5 w-5 text-brand-primary" />
+298 -380
View File
@@ -3,16 +3,14 @@
import type { PortfolioProjectViewMode, PortfolioSectionType } from "@/lib/db/enums";
import {
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
BookOpenText,
BriefcaseBusiness,
CalendarDays,
CheckCircle2,
ChevronLeft,
ChevronRight,
Check,
CircleAlert,
CircleDashed,
Files,
FolderTree,
Grid2x2,
ImagePlus,
@@ -24,18 +22,22 @@ import {
UserRound,
} from "lucide-react";
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 { 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";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import {
Select,
SelectContent,
@@ -43,7 +45,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { moveArrayItem } from "@/lib/array";
import { getAdminAppPath } from "@/lib/admin-routing";
@@ -53,6 +54,7 @@ import {
isPortfolioAssetReady,
isPortfolioSectionReady,
type PortfolioWizardStep,
type PortfolioWizardStepState,
} from "@/lib/portfolio-form-progress";
import type { MediaOption } from "@/lib/media";
import type { PortfolioCategoryView, PortfolioProjectView } from "@/lib/portfolio";
@@ -148,33 +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 },
];
const wizardSteps: Array<{
key: PortfolioWizardStep;
label: string;
description: string;
}> = [
{
key: "basics",
label: "Basics",
description: "Category, slug, client, year, and status.",
},
{
key: "content",
label: "Localized Content",
description: "Title, service label, and summary in all locales.",
},
{
key: "sections",
label: "Sections",
description: "Build the project story with ordered content blocks.",
},
{
key: "assets",
label: "Assets & Media",
description: "Cover and gallery assets from the media library.",
},
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: {
kind: "IMAGE";
assetId?: string | null;
@@ -233,7 +267,8 @@ function createInitialState(
function createInitialSections(project: PortfolioProjectView | null | undefined): SectionFormValue[] {
if (!project?.sections.length) {
return [createEmptySection(0)];
// Sections are optional — start empty so a project can be saved without them.
return [];
}
return project.sections.map((section, index) => ({
@@ -259,7 +294,8 @@ function createInitialSections(project: PortfolioProjectView | null | undefined)
function createInitialAssets(project: PortfolioProjectView | null | undefined): AssetFormValue[] {
if (!project?.assets.length) {
return [createEmptyAsset(0)];
// Gallery assets are optional — start empty so a project can be saved without them.
return [];
}
return project.assets.map((asset, index) => ({
@@ -323,34 +359,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>
@@ -367,7 +401,7 @@ function SectionHeader({
action?: ReactNode;
}) {
return (
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="flex flex-col gap-4 border-b border-border/60 pb-3 lg:flex-row lg:items-end lg:justify-between">
<div className="space-y-1">
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
@@ -377,40 +411,51 @@ function SectionHeader({
);
}
function StepStateBadge({
complete,
active,
showValidation,
function EmptyPanel({
icon: Icon,
title,
description,
action,
}: {
complete: boolean;
active: boolean;
showValidation: boolean;
icon: typeof Grid2x2;
title: string;
description: string;
action?: ReactNode;
}) {
if (complete) {
return <Badge variant="success">Complete</Badge>;
}
if (showValidation) {
return <Badge variant="warning">Needs Attention</Badge>;
}
if (active) {
return <Badge variant="outline">Current</Badge>;
}
return <Badge variant="outline">Incomplete</Badge>;
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() {
function SubmitButton({ onSelect }: { onSelect: () => void }) {
const { pending } = useFormStatus();
return (
<Button type="submit" disabled={pending}>
<Button type="submit" onClick={onSelect} disabled={pending}>
{pending ? "Saving..." : "Save Project"}
</Button>
);
}
function DraftButton({ onSelect }: { onSelect: () => void }) {
const { pending } = useFormStatus();
return (
<Button type="submit" onClick={onSelect} variant="outline" disabled={pending}>
Save as draft
</Button>
);
}
function ViewModeCard({
active,
label,
@@ -506,27 +551,6 @@ export function PortfolioProjectForm({
const initialProjectState = createInitialState(project, availableCategories);
const initialSections = createInitialSections(project);
const initialAssets = createInitialAssets(project);
const initialProgress = getPortfolioWizardProgress({
basics: initialProjectState,
content: initialProjectState,
sections: initialSections.map((section) => ({
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,
})),
assets: initialAssets.map((asset) => ({
mediaAssetId: asset.media.assetId,
altAr: asset.altAr,
altEn: asset.altEn,
altDe: asset.altDe,
})),
});
const [projectState, setProjectState] = useState<ProjectFormState>(initialProjectState);
const [coverMedia, setCoverMedia] = useState<MediaFieldState>(
createMediaFieldState({
@@ -538,12 +562,16 @@ export function PortfolioProjectForm({
);
const [sections, setSections] = useState<SectionFormValue[]>(initialSections);
const [assets, setAssets] = useState<AssetFormValue[]>(initialAssets);
const [currentStep, setCurrentStep] = useState<PortfolioWizardStep>(
getFirstIncompleteWizardStep(initialProgress) ?? "basics",
);
const [showValidation, setShowValidation] = useState(false);
const [activeStep, setActiveStep] = useState<PortfolioWizardStep>("basics");
const sectionsInputRef = useRef<HTMLInputElement | null>(null);
const assetsInputRef = useRef<HTMLInputElement | null>(null);
const intentRef = useRef<HTMLInputElement | null>(null);
const setIntent = (value: "save" | "draft") => {
if (intentRef.current) {
intentRef.current.value = value;
}
};
const sectionsPayload = JSON.stringify(sections.map((section, index) => ({ ...section, sortOrder: index })));
const assetsPayload = JSON.stringify(assets.map((asset, index) => ({ ...asset, sortOrder: index })));
@@ -569,15 +597,12 @@ export function PortfolioProjectForm({
})),
});
const firstIncompleteStep = getFirstIncompleteWizardStep(progress);
const currentStepIndex = wizardSteps.findIndex((step) => step.key === currentStep);
const selectedCategory =
availableCategories.find((category) => category.id === projectState.categoryId) ?? null;
const projectLabel =
projectState.titleDe.trim() ||
projectState.titleEn.trim() ||
projectState.titleAr.trim() ||
projectState.slug.trim() ||
"Untitled Project";
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(() => {
sectionsInputRef.current?.dispatchEvent(new Event("input", { bubbles: true }));
@@ -591,7 +616,7 @@ export function PortfolioProjectForm({
if (!project && availableCategories.length === 0) {
return (
<AppCard level={3} layer="single" padding="lg" className="rounded-surface">
<AppCard level={3} padding="lg" className="rounded-surface">
<div className="space-y-4">
<div className="space-y-2">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Portfolio Setup</p>
@@ -619,13 +644,17 @@ export function PortfolioProjectForm({
action={action}
className="space-y-8"
onSubmit={(event) => {
// A draft save skips the completeness gate entirely.
if (intentRef.current?.value === "draft") {
return;
}
if (!firstIncompleteStep) {
return;
}
event.preventDefault();
setShowValidation(true);
setCurrentStep(firstIncompleteStep);
}}
>
<input type="hidden" name="id" value={project?.id ?? ""} />
@@ -634,113 +663,19 @@ export function PortfolioProjectForm({
<input type="hidden" name="viewMode" value={projectState.viewMode} />
<input ref={sectionsInputRef} type="hidden" name="sections" value={sectionsPayload} />
<input ref={assetsInputRef} type="hidden" name="assets" value={assetsPayload} />
<input ref={intentRef} type="hidden" name="intent" defaultValue="save" />
<AppCard level={3} layer="single" padding="lg" className="space-y-6">
<div className="flex flex-col gap-6">
<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">
<Badge variant={projectState.isPublished ? "success" : "warning"}>
{projectState.isPublished ? "Published" : "Draft"}
</Badge>
{projectState.isFeatured ? <Badge variant="outline">Featured</Badge> : null}
{!selectedCategory?.isActive ? <Badge variant="warning">Inactive Category</Badge> : null}
</div>
<div>
<h2 className="text-2xl font-semibold text-foreground">{projectLabel}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{selectedCategory ? `${selectedCategory.name.de || selectedCategory.name.en || selectedCategory.name.ar}` : "No category selected"}
</p>
</div>
</div>
<WizardStepper activeKey={activeStep} progress={progress} onSelect={setActiveStep} />
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<StatsCard
title="Step"
value={wizardSteps[currentStepIndex]?.label ?? "Basics"}
icon={Layers3}
/>
<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>
</div>
{showValidation && firstIncompleteStep ? (
<div className="rounded-nested border border-status-warning/30 bg-status-warning-soft px-4 py-3">
<div className="flex items-start gap-3">
<CircleAlert className="mt-0.5 h-4 w-4 text-status-warning" />
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">Complete the highlighted step before saving.</p>
<p className="text-sm text-muted-foreground">
The wizard moved to the first incomplete step so the missing fields are easier to find.
</p>
</div>
</div>
</div>
) : null}
</div>
</AppCard>
<Tabs value={currentStep} onValueChange={(value) => setCurrentStep(value as PortfolioWizardStep)}>
<div className="grid gap-6 xl:grid-cols-[300px_minmax(0,1fr)] xl:items-start">
<AppCard level={2} layer="single" padding="sm" className="xl:sticky xl:top-4">
<TabsList className="grid h-auto w-full gap-3 rounded-none bg-transparent p-0">
{wizardSteps.map((step, index) => {
const stepProgress = progress.find((entry) => entry.key === step.key);
const isActive = currentStep === step.key;
return (
<TabsTrigger
key={step.key}
value={step.key}
className={cn(
"w-full rounded-nested border border-border/80 bg-background p-0 text-left hover:bg-accent/30",
isActive && "border-input bg-accent/20 text-foreground shadow-xs",
)}
>
<div className="flex w-full flex-col gap-3 p-4">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-border/80 bg-surface-2 text-sm font-semibold text-foreground">
{index + 1}
</span>
<div>
<p className="text-sm font-semibold text-foreground">{step.label}</p>
</div>
</div>
{stepProgress?.complete ? (
<CheckCircle2 className="h-4 w-4 text-status-success" />
) : (
<CircleDashed className="h-4 w-4 text-muted-foreground" />
)}
</div>
<StepStateBadge
complete={Boolean(stepProgress?.complete)}
active={isActive}
showValidation={showValidation && !stepProgress?.complete}
/>
</div>
</TabsTrigger>
);
})}
</TabsList>
</AppCard>
<div className="space-y-6">
<TabsContent value="basics" className="mt-0">
<AppCard level={3} layer="single" padding="lg">
<section className="space-y-6">
<div className="mx-auto w-full max-w-3xl">
<div className={cn(activeStep !== "basics" && "hidden")}>
<section className="space-y-5">
<SectionHeader
title="Basic Information"
description="Choose the category and the stable project metadata first."
/>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="categoryId" className="sr-only">Category</Label>
<div className="relative">
@@ -891,12 +826,10 @@ export function PortfolioProjectForm({
/>
</div>
</section>
</AppCard>
</TabsContent>
</div>
<TabsContent value="content" className="mt-0">
<AppCard level={3} layer="single" padding="lg">
<section className="space-y-6">
<div className={cn(activeStep !== "content" && "hidden")}>
<section className="space-y-5">
<SectionHeader
title="Localized Content"
description="Finish the user-facing copy before building sections and media."
@@ -942,15 +875,13 @@ export function PortfolioProjectForm({
multiline
/>
</section>
</AppCard>
</TabsContent>
</div>
<TabsContent value="sections" className="mt-0">
<AppCard level={3} layer="single" padding="lg">
<section className="space-y-6">
<div className={cn(activeStep !== "sections" && "hidden")}>
<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"
@@ -963,46 +894,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}
>
@@ -1011,6 +953,7 @@ export function PortfolioProjectForm({
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setSections((current) => moveArrayItem(current, index, index + 1))}
disabled={index === sections.length - 1}
>
@@ -1019,19 +962,17 @@ export function PortfolioProjectForm({
<Button
type="button"
variant="ghost"
size="icon"
className="text-destructive"
onClick={() =>
sections.length > 1 &&
setSections((current) =>
current.filter((_, currentIndex) => currentIndex !== index),
)
}
disabled={sections.length === 1}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
@@ -1131,21 +1072,21 @@ export function PortfolioProjectForm({
/>
) : null}
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
)}
</section>
</AppCard>
</TabsContent>
</div>
<TabsContent value="assets" className="mt-0">
<AppCard level={3} layer="single" padding="lg">
<div className="space-y-8">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
<div className={cn(activeStep !== "assets" && "hidden")}>
<div className="space-y-6">
<section className="space-y-6">
<section className="space-y-4">
<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
@@ -1162,12 +1103,10 @@ export function PortfolioProjectForm({
/>
</section>
<Separator />
<section className="space-y-6">
<section className="space-y-4">
<SectionHeader
title="Assets"
description="Assets stay ordered and ready for localized alt text."
description="Optional — Galeriebilder mit lokalisiertem Alt-Text."
action={(
<Button
type="button"
@@ -1180,36 +1119,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}
>
@@ -1218,6 +1172,7 @@ export function PortfolioProjectForm({
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setAssets((current) => moveArrayItem(current, index, index + 1))}
disabled={index === assets.length - 1}
>
@@ -1226,19 +1181,17 @@ export function PortfolioProjectForm({
<Button
type="button"
variant="ghost"
size="icon"
className="text-destructive"
onClick={() =>
assets.length > 1 &&
setAssets((current) =>
current.filter((_, currentIndex) => currentIndex !== index),
)
}
disabled={assets.length === 1}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<MediaFieldPicker
title="Asset Image"
@@ -1273,91 +1226,56 @@ export function PortfolioProjectForm({
}
/>
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
)}
</section>
</div>
</div>
</div>
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4 border-t border-border/60 pt-6">
{showValidation && firstIncompleteStep ? (
<div className="rounded-nested border border-status-warning/30 bg-status-warning-soft px-4 py-3">
<div className="flex items-start gap-3">
<CircleAlert className="mt-0.5 h-4 w-4 text-status-warning" />
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">Some required fields are still missing.</p>
<p className="text-sm text-muted-foreground">
Fill the Basics and Localized Content fields, then save.
</p>
</div>
</div>
</div>
) : null}
<div className="flex items-center justify-between gap-3">
<Button
type="button"
variant="outline"
onClick={() => goToStep(activeStepIndex - 1)}
disabled={activeStepIndex === 0}
>
<ArrowLeft className="h-4 w-4" />
Zurück
</Button>
<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,
)}
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>
</TabsContent>
</div>
</div>
</Tabs>
<AppCard level={3} layer="single" padding="lg">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={() => setCurrentStep(wizardSteps[Math.max(currentStepIndex - 1, 0)]!.key)}
disabled={currentStepIndex === 0}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
type="button"
variant="outline"
onClick={() =>
setCurrentStep(
wizardSteps[Math.min(currentStepIndex + 1, wizardSteps.length - 1)]!.key,
)
}
disabled={currentStepIndex === wizardSteps.length - 1}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-wrap items-center gap-3">
{firstIncompleteStep ? (
<p className="text-sm text-muted-foreground">
Save becomes available after all steps are complete.
</p>
<DraftButton onSelect={() => setIntent("draft")} />
{isLastStep ? (
<SubmitButton onSelect={() => setIntent("save")} />
) : (
<p className="text-sm text-muted-foreground">
All steps are ready. You can save now.
</p>
<Button type="button" onClick={() => goToStep(activeStepIndex + 1)}>
Weiter
<ArrowRight className="h-4 w-4" />
</Button>
)}
<SubmitButton />
</div>
</div>
</AppCard>
</div>
</form>
);
}
+142 -45
View File
@@ -1,13 +1,22 @@
import { ExternalLink, FolderKanban, Plus, Tags, CheckCircle2 } from "lucide-react";
/* eslint-disable @next/next/no-img-element */
import { CheckCircle2, ExternalLink, FolderKanban, ImageOff, Plus, Star, Tags } from "lucide-react";
import Link from "next/link";
import { StatsCard } from "@/components/dashboard/stats-card";
import { MotionFade } from "@/components/motion-fade";
import { PortfolioProjectActions } from "@/components/admin/portfolio-project-actions";
import { AppCard } from "@/components/ui/app-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CardContent } from "@/components/ui/card";
import { Card } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { getAdminAppPath } from "@/lib/admin-routing";
import { getSiteSettings } from "@/lib/app-config";
import { getLocalizedPath } from "@/lib/locale";
@@ -24,17 +33,34 @@ const copy = {
all: "Alle",
newProject: "Neues Projekt",
newCategory: "Neues Kategorie",
openProject: "Ansehen",
view: "Auf der Website ansehen",
untitled: "Unbenanntes Projekt",
empty: "Noch keine Projekte vorhanden.",
emptyFiltered: "Keine Projekte in dieser Auswahl.",
emptyHint: "Lege dein erstes Projekt an, um es hier zu verwalten.",
colProject: "Projekt",
colCategory: "Kategorie",
colYear: "Jahr",
colStatus: "Status",
colMode: "Layout",
colActions: "Aktionen",
published: "Published",
draft: "Draft",
featured: "Featured",
};
function categoryLabel(name: PortfolioCategoryView["name"]) {
return name.de || name.en || name.ar;
}
export async function PortfolioProjectsOverview({
categories,
projects,
selectedCategory,
}: PortfolioProjectsOverviewProps) {
const siteSettings = await getSiteSettings();
const publishedCount = projects.filter((project) => project.isPublished).length;
const isFiltered = selectedCategory !== "";
return (
<div className="space-y-6">
@@ -42,11 +68,7 @@ export async function PortfolioProjectsOverview({
<div className="grid gap-4 md:grid-cols-3 xl:min-w-[620px]">
<StatsCard title="Projects" value={String(projects.length)} icon={FolderKanban} />
<StatsCard title="Categories" value={String(categories.length)} icon={Tags} />
<StatsCard
title="Published"
value={String(projects.filter((project) => project.isPublished).length)}
icon={CheckCircle2}
/>
<StatsCard title="Published" value={String(publishedCount)} icon={CheckCircle2} />
</div>
<div className="flex flex-wrap gap-2">
@@ -76,59 +98,134 @@ export async function PortfolioProjectsOverview({
variant={selectedCategory === category.id ? "default" : "outline"}
>
<Link href={`${getAdminAppPath("/portfolio")}?category=${category.id}`}>
{category.name.de || category.name.en || category.name.ar}
{categoryLabel(category.name)}
</Link>
</Button>
))}
</div>
<div className="grid gap-4 xl:grid-cols-2">
{projects.map((project, index) => (
<MotionFade key={project.id} delay={0.06 + index * 0.03}>
<AppCard interactive layer="single" className="h-full">
<CardContent className="flex flex-col gap-4 p-5 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<p className="text-xl font-semibold text-foreground">
{getLocalizedValue(project.title, "de") || copy.untitled}
{projects.length === 0 ? (
<AppCard level={3} padding="lg">
<div className="flex flex-col items-center gap-4 py-12 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-pill border border-border/70 bg-surface-2 text-muted-foreground">
<FolderKanban className="h-6 w-6" />
</div>
<div className="space-y-1">
<p className="text-base font-semibold text-foreground">
{isFiltered ? copy.emptyFiltered : copy.empty}
</p>
<Badge variant={project.isPublished ? "success" : "warning"}>
{project.isPublished ? "Published" : "Draft"}
</Badge>
<Badge variant="outline">{project.viewMode}</Badge>
<p className="text-sm text-muted-foreground">{copy.emptyHint}</p>
</div>
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
<span>{project.category.name.de || project.category.name.en || project.category.name.ar}</span>
<span>{project.projectYear}</span>
</div>
</div>
<div className="flex flex-wrap gap-3">
{isFiltered ? (
<Button asChild variant="outline">
<Link href={getAdminAppPath("/portfolio")}>{copy.all}</Link>
</Button>
) : (
<Button asChild>
<Link href={getAdminAppPath("/portfolio/projects/new")}>
<Plus className="h-4 w-4" />
{copy.newProject}
</Link>
</Button>
)}
</div>
</AppCard>
) : (
<Card className="overflow-hidden">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[72px]">
<span className="sr-only">Cover</span>
</TableHead>
<TableHead>{copy.colProject}</TableHead>
<TableHead className="hidden md:table-cell">{copy.colCategory}</TableHead>
<TableHead className="hidden w-[80px] sm:table-cell">{copy.colYear}</TableHead>
<TableHead className="w-[130px]">{copy.colStatus}</TableHead>
<TableHead className="hidden w-[120px] lg:table-cell">{copy.colMode}</TableHead>
<TableHead className="w-[96px] text-right">{copy.colActions}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{projects.map((project) => {
const title = getLocalizedValue(project.title, "de") || copy.untitled;
return (
<TableRow key={project.id}>
<TableCell>
<div className="flex h-11 w-14 items-center justify-center overflow-hidden rounded-nested border border-border/70 bg-surface-2 text-muted-foreground">
{project.coverImagePath ? (
<img
src={project.coverImagePath}
alt={title}
className="h-full w-full object-cover"
/>
) : (
<ImageOff className="h-4 w-4" />
)}
</div>
</TableCell>
<TableCell>
<div className="flex min-w-0 flex-col">
<span className="inline-flex items-center gap-1.5 font-medium text-foreground">
<span className="truncate">{title}</span>
{project.isFeatured ? (
<Star
className="h-3.5 w-3.5 shrink-0 text-brand-primary"
fill="currentColor"
aria-label={copy.featured}
/>
) : null}
</span>
<span className="truncate text-xs text-muted-foreground">/{project.slug}</span>
</div>
</TableCell>
<TableCell className="hidden text-sm text-muted-foreground md:table-cell">
{categoryLabel(project.category.name)}
</TableCell>
<TableCell className="hidden text-sm text-muted-foreground sm:table-cell">
{project.projectYear}
</TableCell>
<TableCell>
<Badge variant={project.isPublished ? "success" : "warning"}>
{project.isPublished ? copy.published : copy.draft}
</Badge>
</TableCell>
<TableCell className="hidden lg:table-cell">
<Badge variant="outline">{project.viewMode}</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1.5">
<Button asChild variant="ghost" size="icon" title={copy.view}>
<Link
href={getLocalizedPath("de", `/portfolio/${project.slug}`, siteSettings.defaultLocale)}
href={getLocalizedPath(
"de",
`/portfolio/${project.slug}`,
siteSettings.defaultLocale,
)}
target="_blank"
rel="noreferrer"
>
<ExternalLink className="h-4 w-4" />
{copy.openProject}
<span className="sr-only">{copy.view}</span>
</Link>
</Button>
<PortfolioProjectActions projectId={project.id} />
</div>
</CardContent>
</AppCard>
</MotionFade>
))}
{projects.length === 0 ? (
<AppCard layer="single" className="xl:col-span-2">
<CardContent className="p-6 text-sm text-muted-foreground">
{copy.empty}
</CardContent>
</AppCard>
) : null}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Card>
)}
</div>
);
}
+9 -9
View File
@@ -270,7 +270,7 @@ function SiteSettingsMediaRow({
return (
<>
<AppCard level={2} layer="single" padding="sm">
<AppCard level={2} padding="sm">
<div className="grid gap-4 lg:grid-cols-[180px_minmax(0,1fr)]">
<div className="space-y-2">
<p className="text-sm font-semibold text-foreground">{title}</p>
@@ -393,7 +393,7 @@ function LocalizedFieldsSection({
<div className="grid gap-5 lg:grid-cols-3">
{localeFields.map((locale) => (
<AppCard key={locale.key} level={2} layer="single" padding="sm" className="space-y-4">
<AppCard key={locale.key} level={2} padding="sm" contentClassName="space-y-4">
<p className="text-sm font-semibold text-foreground">{locale.label}</p>
<div className="space-y-2">
@@ -558,7 +558,7 @@ export function SiteSettingsForm({
</p>
</div>
<AppCard level={2} layer="single" padding="sm" className="space-y-4">
<AppCard level={2} padding="sm" contentClassName="space-y-4">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
<div className="space-y-2">
<Label htmlFor="primaryColor" className="sr-only">Primary Color</Label>
@@ -679,7 +679,7 @@ export function SiteSettingsForm({
</p>
</div>
<AppCard level={2} layer="single" padding="sm" className="space-y-4">
<AppCard level={2} padding="sm" contentClassName="space-y-4">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="space-y-2">
<Label htmlFor="defaultLocaleTrigger" className="sr-only">Default Locale</Label>
@@ -729,7 +729,7 @@ export function SiteSettingsForm({
<section className="space-y-4">
{mode === "brand" ? (
<>
<AppCard level={2} layer="single" padding="sm" className="space-y-2">
<AppCard level={2} padding="sm" contentClassName="space-y-2">
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Search Result</p>
<div className="rounded-nested border border-border/70 bg-background p-3">
<div className="flex items-center gap-3">
@@ -750,7 +750,7 @@ export function SiteSettingsForm({
</div>
</AppCard>
<AppCard level={2} layer="single" padding="sm" className="space-y-2">
<AppCard level={2} padding="sm" contentClassName="space-y-2">
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Brand Assets</p>
<div className="grid gap-3 sm:grid-cols-2">
<AppCard level={1} layer="single" padding="sm">
@@ -773,7 +773,7 @@ export function SiteSettingsForm({
</div>
</AppCard>
<AppCard level={2} layer="single" padding="sm" className="space-y-2">
<AppCard level={2} padding="sm" contentClassName="space-y-2">
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Social Preview</p>
<div className="overflow-hidden rounded-nested border border-border/70 bg-background">
<div className="h-1.5" style={{ backgroundColor: settings.brand.primaryColor }} />
@@ -797,7 +797,7 @@ export function SiteSettingsForm({
</>
) : (
<>
<AppCard level={2} layer="single" padding="sm" className="space-y-2">
<AppCard level={2} padding="sm" contentClassName="space-y-2">
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Visitor Routing</p>
<div className="rounded-nested border border-border/70 bg-background p-3">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
@@ -810,7 +810,7 @@ export function SiteSettingsForm({
</div>
</AppCard>
<AppCard level={2} layer="single" padding="sm" className="space-y-2">
<AppCard level={2} padding="sm" contentClassName="space-y-2">
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Locale Summary</p>
<div className="rounded-nested border border-border/70 bg-background">
<Table>
+2 -2
View File
@@ -25,7 +25,7 @@ export function SMTPSettingsForm({
return (
<form id="smtp-settings-form" action={action} className="space-y-6">
<div className="grid gap-6 xl:grid-cols-2">
<AppCard layer="single">
<AppCard>
<CardHeader>
<CardTitle>SMTP Connection</CardTitle>
<CardDescription>Server und Login.</CardDescription>
@@ -109,7 +109,7 @@ export function SMTPSettingsForm({
</CardContent>
</AppCard>
<AppCard layer="single">
<AppCard>
<CardHeader>
<CardTitle>Sender And Recipients</CardTitle>
<CardDescription>Absender und Ziele.</CardDescription>
+3 -3
View File
@@ -17,7 +17,7 @@ export function WorkspaceHero({
aside?: ReactNode;
}) {
return (
<AppCard level={3} layer="single">
<AppCard level={3}>
<CardContent className="flex flex-col gap-4 p-6 lg:flex-row lg:items-end lg:justify-between lg:p-8">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
@@ -40,7 +40,7 @@ export function WorkspaceSidebarPanel({
children: ReactNode;
}) {
return (
<AppCard level={2} layer="single">
<AppCard level={2}>
<CardHeader className="pb-4">
<CardTitle className="text-base">{title}</CardTitle>
</CardHeader>
@@ -100,7 +100,7 @@ export function WorkspaceLocaleCard({
children: ReactNode;
}) {
return (
<AppCard level={2} layer="single">
<AppCard level={2}>
<CardHeader className="pb-4">
<CardTitle className="text-lg">{title}</CardTitle>
<CardDescription>{hint}</CardDescription>
+321 -315
View File
@@ -5,10 +5,10 @@ import {
Tag,
UserRound,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { MotionFade } from "@/components/motion-fade";
import { PortfolioCover } from "@/components/site/portfolio-cover";
import { AppCard } from "@/components/ui/app-card";
import { Button } from "@/components/ui/button";
import { CardContent } from "@/components/ui/card";
@@ -16,7 +16,6 @@ import { getLocalizedPath, type AppLocale } from "@/lib/locale";
import {
getLocalizedValue,
resolvePortfolioProjectViewMode,
type PortfolioAssetView,
type PortfolioProjectView,
type PortfolioSectionView,
} from "@/lib/portfolio";
@@ -28,27 +27,76 @@ type PortfolioProjectDetailProps = {
t: (key: "back" | "preview" | "openLink" | "gallery" | "download") => string;
};
function PortfolioImage({
src,
alt,
className,
width,
height,
type ProjectImage = {
key: string;
src: string | null;
label: string;
};
/**
* All image sources for a project, in reading order: cover, gallery-section
* images, then gallery assets. `src` may be null — PortfolioCover then renders
* the branded placeholder, so layouts look intentional even without artwork.
*/
function collectImages(item: PortfolioProjectView, locale: AppLocale): ProjectImage[] {
const images: ProjectImage[] = [];
const coverTitle = getLocalizedValue(item.title, locale);
images.push({ key: "cover", src: item.coverImagePath ?? null, label: coverTitle });
for (const section of item.sections) {
if (section.type === "GALLERY") {
images.push({
key: `section-${section.id}`,
src: section.imagePath ?? null,
label: getLocalizedValue(section.title, locale),
});
}
}
for (const asset of item.assets) {
if (asset.kind === "IMAGE") {
images.push({
key: `asset-${asset.id}`,
src: asset.filePath,
label: getLocalizedValue(asset.alt, locale),
});
}
}
return images;
}
/** Text-bearing sections (everything that is not a gallery image). */
function textSections(item: PortfolioProjectView): PortfolioSectionView[] {
return item.sections.filter((section) => section.type !== "GALLERY");
}
function MediaFrame({
image,
aspect = "aspect-[16/10]",
chrome = false,
priority = false,
}: {
src: string;
alt: string;
className: string;
width: number;
height: number;
image: ProjectImage;
aspect?: string;
chrome?: boolean;
priority?: boolean;
}) {
return (
<Image
src={src}
alt={alt}
width={width}
height={height}
className={className}
/>
<figure className="overflow-hidden rounded-surface border border-border bg-surface-2 shadow-card">
{chrome ? (
<div className="flex items-center gap-2 border-b border-border bg-surface-3 px-4 py-3">
<span className="h-2.5 w-2.5 rounded-full bg-border-strong" />
<span className="h-2.5 w-2.5 rounded-full bg-border-strong" />
<span className="h-2.5 w-2.5 rounded-full bg-border-strong" />
<span className="ms-3 h-5 flex-1 rounded-md bg-surface-1" />
</div>
) : null}
<div className={`group relative ${aspect}`}>
<PortfolioCover src={image.src} title={image.label} priority={priority} />
</div>
</figure>
);
}
@@ -60,22 +108,10 @@ function ProjectMeta({
locale: AppLocale;
}) {
const metadata = [
{
icon: Tag,
label: getLocalizedValue(item.category.name, locale),
},
{
icon: CalendarDays,
label: String(item.projectYear),
},
{
icon: FolderKanban,
label: getLocalizedValue(item.serviceLabel, locale),
},
{
icon: UserRound,
label: item.clientName,
},
{ icon: Tag, label: getLocalizedValue(item.category.name, locale) },
{ icon: CalendarDays, label: String(item.projectYear) },
{ icon: FolderKanban, label: getLocalizedValue(item.serviceLabel, locale) },
{ icon: UserRound, label: item.clientName },
].filter((entry) => entry.label);
return (
@@ -84,19 +120,111 @@ function ProjectMeta({
const Icon = meta.icon;
return (
<AppCard key={`${meta.label}-${Icon.name}`} level={2}>
<CardContent className="flex items-center gap-2 p-3">
<span
key={`${meta.label}-${Icon.name}`}
className="inline-flex items-center gap-2 rounded-pill border border-border/70 bg-surface-2 px-3.5 py-1.5"
>
<Icon className="h-4 w-4 text-brand-primary" />
{meta.label}
</CardContent>
</AppCard>
</span>
);
})}
</div>
);
}
function SectionBlock({
function BackLink({
locale,
defaultLocale,
t,
}: {
locale: AppLocale;
defaultLocale: AppLocale;
t: PortfolioProjectDetailProps["t"];
}) {
return (
<Button asChild variant="ghost" className="h-auto px-0 py-0 text-sm text-muted-foreground">
<Link href={getLocalizedPath(locale, "/portfolio", defaultLocale)}>{t("back")}</Link>
</Button>
);
}
function PreviewButton({
item,
t,
full = false,
}: {
item: PortfolioProjectView;
t: PortfolioProjectDetailProps["t"];
full?: boolean;
}) {
if (!item.previewUrl) {
return null;
}
return (
<Button asChild className={full ? "w-full justify-center" : undefined}>
<Link href={item.previewUrl} target="_blank" rel="noreferrer">
{t("preview")}
<ArrowUpRight className="h-4 w-4" />
</Link>
</Button>
);
}
function ProjectIntro({ item, locale }: { item: PortfolioProjectView; locale: AppLocale }) {
return (
<div className="space-y-4">
<p className="text-overline uppercase tracking-[0.18em] text-brand-primary">
{getLocalizedValue(item.category.name, locale)} · {item.projectYear}
</p>
<h1 className="text-h1 text-foreground">{getLocalizedValue(item.title, locale)}</h1>
<p className="max-w-2xl text-body-lg text-muted-foreground">
{getLocalizedValue(item.summary, locale)}
</p>
</div>
);
}
function InfoPanel({
item,
locale,
t,
}: {
item: PortfolioProjectView;
locale: AppLocale;
t: PortfolioProjectDetailProps["t"];
}) {
const rows = [
{ label: "Client", value: item.clientName },
{ label: "Year", value: String(item.projectYear) },
{ label: "Service", value: getLocalizedValue(item.serviceLabel, locale) },
{ label: "Category", value: getLocalizedValue(item.category.name, locale) },
].filter((row) => row.value);
return (
<AppCard level={2} className="lg:sticky lg:top-24">
<CardContent className="space-y-5 p-6">
<dl className="space-y-0">
{rows.map((row, index) => (
<div
key={row.label}
className={`flex items-center justify-between gap-4 py-2.5 text-sm ${
index < rows.length - 1 ? "border-b border-dashed border-border" : ""
}`}
>
<dt className="text-muted-foreground">{row.label}</dt>
<dd className="font-semibold text-foreground">{row.value}</dd>
</div>
))}
</dl>
<PreviewButton item={item} t={t} full />
</CardContent>
</AppCard>
);
}
function TextSection({
section,
locale,
t,
@@ -108,29 +236,13 @@ function SectionBlock({
const title = getLocalizedValue(section.title, locale);
const body = getLocalizedValue(section.body, locale);
if (section.type === "GALLERY") {
return (
<div className="space-y-4">
<h3 className="text-xl font-semibold text-foreground">{title}</h3>
{section.imagePath ? (
<PortfolioImage
src={section.imagePath}
alt={title}
width={1400}
height={880}
className="h-72 w-full rounded-surface object-cover"
/>
<div className="space-y-3">
<h3 className="text-h3 text-foreground">{title}</h3>
{body ? (
<p className="whitespace-pre-line text-body leading-7 text-muted-foreground">{body}</p>
) : null}
</div>
);
}
if (section.type === "LINK") {
return (
<div className="space-y-4">
<h3 className="text-xl font-semibold text-foreground">{title}</h3>
{body ? <p className="whitespace-pre-line text-sm leading-7 text-muted-foreground">{body}</p> : null}
{section.linkUrl ? (
{section.type === "LINK" && section.linkUrl ? (
<Button asChild variant="outline">
<Link href={section.linkUrl} target="_blank" rel="noreferrer">
{t("openLink")}
@@ -140,294 +252,188 @@ function SectionBlock({
) : null}
</div>
);
}
return (
<div className="space-y-4">
<h3 className="text-xl font-semibold text-foreground">{title}</h3>
<p className="whitespace-pre-line text-sm leading-7 text-muted-foreground">{body}</p>
</div>
);
}
function AssetGallery({
assets,
locale,
t,
}: {
assets: PortfolioAssetView[];
locale: AppLocale;
t: PortfolioProjectDetailProps["t"];
}) {
if (assets.length === 0) {
return null;
}
/* ============================================================
WEB (viewMode: CASE_STUDY)
Two columns: stacked full screenshots + sticky info panel.
============================================================ */
function WebTemplate({ item, locale, defaultLocale, t }: PortfolioProjectDetailProps) {
const images = collectImages(item, locale);
const texts = textSections(item);
return (
<MotionFade delay={0.12}>
<AppCard>
<CardContent className="p-6 lg:p-8">
<div className="flex items-end justify-between gap-4">
<div>
<p className="text-sm uppercase tracking-[0.18em] text-muted-foreground">Assets</p>
<h2 className="mt-2 text-2xl font-semibold text-foreground">{t("gallery")}</h2>
</div>
<BadgeCount count={assets.length} />
</div>
<div className="mt-6 grid gap-4 md:grid-cols-2">
{assets.map((asset) => (
<div key={asset.id} className="overflow-hidden rounded-surface border border-border bg-card">
{asset.kind === "IMAGE" ? (
<PortfolioImage
src={asset.filePath}
alt={getLocalizedValue(asset.alt, locale)}
width={1200}
height={760}
className="h-72 w-full object-cover"
/>
) : (
<div className="flex min-h-72 items-center justify-center bg-muted/30 p-6 text-center">
<div className="space-y-3">
<p className="text-sm font-medium text-foreground">
{getLocalizedValue(asset.alt, locale)}
</p>
<Button asChild variant="outline">
<Link href={asset.filePath} target="_blank" rel="noreferrer">
{t("download")}
</Link>
</Button>
</div>
</div>
)}
</div>
))}
</div>
</CardContent>
</AppCard>
</MotionFade>
);
}
function BadgeCount({ count }: { count: number }) {
return (
<div className="rounded-pill border border-border/70 bg-background px-4 py-2 text-sm text-muted-foreground">
{count} {count === 1 ? "file" : "files"}
</div>
);
}
function ProjectHeader({
item,
locale,
defaultLocale,
t,
}: {
item: PortfolioProjectView;
locale: AppLocale;
defaultLocale: AppLocale;
t: PortfolioProjectDetailProps["t"];
}) {
return (
<div className="space-y-8">
<MotionFade>
<AppCard level={3}>
<CardContent className="p-6 lg:p-10">
<Button asChild variant="ghost" className="h-auto px-0 py-0 text-sm">
<Link href={getLocalizedPath(locale, "/portfolio", defaultLocale)}>{t("back")}</Link>
</Button>
<div className="mt-6">
<ProjectMeta item={item} locale={locale} />
</div>
{item.previewUrl ? (
<div className="mt-8">
<Button asChild>
<Link href={item.previewUrl} target="_blank" rel="noreferrer">
{t("preview")}
<ArrowUpRight className="h-4 w-4" />
</Link>
</Button>
</div>
) : null}
</CardContent>
</AppCard>
</MotionFade>
);
}
function GridTemplate({
item,
locale,
defaultLocale,
t,
}: PortfolioProjectDetailProps) {
return (
<div className="space-y-6">
<ProjectHeader item={item} locale={locale} defaultLocale={defaultLocale} t={t} />
{item.coverImagePath ? (
<MotionFade delay={0.04}>
<AppCard>
<CardContent className="p-3 lg:p-4">
<PortfolioImage
src={item.coverImagePath}
alt={getLocalizedValue(item.title, locale)}
width={1800}
height={1000}
className="h-auto w-full rounded-surface object-cover"
/>
</CardContent>
</AppCard>
<BackLink locale={locale} defaultLocale={defaultLocale} t={t} />
<ProjectIntro item={item} locale={locale} />
</div>
</MotionFade>
) : null}
<div className="grid gap-4 lg:grid-cols-2">
{item.sections.map((section, index) => (
<MotionFade key={section.id} delay={0.06 * (index + 1)}>
<AppCard>
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_340px] lg:items-start">
<div className="order-2 space-y-6 lg:order-1">
{images.map((image, index) => (
<MotionFade key={image.key} delay={0.04 * index}>
<MediaFrame image={image} chrome aspect="aspect-[16/11]" priority={index === 0} />
</MotionFade>
))}
{texts.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2">
{texts.map((section) => (
<AppCard key={section.id}>
<CardContent className="p-5 lg:p-6">
<SectionBlock section={section} locale={locale} t={t} />
<TextSection section={section} locale={locale} t={t} />
</CardContent>
</AppCard>
</MotionFade>
))}
</div>
<AssetGallery assets={item.assets} locale={locale} t={t} />
</div>
);
}
function StoryTemplate({
item,
locale,
defaultLocale,
t,
}: PortfolioProjectDetailProps) {
return (
<div className="space-y-8">
<ProjectHeader item={item} locale={locale} defaultLocale={defaultLocale} t={t} />
{item.coverImagePath ? (
<MotionFade delay={0.04}>
<div className="overflow-hidden rounded-[32px] border border-border/70 bg-card p-3 shadow-card">
<PortfolioImage
src={item.coverImagePath}
alt={getLocalizedValue(item.title, locale)}
width={1800}
height={1100}
className="h-[440px] w-full rounded-[28px] object-cover"
/>
</div>
</MotionFade>
) : null}
<div className="space-y-4">
{item.sections.map((section, index) => (
<MotionFade key={section.id} delay={0.06 * (index + 1)}>
<div className="grid gap-4 lg:grid-cols-[120px_minmax(0,1fr)]">
<div className="pt-4">
<div className="inline-flex rounded-pill border border-border/70 bg-background px-4 py-2 text-xs uppercase tracking-[0.2em] text-muted-foreground">
{String(index + 1).padStart(2, "0")}
</div>
</div>
<AppCard className="overflow-hidden">
<CardContent className="p-6 lg:p-8">
<SectionBlock section={section} locale={locale} t={t} />
</CardContent>
</AppCard>
</div>
</MotionFade>
))}
</div>
<AssetGallery assets={item.assets} locale={locale} t={t} />
<aside className="order-1 lg:order-2">
<InfoPanel item={item} locale={locale} t={t} />
</aside>
</div>
</div>
);
}
function CaseStudyTemplate({
item,
locale,
defaultLocale,
t,
}: PortfolioProjectDetailProps) {
const [challenge, solution, outcome, ...restSections] = item.sections;
const leadSections = [challenge, solution, outcome].filter(
(section): section is PortfolioSectionView => Boolean(section),
);
/* ============================================================
PRINT (viewMode: GRID)
Gallery: mixed-size grid of images (logos, posters, ...).
============================================================ */
function PrintTemplate({ item, locale, defaultLocale, t }: PortfolioProjectDetailProps) {
const images = collectImages(item, locale);
const texts = textSections(item);
return (
<div className="space-y-8">
<ProjectHeader item={item} locale={locale} defaultLocale={defaultLocale} t={t} />
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_420px]">
<div className="space-y-10">
<MotionFade>
<div className="space-y-6">
{item.coverImagePath ? (
<MotionFade delay={0.04}>
<AppCard className="overflow-hidden">
<CardContent className="p-3">
<PortfolioImage
src={item.coverImagePath}
alt={getLocalizedValue(item.title, locale)}
width={1800}
height={1100}
className="h-auto w-full rounded-surface object-cover"
/>
<BackLink locale={locale} defaultLocale={defaultLocale} t={t} />
<ProjectIntro item={item} locale={locale} />
<div className="flex flex-wrap items-center gap-3">
<ProjectMeta item={item} locale={locale} />
<PreviewButton item={item} t={t} />
</div>
</div>
</MotionFade>
<MotionFade delay={0.06}>
<div className="grid auto-rows-[minmax(0,1fr)] grid-cols-2 gap-4 md:grid-cols-3">
{images.map((image, index) => {
// First image spans a large feature tile; the rest alternate size.
const span =
index === 0
? "col-span-2 row-span-2"
: index % 4 === 0
? "col-span-2"
: "";
const aspect = index === 0 ? "aspect-square" : "aspect-[4/3]";
return (
<div key={image.key} className={span}>
<MediaFrame image={image} aspect={index === 0 ? "aspect-square" : aspect} priority={index === 0} />
</div>
);
})}
</div>
</MotionFade>
{texts.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2">
{texts.map((section) => (
<MotionFade key={section.id} delay={0.04}>
<AppCard>
<CardContent className="p-6">
<TextSection section={section} locale={locale} t={t} />
</CardContent>
</AppCard>
</MotionFade>
))}
</div>
) : null}
</div>
);
}
{leadSections.map((section, index) => (
<MotionFade key={section.id} delay={0.06 * (index + 1)}>
<AppCard level={index === 1 ? 3 : 1}>
<CardContent className="space-y-4 p-6 lg:p-8">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
{index === 0 ? "Challenge" : index === 1 ? "Solution" : "Outcome"}
/* ============================================================
EDITORIAL (viewMode: STORY)
Full-bleed hero + alternating text / media sections.
============================================================ */
function EditorialTemplate({ item, locale, defaultLocale, t }: PortfolioProjectDetailProps) {
const cover = collectImages(item, locale)[0]!;
const sections = item.sections;
const galleryImages = collectImages(item, locale).slice(1);
return (
<div className="space-y-6">
<MotionFade>
<BackLink locale={locale} defaultLocale={defaultLocale} t={t} />
</MotionFade>
<MotionFade delay={0.04}>
<div className="relative flex min-h-[420px] items-end overflow-hidden rounded-surface border border-border">
<div className="absolute inset-0">
<PortfolioCover src={cover.src} title={cover.label} priority sizes="100vw" />
</div>
<div className="absolute inset-0 bg-gradient-to-t from-background/90 via-background/40 to-transparent" />
<div className="relative z-10 max-w-3xl space-y-4 p-8 lg:p-12">
<p className="text-overline uppercase tracking-[0.18em] text-brand-primary">
{getLocalizedValue(item.category.name, locale)} · {item.projectYear}
</p>
<SectionBlock section={section} locale={locale} t={t} />
</CardContent>
</AppCard>
<h1 className="text-display text-foreground">{getLocalizedValue(item.title, locale)}</h1>
</div>
</div>
</MotionFade>
))}
<div className="flex flex-wrap items-center gap-3">
<ProjectMeta item={item} locale={locale} />
<PreviewButton item={item} t={t} />
</div>
<div className="space-y-6">
<MotionFade delay={0.08}>
<AppCard level={2} className="sticky top-24">
<CardContent className="space-y-5 p-6">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Case Study Snapshot</p>
<h2 className="mt-2 text-2xl font-semibold text-foreground">
{getLocalizedValue(item.title, locale)}
</h2>
</div>
<p className="text-sm leading-7 text-muted-foreground">
<MotionFade delay={0.06}>
<p className="max-w-3xl py-6 text-h3 font-medium leading-snug text-foreground/90">
{getLocalizedValue(item.summary, locale)}
</p>
<ProjectMeta item={item} locale={locale} />
</CardContent>
</AppCard>
</MotionFade>
</div>
</div>
{restSections.length > 0 ? (
<div className="grid gap-4 lg:grid-cols-2">
{restSections.map((section, index) => (
<MotionFade key={section.id} delay={0.1 + index * 0.04}>
<AppCard>
<CardContent className="p-5 lg:p-6">
<SectionBlock section={section} locale={locale} t={t} />
</CardContent>
</AppCard>
<div className="space-y-16 py-4">
{sections.map((section, index) => {
if (section.type === "GALLERY") {
return (
<MotionFade key={section.id} delay={0.04}>
<MediaFrame
image={{
key: section.id,
src: section.imagePath ?? null,
label: getLocalizedValue(section.title, locale),
}}
aspect="aspect-[16/8]"
/>
</MotionFade>
))}
</div>
) : null}
);
}
<AssetGallery assets={item.assets} locale={locale} t={t} />
return (
<MotionFade key={section.id} delay={0.04}>
<div
className={`grid items-center gap-8 lg:grid-cols-2 ${
index % 2 === 1 ? "lg:[&>*:first-child]:order-2" : ""
}`}
>
<TextSection section={section} locale={locale} t={t} />
<MediaFrame
image={galleryImages[index % Math.max(galleryImages.length, 1)] ?? cover}
aspect="aspect-[4/3]"
/>
</div>
</MotionFade>
);
})}
</div>
</div>
);
}
@@ -441,12 +447,12 @@ export function PortfolioProjectDetail({
const viewMode = resolvePortfolioProjectViewMode(item.viewMode);
if (viewMode === "STORY") {
return <StoryTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
return <EditorialTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
}
if (viewMode === "CASE_STUDY") {
return <CaseStudyTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
return <WebTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
}
return <GridTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
return <PrintTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
}
+14 -1
View File
@@ -87,10 +87,21 @@ export interface AppCardProps
VariantProps<typeof appCardVariants>,
VariantProps<typeof appCardInnerVariants> {
layer?: "double" | "single";
/**
* Classes applied to the element that directly wraps `children` (the inner
* shell in `double` layer, the single shell in `single` layer). Use it for
* content utilities such as `space-y-*` / `grid` / `flex` so they keep
* working after switching a card to the layered (double) look, where
* `className` lands on the outer shell instead.
*/
contentClassName?: string;
}
const AppCard = React.forwardRef<HTMLDivElement, AppCardProps>(
({ className, level, padding, interactive, layer = "double", children, ...props }, ref) => {
(
{ className, contentClassName, level, padding, interactive, layer = "double", children, ...props },
ref,
) => {
if (layer === "single") {
return (
<Card
@@ -102,6 +113,7 @@ const AppCard = React.forwardRef<HTMLDivElement, AppCardProps>(
interactive,
}),
className,
contentClassName,
)}
{...props}
>
@@ -129,6 +141,7 @@ const AppCard = React.forwardRef<HTMLDivElement, AppCardProps>(
padding,
interactive,
}),
contentClassName,
)}
>
{children}
+7 -1
View File
@@ -98,22 +98,28 @@ function TabsTrigger({
type TabsContentProps = React.HTMLAttributes<HTMLDivElement> & {
value: string;
// Keep the panel mounted while inactive (hidden via `hidden`) so its form
// fields still submit. Use for tabbed forms that share one submit button.
forceMount?: boolean;
};
function TabsContent({
className,
value,
children,
forceMount = false,
...props
}: TabsContentProps) {
const { value: activeValue } = useTabsContext();
const isActive = activeValue === value;
if (activeValue !== value) {
if (!isActive && !forceMount) {
return null;
}
return (
<div
hidden={!isActive}
className={cn(
"mt-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0",
className,
+10
View File
@@ -1,5 +1,15 @@
import { existsSync } from "node:fs";
import { defineConfig } from "drizzle-kit";
// drizzle-kit (unlike the Next.js app) does not load .env automatically, so
// DATABASE_URL would be undefined and fall back to the wrong host. Load .env
// for local dev. In production the env is already provided (docker-compose)
// and no .env file exists, so this is skipped.
if (!process.env.DATABASE_URL && existsSync(".env")) {
process.loadEnvFile(".env");
}
export default defineConfig({
schema: "./lib/db/schema.ts",
out: "./lib/db/migrations",
+12 -4
View File
@@ -136,13 +136,21 @@ export function getPortfolioWizardProgress(
},
{
key: "sections",
complete: input.sections.length > 0 && completedSections === input.sections.length,
summary: `${completedSections}/${input.sections.length} sections ready.`,
// Optional: no sections is valid. If sections were added, each must be ready.
complete: completedSections === input.sections.length,
summary:
input.sections.length === 0
? "Optional — no sections added."
: `${completedSections}/${input.sections.length} sections ready.`,
},
{
key: "assets",
complete: input.assets.length > 0 && completedAssets === input.assets.length,
summary: `${completedAssets}/${input.assets.length} assets ready.`,
// Optional: no gallery assets is valid. If assets were added, each must be ready.
complete: completedAssets === input.assets.length,
summary:
input.assets.length === 0
? "Optional — no gallery assets added."
: `${completedAssets}/${input.assets.length} assets ready.`,
},
];
}
+18
View File
@@ -123,3 +123,21 @@ export const projectInputSchema = z.object({
sections: z.array(sectionInputSchema),
assets: z.array(assetInputSchema),
});
/**
* Draft variant: the user-facing copy fields are optional so an unfinished
* project can be saved and completed later. The action still guarantees a
* slug, a category, and a year (auto-filled), and forces the project unpublished.
*/
export const projectDraftInputSchema = projectInputSchema.extend({
titleAr: optionalTrimmedText,
titleEn: optionalTrimmedText,
titleDe: optionalTrimmedText,
summaryAr: optionalTrimmedText,
summaryEn: optionalTrimmedText,
summaryDe: optionalTrimmedText,
serviceLabelAr: optionalTrimmedText,
serviceLabelEn: optionalTrimmedText,
serviceLabelDe: optionalTrimmedText,
clientName: optionalTrimmedText,
});
+59
View File
@@ -9,6 +9,7 @@ import {
import {
assetInputSchema,
categoryInputSchema,
projectDraftInputSchema,
projectInputSchema,
sectionInputSchema,
} from "../lib/portfolio-validation";
@@ -65,6 +66,64 @@ describe("portfolio validation", () => {
).toThrow(/slug/i);
});
it("draft schema accepts empty user-facing copy that a strict save rejects", () => {
const draftPayload = {
categoryId: "cat_1",
slug: "draft-123",
viewMode: "GRID" as const,
titleAr: "",
titleEn: "",
titleDe: "",
summaryAr: "",
summaryEn: "",
summaryDe: "",
clientName: "",
projectYear: 2026,
serviceLabelAr: "",
serviceLabelEn: "",
serviceLabelDe: "",
previewUrl: "",
currentCoverImagePath: "",
sortOrder: 0,
isFeatured: false,
isPublished: false,
sections: [],
assets: [],
};
expect(projectDraftInputSchema.parse(draftPayload).slug).toBe("draft-123");
expect(() => projectInputSchema.parse(draftPayload)).toThrow();
});
it("draft schema still requires a slug and a valid year", () => {
const base = {
categoryId: "cat_1",
slug: "draft-1",
viewMode: "GRID" as const,
titleAr: "",
titleEn: "",
titleDe: "",
summaryAr: "",
summaryEn: "",
summaryDe: "",
clientName: "",
projectYear: 2026,
serviceLabelAr: "",
serviceLabelEn: "",
serviceLabelDe: "",
previewUrl: "",
currentCoverImagePath: "",
sortOrder: 0,
isFeatured: false,
isPublished: false,
sections: [],
assets: [],
};
expect(() => projectDraftInputSchema.parse({ ...base, slug: "" })).toThrow(/slug/i);
expect(() => projectDraftInputSchema.parse({ ...base, projectYear: 1999 })).toThrow();
});
it("accepts valid section and asset payloads", () => {
expect(
sectionInputSchema.parse({
+22 -3
View File
@@ -117,10 +117,29 @@ describe("getPortfolioWizardProgress", () => {
expect(getFirstIncompleteWizardStep(progress)).toBe("content");
});
it("sections/assets steps require at least one ready entry", () => {
it("treats sections/assets as optional: empty steps are complete and the project is saveable", () => {
const noEntries = getPortfolioWizardProgress({ ...completeInput, sections: [], assets: [] });
expect(noEntries.find((s) => s.key === "sections")?.complete).toBe(false);
expect(noEntries.find((s) => s.key === "assets")?.complete).toBe(false);
expect(noEntries.find((s) => s.key === "sections")?.complete).toBe(true);
expect(noEntries.find((s) => s.key === "assets")?.complete).toBe(true);
// Basics + content only is enough to save.
expect(getFirstIncompleteWizardStep(noEntries)).toBeNull();
});
it("shows an optional summary when no sections/assets are added", () => {
const noEntries = getPortfolioWizardProgress({ ...completeInput, sections: [], assets: [] });
expect(noEntries.find((s) => s.key === "sections")?.summary).toBe("Optional — no sections added.");
expect(noEntries.find((s) => s.key === "assets")?.summary).toBe(
"Optional — no gallery assets added.",
);
});
it("blocks saving when an added section is incomplete", () => {
const progress = getPortfolioWizardProgress({
...completeInput,
sections: [section({ titleEn: "" })],
});
expect(progress.find((s) => s.key === "sections")?.complete).toBe(false);
expect(getFirstIncompleteWizardStep(progress)).toBe("sections");
});
it("summarizes section/asset readiness counts", () => {