Compare commits
14
Commits
a13e33edcd
...
369c0e24c6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
369c0e24c6 | ||
|
|
d4b7f883e1 | ||
|
|
19e8fa8f19 | ||
|
|
077e1c5836 | ||
|
|
2f8fd66812 | ||
|
|
40387c19d4 | ||
|
|
d1245204f1 | ||
|
|
4b535694bc | ||
|
|
39e459fa09 | ||
|
|
80b1365ebc | ||
|
|
3c2421fb8b | ||
|
|
22cd3f8645 | ||
|
|
26969e25b2 | ||
|
|
d78450fc47 |
+1
-1
@@ -5,7 +5,7 @@
|
||||
"name": "dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 3000
|
||||
"port": 3014
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 1–3 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>;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
if (showValidation) {
|
||||
return <Badge variant="warning">Needs Attention</Badge>;
|
||||
}
|
||||
|
||||
if (active) {
|
||||
return <Badge variant="outline">Current</Badge>;
|
||||
}
|
||||
|
||||
return <Badge variant="outline">Incomplete</Badge>;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
@@ -142,292 +254,186 @@ function SectionBlock({
|
||||
);
|
||||
}
|
||||
|
||||
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} />;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user