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", "name": "dev",
"runtimeExecutable": "npm", "runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"], "runtimeArgs": ["run", "dev"],
"port": 3000 "port": 3014
} }
] ]
} }
+1 -1
View File
@@ -60,7 +60,7 @@ export default async function AdminMaintenancePage({
headerDescription={copy.subtitle} headerDescription={copy.subtitle}
> >
<MotionFade delay={0.1}> <MotionFade delay={0.1}>
<AppCard layer="single"> <AppCard>
<CardContent className="space-y-4 p-6"> <CardContent className="space-y-4 p-6">
<p className="text-sm text-muted-foreground">{copy.maintenanceText}</p> <p className="text-sm text-muted-foreground">{copy.maintenanceText}</p>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
+60 -10
View File
@@ -30,10 +30,21 @@ import { getSiteSettings } from "@/lib/app-config";
import { import {
assetInputSchema, assetInputSchema,
categoryInputSchema, categoryInputSchema,
projectDraftInputSchema,
projectInputSchema, projectInputSchema,
sectionInputSchema, sectionInputSchema,
} from "@/lib/portfolio-validation"; } 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() { async function ensureAdmin() {
if (!(await isAdminAuthenticated())) { if (!(await isAdminAuthenticated())) {
await clearAdminSessionCookie(); await clearAdminSessionCookie();
@@ -204,28 +215,67 @@ export async function saveProjectAction(formData: FormData) {
const createdMediaAssetIds: string[] = []; const createdMediaAssetIds: string[] = [];
try { 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({ sectionInputSchema.parse({
...section, ...section,
media: section.media ? mediaFieldInputSchema.parse(section.media) : undefined, media: section.media ? mediaFieldInputSchema.parse(section.media) : undefined,
sortOrder: section.sortOrder ?? index, sortOrder: section.sortOrder ?? index,
}), });
);
const assets = parseJsonArray(formData.get("assets"), "assets").map((asset, index) => const parseAsset = (asset: Record<string, unknown>, index: number) =>
assetInputSchema.parse({ assetInputSchema.parse({
...asset, ...asset,
media: asset.media ? mediaFieldInputSchema.parse(asset.media) : undefined, media: asset.media ? mediaFieldInputSchema.parse(asset.media) : undefined,
sortOrder: asset.sortOrder ?? index, 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 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, id: String(formData.get("id") ?? "").trim() || undefined,
categoryId: String(formData.get("categoryId") ?? ""), categoryId: String(formData.get("categoryId") ?? ""),
slug: String(formData.get("slug") ?? ""), slug,
viewMode: String(formData.get("viewMode") ?? "GRID"), viewMode: String(formData.get("viewMode") ?? "GRID"),
titleAr: String(formData.get("titleAr") ?? ""), titleAr: String(formData.get("titleAr") ?? ""),
titleEn: String(formData.get("titleEn") ?? ""), titleEn: String(formData.get("titleEn") ?? ""),
@@ -234,7 +284,7 @@ export async function saveProjectAction(formData: FormData) {
summaryEn: String(formData.get("summaryEn") ?? ""), summaryEn: String(formData.get("summaryEn") ?? ""),
summaryDe: String(formData.get("summaryDe") ?? ""), summaryDe: String(formData.get("summaryDe") ?? ""),
clientName: String(formData.get("clientName") ?? ""), clientName: String(formData.get("clientName") ?? ""),
projectYear: String(formData.get("projectYear") ?? ""), projectYear,
serviceLabelAr: String(formData.get("serviceLabelAr") ?? ""), serviceLabelAr: String(formData.get("serviceLabelAr") ?? ""),
serviceLabelEn: String(formData.get("serviceLabelEn") ?? ""), serviceLabelEn: String(formData.get("serviceLabelEn") ?? ""),
serviceLabelDe: String(formData.get("serviceLabelDe") ?? ""), serviceLabelDe: String(formData.get("serviceLabelDe") ?? ""),
@@ -243,7 +293,7 @@ export async function saveProjectAction(formData: FormData) {
coverMedia: coverMedia ? mediaFieldInputSchema.parse(coverMedia) : undefined, coverMedia: coverMedia ? mediaFieldInputSchema.parse(coverMedia) : undefined,
sortOrder: String(formData.get("sortOrder") ?? "0"), sortOrder: String(formData.get("sortOrder") ?? "0"),
isFeatured: normalizeCheckboxValue(formData, "isFeatured"), isFeatured: normalizeCheckboxValue(formData, "isFeatured"),
isPublished: normalizeCheckboxValue(formData, "isPublished"), isPublished: isDraft ? false : normalizeCheckboxValue(formData, "isPublished"),
sections, sections,
assets, assets,
}); });
+1 -1
View File
@@ -104,7 +104,7 @@ export default async function AdminPortfolioProjectPage({
</MotionFade> </MotionFade>
<MotionFade delay={0.2}> <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"> <CardContent className="flex flex-col gap-4 p-6 lg:flex-row lg:items-center lg:justify-between">
<div> <div>
<p className="text-sm font-medium text-foreground">{copy.dangerZone}</p> <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"> <div className="grid gap-4 xl:grid-cols-4">
{rowMeta.map((row) => ( {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> <Label htmlFor={`${row.key}-de`} className="text-sm font-semibold text-foreground">{row.label}</Label>
<Textarea <Textarea
id={`${row.key}-de`} 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 { Check, ImageIcon, Search, Trash2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { AppCard } from "@/components/ui/app-card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -17,7 +16,6 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { MediaOption } from "@/lib/media"; import type { MediaOption } from "@/lib/media";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -89,29 +87,45 @@ export function MediaFieldPicker({
}, [serializedValue]); }, [serializedValue]);
return ( 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} /> <input ref={hiddenInputRef} type="hidden" name={inputName} value={serializedValue} />
<div className="flex items-start justify-between gap-4"> <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="space-y-1"> <div className="flex min-w-0 items-center gap-3">
<Label className="text-sm font-semibold text-foreground">{title}</Label> {selectedOption ? (
<p className="text-sm text-muted-foreground"> <>
Media must be selected from the <img
{" "} src={selectedOption.url}
Media Library alt={selectedOption.label}
. className="h-14 w-14 shrink-0 rounded-nested border border-border/60 object-cover"
</p> />
<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 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>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={() => setOpen(true)}> <div className="flex shrink-0 gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
<ImageIcon className="h-4 w-4" /> <ImageIcon className="h-4 w-4" />
Select from Media {selectedOption ? "Ändern" : "Auswählen"}
</Button> </Button>
{canClear ? ( {canClear ? (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm"
className="text-destructive hover:text-destructive" className="text-destructive hover:text-destructive"
title={clearLabel}
onClick={() => onClick={() =>
onChange({ onChange({
...value, ...value,
@@ -124,32 +138,12 @@ export function MediaFieldPicker({
} }
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
{clearLabel} <span className="sr-only">{clearLabel}</span>
</Button> </Button>
) : null} ) : null}
</div> </div>
</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}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-4xl"> <DialogContent className="max-w-4xl">
<DialogHeader> <DialogHeader>
@@ -215,6 +209,6 @@ export function MediaFieldPicker({
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</AppCard> </div>
); );
} }
+1 -1
View File
@@ -486,7 +486,7 @@ export function MediaLibraryManager({
) )
) : ( ) : (
<MotionFade delay={0.18}> <MotionFade delay={0.18}>
<AppCard layer="single"> <AppCard>
<CardContent className="p-6 text-sm text-muted-foreground"> <CardContent className="p-6 text-sm text-muted-foreground">
{copy.empty} {copy.empty}
</CardContent> </CardContent>
@@ -110,7 +110,7 @@ function CategoryLocaleFields({
const descriptionKey = `description${locale.key}` as const; const descriptionKey = `description${locale.key}` as const;
return ( 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> <p className="text-sm font-medium text-foreground">{locale.label}</p>
<div className="space-y-2"> <div className="space-y-2">
@@ -168,7 +168,7 @@ function CategoryStatusFields({
</div> </div>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]"> <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"> <div className="flex items-start gap-3">
{isActive ? ( {isActive ? (
<ShieldCheck className="mt-0.5 h-4 w-4 text-status-success" /> <ShieldCheck className="mt-0.5 h-4 w-4 text-status-success" />
@@ -395,7 +395,7 @@ export function PortfolioCategoriesManager({
<DialogDescription>{copy.modalDescription}</DialogDescription> <DialogDescription>{copy.modalDescription}</DialogDescription>
</DialogHeader> </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="grid gap-3 sm:grid-cols-3">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-foreground">1. Basics</p> <p className="text-sm font-medium text-foreground">1. Basics</p>
@@ -426,7 +426,7 @@ export function PortfolioCategoriesManager({
</Dialog> </Dialog>
</div> </div>
<AppCard level={3} layer="single"> <AppCard level={3}>
<CardContent className="space-y-4 p-6"> <CardContent className="space-y-4 p-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Layers3 className="h-5 w-5 text-brand-primary" /> <Layers3 className="h-5 w-5 text-brand-primary" />
File diff suppressed because it is too large Load Diff
+152 -55
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 Link from "next/link";
import { StatsCard } from "@/components/dashboard/stats-card"; import { StatsCard } from "@/components/dashboard/stats-card";
import { MotionFade } from "@/components/motion-fade";
import { PortfolioProjectActions } from "@/components/admin/portfolio-project-actions"; import { PortfolioProjectActions } from "@/components/admin/portfolio-project-actions";
import { AppCard } from "@/components/ui/app-card"; import { AppCard } from "@/components/ui/app-card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; 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 { getAdminAppPath } from "@/lib/admin-routing";
import { getSiteSettings } from "@/lib/app-config"; import { getSiteSettings } from "@/lib/app-config";
import { getLocalizedPath } from "@/lib/locale"; import { getLocalizedPath } from "@/lib/locale";
@@ -24,17 +33,34 @@ const copy = {
all: "Alle", all: "Alle",
newProject: "Neues Projekt", newProject: "Neues Projekt",
newCategory: "Neues Kategorie", newCategory: "Neues Kategorie",
openProject: "Ansehen", view: "Auf der Website ansehen",
untitled: "Unbenanntes Projekt", untitled: "Unbenanntes Projekt",
empty: "Noch keine Projekte vorhanden.", 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({ export async function PortfolioProjectsOverview({
categories, categories,
projects, projects,
selectedCategory, selectedCategory,
}: PortfolioProjectsOverviewProps) { }: PortfolioProjectsOverviewProps) {
const siteSettings = await getSiteSettings(); const siteSettings = await getSiteSettings();
const publishedCount = projects.filter((project) => project.isPublished).length;
const isFiltered = selectedCategory !== "";
return ( return (
<div className="space-y-6"> <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]"> <div className="grid gap-4 md:grid-cols-3 xl:min-w-[620px]">
<StatsCard title="Projects" value={String(projects.length)} icon={FolderKanban} /> <StatsCard title="Projects" value={String(projects.length)} icon={FolderKanban} />
<StatsCard title="Categories" value={String(categories.length)} icon={Tags} /> <StatsCard title="Categories" value={String(categories.length)} icon={Tags} />
<StatsCard <StatsCard title="Published" value={String(publishedCount)} icon={CheckCircle2} />
title="Published"
value={String(projects.filter((project) => project.isPublished).length)}
icon={CheckCircle2}
/>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -76,59 +98,134 @@ export async function PortfolioProjectsOverview({
variant={selectedCategory === category.id ? "default" : "outline"} variant={selectedCategory === category.id ? "default" : "outline"}
> >
<Link href={`${getAdminAppPath("/portfolio")}?category=${category.id}`}> <Link href={`${getAdminAppPath("/portfolio")}?category=${category.id}`}>
{category.name.de || category.name.en || category.name.ar} {categoryLabel(category.name)}
</Link> </Link>
</Button> </Button>
))} ))}
</div> </div>
<div className="grid gap-4 xl:grid-cols-2"> {projects.length === 0 ? (
{projects.map((project, index) => ( <AppCard level={3} padding="lg">
<MotionFade key={project.id} delay={0.06 + index * 0.03}> <div className="flex flex-col items-center gap-4 py-12 text-center">
<AppCard interactive layer="single" className="h-full"> <div className="flex h-14 w-14 items-center justify-center rounded-pill border border-border/70 bg-surface-2 text-muted-foreground">
<CardContent className="flex flex-col gap-4 p-5 lg:flex-row lg:items-center lg:justify-between"> <FolderKanban className="h-6 w-6" />
<div className="space-y-2"> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="space-y-1">
<p className="text-xl font-semibold text-foreground"> <p className="text-base font-semibold text-foreground">
{getLocalizedValue(project.title, "de") || copy.untitled} {isFiltered ? copy.emptyFiltered : copy.empty}
</p> </p>
<Badge variant={project.isPublished ? "success" : "warning"}> <p className="text-sm text-muted-foreground">{copy.emptyHint}</p>
{project.isPublished ? "Published" : "Draft"} </div>
</Badge> {isFiltered ? (
<Badge variant="outline">{project.viewMode}</Badge> <Button asChild variant="outline">
</div> <Link href={getAdminAppPath("/portfolio")}>{copy.all}</Link>
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground"> </Button>
<span>{project.category.name.de || project.category.name.en || project.category.name.ar}</span> ) : (
<span>{project.projectYear}</span> <Button asChild>
</div> <Link href={getAdminAppPath("/portfolio/projects/new")}>
</div> <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;
<div className="flex flex-wrap gap-3"> return (
<Button asChild variant="outline"> <TableRow key={project.id}>
<Link <TableCell>
href={getLocalizedPath("de", `/portfolio/${project.slug}`, siteSettings.defaultLocale)} <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">
target="_blank" {project.coverImagePath ? (
rel="noreferrer" <img
> src={project.coverImagePath}
<ExternalLink className="h-4 w-4" /> alt={title}
{copy.openProject} className="h-full w-full object-cover"
</Link> />
</Button> ) : (
<PortfolioProjectActions projectId={project.id} /> <ImageOff className="h-4 w-4" />
</div> )}
</CardContent> </div>
</AppCard> </TableCell>
</MotionFade>
))}
{projects.length === 0 ? ( <TableCell>
<AppCard layer="single" className="xl:col-span-2"> <div className="flex min-w-0 flex-col">
<CardContent className="p-6 text-sm text-muted-foreground"> <span className="inline-flex items-center gap-1.5 font-medium text-foreground">
{copy.empty} <span className="truncate">{title}</span>
</CardContent> {project.isFeatured ? (
</AppCard> <Star
) : null} className="h-3.5 w-3.5 shrink-0 text-brand-primary"
</div> 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,
)}
target="_blank"
rel="noreferrer"
>
<ExternalLink className="h-4 w-4" />
<span className="sr-only">{copy.view}</span>
</Link>
</Button>
<PortfolioProjectActions projectId={project.id} />
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Card>
)}
</div> </div>
); );
} }
+9 -9
View File
@@ -270,7 +270,7 @@ function SiteSettingsMediaRow({
return ( 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="grid gap-4 lg:grid-cols-[180px_minmax(0,1fr)]">
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm font-semibold text-foreground">{title}</p> <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"> <div className="grid gap-5 lg:grid-cols-3">
{localeFields.map((locale) => ( {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> <p className="text-sm font-semibold text-foreground">{locale.label}</p>
<div className="space-y-2"> <div className="space-y-2">
@@ -558,7 +558,7 @@ export function SiteSettingsForm({
</p> </p>
</div> </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="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="primaryColor" className="sr-only">Primary Color</Label> <Label htmlFor="primaryColor" className="sr-only">Primary Color</Label>
@@ -679,7 +679,7 @@ export function SiteSettingsForm({
</p> </p>
</div> </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="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="defaultLocaleTrigger" className="sr-only">Default Locale</Label> <Label htmlFor="defaultLocaleTrigger" className="sr-only">Default Locale</Label>
@@ -729,7 +729,7 @@ export function SiteSettingsForm({
<section className="space-y-4"> <section className="space-y-4">
{mode === "brand" ? ( {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> <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="rounded-nested border border-border/70 bg-background p-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -750,7 +750,7 @@ export function SiteSettingsForm({
</div> </div>
</AppCard> </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> <p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Brand Assets</p>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<AppCard level={1} layer="single" padding="sm"> <AppCard level={1} layer="single" padding="sm">
@@ -773,7 +773,7 @@ export function SiteSettingsForm({
</div> </div>
</AppCard> </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> <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="overflow-hidden rounded-nested border border-border/70 bg-background">
<div className="h-1.5" style={{ backgroundColor: settings.brand.primaryColor }} /> <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> <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="rounded-nested border border-border/70 bg-background p-3">
<div className="flex items-center gap-2 text-sm font-medium text-foreground"> <div className="flex items-center gap-2 text-sm font-medium text-foreground">
@@ -810,7 +810,7 @@ export function SiteSettingsForm({
</div> </div>
</AppCard> </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> <p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Locale Summary</p>
<div className="rounded-nested border border-border/70 bg-background"> <div className="rounded-nested border border-border/70 bg-background">
<Table> <Table>
+2 -2
View File
@@ -25,7 +25,7 @@ export function SMTPSettingsForm({
return ( return (
<form id="smtp-settings-form" action={action} className="space-y-6"> <form id="smtp-settings-form" action={action} className="space-y-6">
<div className="grid gap-6 xl:grid-cols-2"> <div className="grid gap-6 xl:grid-cols-2">
<AppCard layer="single"> <AppCard>
<CardHeader> <CardHeader>
<CardTitle>SMTP Connection</CardTitle> <CardTitle>SMTP Connection</CardTitle>
<CardDescription>Server und Login.</CardDescription> <CardDescription>Server und Login.</CardDescription>
@@ -109,7 +109,7 @@ export function SMTPSettingsForm({
</CardContent> </CardContent>
</AppCard> </AppCard>
<AppCard layer="single"> <AppCard>
<CardHeader> <CardHeader>
<CardTitle>Sender And Recipients</CardTitle> <CardTitle>Sender And Recipients</CardTitle>
<CardDescription>Absender und Ziele.</CardDescription> <CardDescription>Absender und Ziele.</CardDescription>
+3 -3
View File
@@ -17,7 +17,7 @@ export function WorkspaceHero({
aside?: ReactNode; aside?: ReactNode;
}) { }) {
return ( 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"> <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"> <div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground"> <p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
@@ -40,7 +40,7 @@ export function WorkspaceSidebarPanel({
children: ReactNode; children: ReactNode;
}) { }) {
return ( return (
<AppCard level={2} layer="single"> <AppCard level={2}>
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<CardTitle className="text-base">{title}</CardTitle> <CardTitle className="text-base">{title}</CardTitle>
</CardHeader> </CardHeader>
@@ -100,7 +100,7 @@ export function WorkspaceLocaleCard({
children: ReactNode; children: ReactNode;
}) { }) {
return ( return (
<AppCard level={2} layer="single"> <AppCard level={2}>
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<CardTitle className="text-lg">{title}</CardTitle> <CardTitle className="text-lg">{title}</CardTitle>
<CardDescription>{hint}</CardDescription> <CardDescription>{hint}</CardDescription>
+351 -345
View File
@@ -5,10 +5,10 @@ import {
Tag, Tag,
UserRound, UserRound,
} from "lucide-react"; } from "lucide-react";
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { MotionFade } from "@/components/motion-fade"; import { MotionFade } from "@/components/motion-fade";
import { PortfolioCover } from "@/components/site/portfolio-cover";
import { AppCard } from "@/components/ui/app-card"; import { AppCard } from "@/components/ui/app-card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { CardContent } from "@/components/ui/card"; import { CardContent } from "@/components/ui/card";
@@ -16,7 +16,6 @@ import { getLocalizedPath, type AppLocale } from "@/lib/locale";
import { import {
getLocalizedValue, getLocalizedValue,
resolvePortfolioProjectViewMode, resolvePortfolioProjectViewMode,
type PortfolioAssetView,
type PortfolioProjectView, type PortfolioProjectView,
type PortfolioSectionView, type PortfolioSectionView,
} from "@/lib/portfolio"; } from "@/lib/portfolio";
@@ -28,27 +27,76 @@ type PortfolioProjectDetailProps = {
t: (key: "back" | "preview" | "openLink" | "gallery" | "download") => string; t: (key: "back" | "preview" | "openLink" | "gallery" | "download") => string;
}; };
function PortfolioImage({ type ProjectImage = {
src, key: string;
alt, src: string | null;
className, label: string;
width, };
height,
/**
* 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; image: ProjectImage;
alt: string; aspect?: string;
className: string; chrome?: boolean;
width: number; priority?: boolean;
height: number;
}) { }) {
return ( return (
<Image <figure className="overflow-hidden rounded-surface border border-border bg-surface-2 shadow-card">
src={src} {chrome ? (
alt={alt} <div className="flex items-center gap-2 border-b border-border bg-surface-3 px-4 py-3">
width={width} <span className="h-2.5 w-2.5 rounded-full bg-border-strong" />
height={height} <span className="h-2.5 w-2.5 rounded-full bg-border-strong" />
className={className} <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; locale: AppLocale;
}) { }) {
const metadata = [ const metadata = [
{ { icon: Tag, label: getLocalizedValue(item.category.name, locale) },
icon: Tag, { icon: CalendarDays, label: String(item.projectYear) },
label: getLocalizedValue(item.category.name, locale), { icon: FolderKanban, label: getLocalizedValue(item.serviceLabel, locale) },
}, { icon: UserRound, label: item.clientName },
{
icon: CalendarDays,
label: String(item.projectYear),
},
{
icon: FolderKanban,
label: getLocalizedValue(item.serviceLabel, locale),
},
{
icon: UserRound,
label: item.clientName,
},
].filter((entry) => entry.label); ].filter((entry) => entry.label);
return ( return (
@@ -84,19 +120,111 @@ function ProjectMeta({
const Icon = meta.icon; const Icon = meta.icon;
return ( return (
<AppCard key={`${meta.label}-${Icon.name}`} level={2}> <span
<CardContent className="flex items-center gap-2 p-3"> key={`${meta.label}-${Icon.name}`}
<Icon className="h-4 w-4 text-brand-primary" /> className="inline-flex items-center gap-2 rounded-pill border border-border/70 bg-surface-2 px-3.5 py-1.5"
{meta.label} >
</CardContent> <Icon className="h-4 w-4 text-brand-primary" />
</AppCard> {meta.label}
</span>
); );
})} })}
</div> </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, section,
locale, locale,
t, t,
@@ -108,326 +236,204 @@ function SectionBlock({
const title = getLocalizedValue(section.title, locale); const title = getLocalizedValue(section.title, locale);
const body = getLocalizedValue(section.body, 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"
/>
) : 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 ? (
<Button asChild variant="outline">
<Link href={section.linkUrl} target="_blank" rel="noreferrer">
{t("openLink")}
<ArrowUpRight className="h-4 w-4" />
</Link>
</Button>
) : null}
</div>
);
}
return ( return (
<div className="space-y-4"> <div className="space-y-3">
<h3 className="text-xl font-semibold text-foreground">{title}</h3> <h3 className="text-h3 text-foreground">{title}</h3>
<p className="whitespace-pre-line text-sm leading-7 text-muted-foreground">{body}</p> {body ? (
<p className="whitespace-pre-line text-body leading-7 text-muted-foreground">{body}</p>
) : null}
{section.type === "LINK" && section.linkUrl ? (
<Button asChild variant="outline">
<Link href={section.linkUrl} target="_blank" rel="noreferrer">
{t("openLink")}
<ArrowUpRight className="h-4 w-4" />
</Link>
</Button>
) : null}
</div> </div>
); );
} }
function AssetGallery({ /* ============================================================
assets, WEB (viewMode: CASE_STUDY)
locale, Two columns: stacked full screenshots + sticky info panel.
t, ============================================================ */
}: { function WebTemplate({ item, locale, defaultLocale, t }: PortfolioProjectDetailProps) {
assets: PortfolioAssetView[]; const images = collectImages(item, locale);
locale: AppLocale; const texts = textSections(item);
t: PortfolioProjectDetailProps["t"];
}) {
if (assets.length === 0) {
return null;
}
return ( return (
<MotionFade delay={0.12}> <div className="space-y-8">
<AppCard> <MotionFade>
<CardContent className="p-6 lg:p-8"> <div className="space-y-6">
<div className="flex items-end justify-between gap-4"> <BackLink locale={locale} defaultLocale={defaultLocale} t={t} />
<div> <ProjectIntro item={item} locale={locale} />
<p className="text-sm uppercase tracking-[0.18em] text-muted-foreground">Assets</p> </div>
<h2 className="mt-2 text-2xl font-semibold text-foreground">{t("gallery")}</h2> </MotionFade>
</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 }) { <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_340px] lg:items-start">
return ( <div className="order-2 space-y-6 lg:order-1">
<div className="rounded-pill border border-border/70 bg-background px-4 py-2 text-sm text-muted-foreground"> {images.map((image, index) => (
{count} {count === 1 ? "file" : "files"} <MotionFade key={image.key} delay={0.04 * index}>
</div> <MediaFrame image={image} chrome aspect="aspect-[16/11]" priority={index === 0} />
); </MotionFade>
} ))}
function ProjectHeader({ {texts.length > 0 ? (
item, <div className="grid gap-4 sm:grid-cols-2">
locale, {texts.map((section) => (
defaultLocale, <AppCard key={section.id}>
t, <CardContent className="p-5 lg:p-6">
}: { <TextSection section={section} locale={locale} t={t} />
item: PortfolioProjectView; </CardContent>
locale: AppLocale; </AppCard>
defaultLocale: AppLocale; ))}
t: PortfolioProjectDetailProps["t"];
}) {
return (
<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> </div>
) : null} ) : null}
</CardContent> </div>
</AppCard>
</MotionFade> <aside className="order-1 lg:order-2">
<InfoPanel item={item} locale={locale} t={t} />
</aside>
</div>
</div>
); );
} }
function GridTemplate({ /* ============================================================
item, PRINT (viewMode: GRID)
locale, Gallery: mixed-size grid of images (logos, posters, ...).
defaultLocale, ============================================================ */
t, function PrintTemplate({ item, locale, defaultLocale, t }: PortfolioProjectDetailProps) {
}: PortfolioProjectDetailProps) { const images = collectImages(item, locale);
const texts = textSections(item);
return (
<div className="space-y-10">
<MotionFade>
<div className="space-y-6">
<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>
);
}
/* ============================================================
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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<ProjectHeader item={item} locale={locale} defaultLocale={defaultLocale} t={t} /> <MotionFade>
<BackLink locale={locale} defaultLocale={defaultLocale} t={t} />
</MotionFade>
{item.coverImagePath ? ( <MotionFade delay={0.04}>
<MotionFade delay={0.04}> <div className="relative flex min-h-[420px] items-end overflow-hidden rounded-surface border border-border">
<AppCard> <div className="absolute inset-0">
<CardContent className="p-3 lg:p-4"> <PortfolioCover src={cover.src} title={cover.label} priority sizes="100vw" />
<PortfolioImage
src={item.coverImagePath}
alt={getLocalizedValue(item.title, locale)}
width={1800}
height={1000}
className="h-auto w-full rounded-surface object-cover"
/>
</CardContent>
</AppCard>
</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>
<CardContent className="p-5 lg:p-6">
<SectionBlock 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> </div>
</MotionFade> <div className="absolute inset-0 bg-gradient-to-t from-background/90 via-background/40 to-transparent" />
) : null} <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>
<h1 className="text-display text-foreground">{getLocalizedValue(item.title, locale)}</h1>
</div>
</div>
</MotionFade>
<div className="space-y-4"> <div className="flex flex-wrap items-center gap-3">
{item.sections.map((section, index) => ( <ProjectMeta item={item} locale={locale} />
<MotionFade key={section.id} delay={0.06 * (index + 1)}> <PreviewButton item={item} t={t} />
<div className="grid gap-4 lg:grid-cols-[120px_minmax(0,1fr)]"> </div>
<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"> <MotionFade delay={0.06}>
{String(index + 1).padStart(2, "0")} <p className="max-w-3xl py-6 text-h3 font-medium leading-snug text-foreground/90">
</div> {getLocalizedValue(item.summary, locale)}
</p>
</MotionFade>
<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>
);
}
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> </div>
<AppCard className="overflow-hidden"> </MotionFade>
<CardContent className="p-6 lg:p-8"> );
<SectionBlock section={section} locale={locale} t={t} /> })}
</CardContent>
</AppCard>
</div>
</MotionFade>
))}
</div> </div>
<AssetGallery assets={item.assets} locale={locale} t={t} />
</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),
);
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-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"
/>
</CardContent>
</AppCard>
</MotionFade>
) : null}
{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"}
</p>
<SectionBlock section={section} locale={locale} t={t} />
</CardContent>
</AppCard>
</MotionFade>
))}
</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">
{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>
</MotionFade>
))}
</div>
) : null}
<AssetGallery assets={item.assets} locale={locale} t={t} />
</div> </div>
); );
} }
@@ -441,12 +447,12 @@ export function PortfolioProjectDetail({
const viewMode = resolvePortfolioProjectViewMode(item.viewMode); const viewMode = resolvePortfolioProjectViewMode(item.viewMode);
if (viewMode === "STORY") { 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") { 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 appCardVariants>,
VariantProps<typeof appCardInnerVariants> { VariantProps<typeof appCardInnerVariants> {
layer?: "double" | "single"; 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>( 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") { if (layer === "single") {
return ( return (
<Card <Card
@@ -102,6 +113,7 @@ const AppCard = React.forwardRef<HTMLDivElement, AppCardProps>(
interactive, interactive,
}), }),
className, className,
contentClassName,
)} )}
{...props} {...props}
> >
@@ -129,6 +141,7 @@ const AppCard = React.forwardRef<HTMLDivElement, AppCardProps>(
padding, padding,
interactive, interactive,
}), }),
contentClassName,
)} )}
> >
{children} {children}
+7 -1
View File
@@ -98,22 +98,28 @@ function TabsTrigger({
type TabsContentProps = React.HTMLAttributes<HTMLDivElement> & { type TabsContentProps = React.HTMLAttributes<HTMLDivElement> & {
value: string; 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({ function TabsContent({
className, className,
value, value,
children, children,
forceMount = false,
...props ...props
}: TabsContentProps) { }: TabsContentProps) {
const { value: activeValue } = useTabsContext(); const { value: activeValue } = useTabsContext();
const isActive = activeValue === value;
if (activeValue !== value) { if (!isActive && !forceMount) {
return null; return null;
} }
return ( return (
<div <div
hidden={!isActive}
className={cn( className={cn(
"mt-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0", "mt-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0",
className, className,
+10
View File
@@ -1,5 +1,15 @@
import { existsSync } from "node:fs";
import { defineConfig } from "drizzle-kit"; 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({ export default defineConfig({
schema: "./lib/db/schema.ts", schema: "./lib/db/schema.ts",
out: "./lib/db/migrations", out: "./lib/db/migrations",
+12 -4
View File
@@ -136,13 +136,21 @@ export function getPortfolioWizardProgress(
}, },
{ {
key: "sections", key: "sections",
complete: input.sections.length > 0 && completedSections === input.sections.length, // Optional: no sections is valid. If sections were added, each must be ready.
summary: `${completedSections}/${input.sections.length} sections ready.`, complete: completedSections === input.sections.length,
summary:
input.sections.length === 0
? "Optional — no sections added."
: `${completedSections}/${input.sections.length} sections ready.`,
}, },
{ {
key: "assets", key: "assets",
complete: input.assets.length > 0 && completedAssets === input.assets.length, // Optional: no gallery assets is valid. If assets were added, each must be ready.
summary: `${completedAssets}/${input.assets.length} assets 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), sections: z.array(sectionInputSchema),
assets: z.array(assetInputSchema), 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 { import {
assetInputSchema, assetInputSchema,
categoryInputSchema, categoryInputSchema,
projectDraftInputSchema,
projectInputSchema, projectInputSchema,
sectionInputSchema, sectionInputSchema,
} from "../lib/portfolio-validation"; } from "../lib/portfolio-validation";
@@ -65,6 +66,64 @@ describe("portfolio validation", () => {
).toThrow(/slug/i); ).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", () => { it("accepts valid section and asset payloads", () => {
expect( expect(
sectionInputSchema.parse({ sectionInputSchema.parse({
+22 -3
View File
@@ -117,10 +117,29 @@ describe("getPortfolioWizardProgress", () => {
expect(getFirstIncompleteWizardStep(progress)).toBe("content"); 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: [] }); const noEntries = getPortfolioWizardProgress({ ...completeInput, sections: [], assets: [] });
expect(noEntries.find((s) => s.key === "sections")?.complete).toBe(false); expect(noEntries.find((s) => s.key === "sections")?.complete).toBe(true);
expect(noEntries.find((s) => s.key === "assets")?.complete).toBe(false); 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", () => { it("summarizes section/asset readiness counts", () => {