Compare commits
14
Commits
| 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",
|
"name": "dev",
|
||||||
"runtimeExecutable": "npm",
|
"runtimeExecutable": "npm",
|
||||||
"runtimeArgs": ["run", "dev"],
|
"runtimeArgs": ["run", "dev"],
|
||||||
"port": 3000
|
"port": 3014
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default async function AdminMaintenancePage({
|
|||||||
headerDescription={copy.subtitle}
|
headerDescription={copy.subtitle}
|
||||||
>
|
>
|
||||||
<MotionFade delay={0.1}>
|
<MotionFade delay={0.1}>
|
||||||
<AppCard layer="single">
|
<AppCard>
|
||||||
<CardContent className="space-y-4 p-6">
|
<CardContent className="space-y-4 p-6">
|
||||||
<p className="text-sm text-muted-foreground">{copy.maintenanceText}</p>
|
<p className="text-sm text-muted-foreground">{copy.maintenanceText}</p>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
|||||||
@@ -30,10 +30,21 @@ import { getSiteSettings } from "@/lib/app-config";
|
|||||||
import {
|
import {
|
||||||
assetInputSchema,
|
assetInputSchema,
|
||||||
categoryInputSchema,
|
categoryInputSchema,
|
||||||
|
projectDraftInputSchema,
|
||||||
projectInputSchema,
|
projectInputSchema,
|
||||||
sectionInputSchema,
|
sectionInputSchema,
|
||||||
} from "@/lib/portfolio-validation";
|
} from "@/lib/portfolio-validation";
|
||||||
|
|
||||||
|
/** Build a URL-safe slug from a title, falling back to a unique draft slug. */
|
||||||
|
function slugifyForDraft(input: string): string {
|
||||||
|
const base = input
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "");
|
||||||
|
|
||||||
|
return base || `draft-${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureAdmin() {
|
async function ensureAdmin() {
|
||||||
if (!(await isAdminAuthenticated())) {
|
if (!(await isAdminAuthenticated())) {
|
||||||
await clearAdminSessionCookie();
|
await clearAdminSessionCookie();
|
||||||
@@ -204,28 +215,67 @@ export async function saveProjectAction(formData: FormData) {
|
|||||||
const createdMediaAssetIds: string[] = [];
|
const createdMediaAssetIds: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sections = parseJsonArray(formData.get("sections"), "sections").map((section, index) =>
|
const intent = String(formData.get("intent") ?? "save");
|
||||||
|
const isDraft = intent === "draft";
|
||||||
|
|
||||||
|
const parseSection = (section: Record<string, unknown>, index: number) =>
|
||||||
sectionInputSchema.parse({
|
sectionInputSchema.parse({
|
||||||
...section,
|
...section,
|
||||||
media: section.media ? mediaFieldInputSchema.parse(section.media) : undefined,
|
media: section.media ? mediaFieldInputSchema.parse(section.media) : undefined,
|
||||||
sortOrder: section.sortOrder ?? index,
|
sortOrder: section.sortOrder ?? index,
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const assets = parseJsonArray(formData.get("assets"), "assets").map((asset, index) =>
|
const parseAsset = (asset: Record<string, unknown>, index: number) =>
|
||||||
assetInputSchema.parse({
|
assetInputSchema.parse({
|
||||||
...asset,
|
...asset,
|
||||||
media: asset.media ? mediaFieldInputSchema.parse(asset.media) : undefined,
|
media: asset.media ? mediaFieldInputSchema.parse(asset.media) : undefined,
|
||||||
sortOrder: asset.sortOrder ?? index,
|
sortOrder: asset.sortOrder ?? index,
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
|
// A draft keeps only the entries that are already valid; a full save
|
||||||
|
// validates every entry strictly.
|
||||||
|
const sections = parseJsonArray(formData.get("sections"), "sections").flatMap((section, index) => {
|
||||||
|
if (!isDraft) {
|
||||||
|
return [parseSection(section, index)];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return [parseSection(section, index)];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const assets = parseJsonArray(formData.get("assets"), "assets").flatMap((asset, index) => {
|
||||||
|
if (!isDraft) {
|
||||||
|
return [parseAsset(asset, index)];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return [parseAsset(asset, index)];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const coverMedia = parseJsonObject(formData.get("coverMedia"), "coverMedia");
|
const coverMedia = parseJsonObject(formData.get("coverMedia"), "coverMedia");
|
||||||
|
|
||||||
const parsed = projectInputSchema.parse({
|
const rawSlug = String(formData.get("slug") ?? "").trim();
|
||||||
|
const slug =
|
||||||
|
isDraft && !rawSlug
|
||||||
|
? slugifyForDraft(
|
||||||
|
String(formData.get("titleDe") ?? "") ||
|
||||||
|
String(formData.get("titleEn") ?? "") ||
|
||||||
|
String(formData.get("titleAr") ?? ""),
|
||||||
|
)
|
||||||
|
: rawSlug;
|
||||||
|
const rawYear = String(formData.get("projectYear") ?? "").trim();
|
||||||
|
const projectYear = isDraft && !rawYear ? String(new Date().getFullYear()) : rawYear;
|
||||||
|
|
||||||
|
const parsed = (isDraft ? projectDraftInputSchema : projectInputSchema).parse({
|
||||||
id: String(formData.get("id") ?? "").trim() || undefined,
|
id: String(formData.get("id") ?? "").trim() || undefined,
|
||||||
categoryId: String(formData.get("categoryId") ?? ""),
|
categoryId: String(formData.get("categoryId") ?? ""),
|
||||||
slug: String(formData.get("slug") ?? ""),
|
slug,
|
||||||
viewMode: String(formData.get("viewMode") ?? "GRID"),
|
viewMode: String(formData.get("viewMode") ?? "GRID"),
|
||||||
titleAr: String(formData.get("titleAr") ?? ""),
|
titleAr: String(formData.get("titleAr") ?? ""),
|
||||||
titleEn: String(formData.get("titleEn") ?? ""),
|
titleEn: String(formData.get("titleEn") ?? ""),
|
||||||
@@ -234,7 +284,7 @@ export async function saveProjectAction(formData: FormData) {
|
|||||||
summaryEn: String(formData.get("summaryEn") ?? ""),
|
summaryEn: String(formData.get("summaryEn") ?? ""),
|
||||||
summaryDe: String(formData.get("summaryDe") ?? ""),
|
summaryDe: String(formData.get("summaryDe") ?? ""),
|
||||||
clientName: String(formData.get("clientName") ?? ""),
|
clientName: String(formData.get("clientName") ?? ""),
|
||||||
projectYear: String(formData.get("projectYear") ?? ""),
|
projectYear,
|
||||||
serviceLabelAr: String(formData.get("serviceLabelAr") ?? ""),
|
serviceLabelAr: String(formData.get("serviceLabelAr") ?? ""),
|
||||||
serviceLabelEn: String(formData.get("serviceLabelEn") ?? ""),
|
serviceLabelEn: String(formData.get("serviceLabelEn") ?? ""),
|
||||||
serviceLabelDe: String(formData.get("serviceLabelDe") ?? ""),
|
serviceLabelDe: String(formData.get("serviceLabelDe") ?? ""),
|
||||||
@@ -243,7 +293,7 @@ export async function saveProjectAction(formData: FormData) {
|
|||||||
coverMedia: coverMedia ? mediaFieldInputSchema.parse(coverMedia) : undefined,
|
coverMedia: coverMedia ? mediaFieldInputSchema.parse(coverMedia) : undefined,
|
||||||
sortOrder: String(formData.get("sortOrder") ?? "0"),
|
sortOrder: String(formData.get("sortOrder") ?? "0"),
|
||||||
isFeatured: normalizeCheckboxValue(formData, "isFeatured"),
|
isFeatured: normalizeCheckboxValue(formData, "isFeatured"),
|
||||||
isPublished: normalizeCheckboxValue(formData, "isPublished"),
|
isPublished: isDraft ? false : normalizeCheckboxValue(formData, "isPublished"),
|
||||||
sections,
|
sections,
|
||||||
assets,
|
assets,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export default async function AdminPortfolioProjectPage({
|
|||||||
</MotionFade>
|
</MotionFade>
|
||||||
|
|
||||||
<MotionFade delay={0.2}>
|
<MotionFade delay={0.2}>
|
||||||
<AppCard level={2} layer="single">
|
<AppCard level={2}>
|
||||||
<CardContent className="flex flex-col gap-4 p-6 lg:flex-row lg:items-center lg:justify-between">
|
<CardContent className="flex flex-col gap-4 p-6 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-foreground">{copy.dangerZone}</p>
|
<p className="text-sm font-medium text-foreground">{copy.dangerZone}</p>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function MarqueeSettingsForm({
|
|||||||
|
|
||||||
<div className="grid gap-4 xl:grid-cols-4">
|
<div className="grid gap-4 xl:grid-cols-4">
|
||||||
{rowMeta.map((row) => (
|
{rowMeta.map((row) => (
|
||||||
<AppCard key={`de-${row.key}`} level={2} layer="single" padding="sm" className="space-y-2">
|
<AppCard key={`de-${row.key}`} level={2} padding="sm" contentClassName="space-y-2">
|
||||||
<Label htmlFor={`${row.key}-de`} className="text-sm font-semibold text-foreground">{row.label}</Label>
|
<Label htmlFor={`${row.key}-de`} className="text-sm font-semibold text-foreground">{row.label}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id={`${row.key}-de`}
|
id={`${row.key}-de`}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import type { MediaKind } from "@/lib/db/enums";
|
|||||||
import { Check, ImageIcon, Search, Trash2 } from "lucide-react";
|
import { Check, ImageIcon, Search, Trash2 } from "lucide-react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { AppCard } from "@/components/ui/app-card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -17,7 +16,6 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import type { MediaOption } from "@/lib/media";
|
import type { MediaOption } from "@/lib/media";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -89,29 +87,45 @@ export function MediaFieldPicker({
|
|||||||
}, [serializedValue]);
|
}, [serializedValue]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppCard level={2} layer="single" padding="sm" className="space-y-4">
|
<div className="space-y-2">
|
||||||
<input ref={hiddenInputRef} type="hidden" name={inputName} value={serializedValue} />
|
<input ref={hiddenInputRef} type="hidden" name={inputName} value={serializedValue} />
|
||||||
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex flex-col gap-3 rounded-nested border border-border/70 bg-background p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="space-y-1">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<Label className="text-sm font-semibold text-foreground">{title}</Label>
|
{selectedOption ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<>
|
||||||
Media must be selected from the
|
<img
|
||||||
{" "}
|
src={selectedOption.url}
|
||||||
Media Library
|
alt={selectedOption.label}
|
||||||
.
|
className="h-14 w-14 shrink-0 rounded-nested border border-border/60 object-cover"
|
||||||
</p>
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium text-foreground">{selectedOption.label}</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">{selectedOption.source}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-3 text-muted-foreground">
|
||||||
|
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-nested border border-dashed border-border/70">
|
||||||
|
<ImageIcon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm">Kein Medium ausgewählt</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button type="button" variant="outline" onClick={() => setOpen(true)}>
|
<div className="flex shrink-0 gap-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||||
<ImageIcon className="h-4 w-4" />
|
<ImageIcon className="h-4 w-4" />
|
||||||
Select from Media
|
{selectedOption ? "Ändern" : "Auswählen"}
|
||||||
</Button>
|
</Button>
|
||||||
{canClear ? (
|
{canClear ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
className="text-destructive hover:text-destructive"
|
className="text-destructive hover:text-destructive"
|
||||||
|
title={clearLabel}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onChange({
|
onChange({
|
||||||
...value,
|
...value,
|
||||||
@@ -124,32 +138,12 @@ export function MediaFieldPicker({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
{clearLabel}
|
<span className="sr-only">{clearLabel}</span>
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AppCard layer="single" padding="sm" className="rounded-nested border-border/70">
|
|
||||||
{selectedOption ? (
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<img
|
|
||||||
src={selectedOption.url}
|
|
||||||
alt={selectedOption.label}
|
|
||||||
className="h-16 w-16 rounded-nested object-cover"
|
|
||||||
/>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-sm font-medium text-foreground">{selectedOption.label}</p>
|
|
||||||
<p className="truncate text-xs text-muted-foreground">{selectedOption.source}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-nested border border-dashed border-border/70 px-4 py-6 text-sm text-muted-foreground">
|
|
||||||
No media selected.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</AppCard>
|
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent className="max-w-4xl">
|
<DialogContent className="max-w-4xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -215,6 +209,6 @@ export function MediaFieldPicker({
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</AppCard>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -486,7 +486,7 @@ export function MediaLibraryManager({
|
|||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<MotionFade delay={0.18}>
|
<MotionFade delay={0.18}>
|
||||||
<AppCard layer="single">
|
<AppCard>
|
||||||
<CardContent className="p-6 text-sm text-muted-foreground">
|
<CardContent className="p-6 text-sm text-muted-foreground">
|
||||||
{copy.empty}
|
{copy.empty}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ function CategoryLocaleFields({
|
|||||||
const descriptionKey = `description${locale.key}` as const;
|
const descriptionKey = `description${locale.key}` as const;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppCard key={`${idPrefix}-${locale.key}`} level={2} layer="single" padding="sm" className="space-y-4 rounded-nested">
|
<AppCard key={`${idPrefix}-${locale.key}`} level={2} padding="sm" className="rounded-nested" contentClassName="space-y-4">
|
||||||
<p className="text-sm font-medium text-foreground">{locale.label}</p>
|
<p className="text-sm font-medium text-foreground">{locale.label}</p>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -168,7 +168,7 @@ function CategoryStatusFields({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
|
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
|
||||||
<AppCard level={2} layer="single" padding="sm" className="rounded-nested">
|
<AppCard level={2} padding="sm" className="rounded-nested">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{isActive ? (
|
{isActive ? (
|
||||||
<ShieldCheck className="mt-0.5 h-4 w-4 text-status-success" />
|
<ShieldCheck className="mt-0.5 h-4 w-4 text-status-success" />
|
||||||
@@ -395,7 +395,7 @@ export function PortfolioCategoriesManager({
|
|||||||
<DialogDescription>{copy.modalDescription}</DialogDescription>
|
<DialogDescription>{copy.modalDescription}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<AppCard level={2} layer="single" padding="sm" className="rounded-nested">
|
<AppCard level={2} padding="sm" className="rounded-nested">
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm font-medium text-foreground">1. Basics</p>
|
<p className="text-sm font-medium text-foreground">1. Basics</p>
|
||||||
@@ -426,7 +426,7 @@ export function PortfolioCategoriesManager({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AppCard level={3} layer="single">
|
<AppCard level={3}>
|
||||||
<CardContent className="space-y-4 p-6">
|
<CardContent className="space-y-4 p-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Layers3 className="h-5 w-5 text-brand-primary" />
|
<Layers3 className="h-5 w-5 text-brand-primary" />
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,22 @@
|
|||||||
import { ExternalLink, FolderKanban, Plus, Tags, CheckCircle2 } from "lucide-react";
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
|
||||||
|
import { CheckCircle2, ExternalLink, FolderKanban, ImageOff, Plus, Star, Tags } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { StatsCard } from "@/components/dashboard/stats-card";
|
import { StatsCard } from "@/components/dashboard/stats-card";
|
||||||
import { MotionFade } from "@/components/motion-fade";
|
|
||||||
import { PortfolioProjectActions } from "@/components/admin/portfolio-project-actions";
|
import { PortfolioProjectActions } from "@/components/admin/portfolio-project-actions";
|
||||||
import { AppCard } from "@/components/ui/app-card";
|
import { AppCard } from "@/components/ui/app-card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CardContent } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
import { getAdminAppPath } from "@/lib/admin-routing";
|
import { getAdminAppPath } from "@/lib/admin-routing";
|
||||||
import { getSiteSettings } from "@/lib/app-config";
|
import { getSiteSettings } from "@/lib/app-config";
|
||||||
import { getLocalizedPath } from "@/lib/locale";
|
import { getLocalizedPath } from "@/lib/locale";
|
||||||
@@ -24,17 +33,34 @@ const copy = {
|
|||||||
all: "Alle",
|
all: "Alle",
|
||||||
newProject: "Neues Projekt",
|
newProject: "Neues Projekt",
|
||||||
newCategory: "Neues Kategorie",
|
newCategory: "Neues Kategorie",
|
||||||
openProject: "Ansehen",
|
view: "Auf der Website ansehen",
|
||||||
untitled: "Unbenanntes Projekt",
|
untitled: "Unbenanntes Projekt",
|
||||||
empty: "Noch keine Projekte vorhanden.",
|
empty: "Noch keine Projekte vorhanden.",
|
||||||
|
emptyFiltered: "Keine Projekte in dieser Auswahl.",
|
||||||
|
emptyHint: "Lege dein erstes Projekt an, um es hier zu verwalten.",
|
||||||
|
colProject: "Projekt",
|
||||||
|
colCategory: "Kategorie",
|
||||||
|
colYear: "Jahr",
|
||||||
|
colStatus: "Status",
|
||||||
|
colMode: "Layout",
|
||||||
|
colActions: "Aktionen",
|
||||||
|
published: "Published",
|
||||||
|
draft: "Draft",
|
||||||
|
featured: "Featured",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function categoryLabel(name: PortfolioCategoryView["name"]) {
|
||||||
|
return name.de || name.en || name.ar;
|
||||||
|
}
|
||||||
|
|
||||||
export async function PortfolioProjectsOverview({
|
export async function PortfolioProjectsOverview({
|
||||||
categories,
|
categories,
|
||||||
projects,
|
projects,
|
||||||
selectedCategory,
|
selectedCategory,
|
||||||
}: PortfolioProjectsOverviewProps) {
|
}: PortfolioProjectsOverviewProps) {
|
||||||
const siteSettings = await getSiteSettings();
|
const siteSettings = await getSiteSettings();
|
||||||
|
const publishedCount = projects.filter((project) => project.isPublished).length;
|
||||||
|
const isFiltered = selectedCategory !== "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -42,11 +68,7 @@ export async function PortfolioProjectsOverview({
|
|||||||
<div className="grid gap-4 md:grid-cols-3 xl:min-w-[620px]">
|
<div className="grid gap-4 md:grid-cols-3 xl:min-w-[620px]">
|
||||||
<StatsCard title="Projects" value={String(projects.length)} icon={FolderKanban} />
|
<StatsCard title="Projects" value={String(projects.length)} icon={FolderKanban} />
|
||||||
<StatsCard title="Categories" value={String(categories.length)} icon={Tags} />
|
<StatsCard title="Categories" value={String(categories.length)} icon={Tags} />
|
||||||
<StatsCard
|
<StatsCard title="Published" value={String(publishedCount)} icon={CheckCircle2} />
|
||||||
title="Published"
|
|
||||||
value={String(projects.filter((project) => project.isPublished).length)}
|
|
||||||
icon={CheckCircle2}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -76,59 +98,134 @@ export async function PortfolioProjectsOverview({
|
|||||||
variant={selectedCategory === category.id ? "default" : "outline"}
|
variant={selectedCategory === category.id ? "default" : "outline"}
|
||||||
>
|
>
|
||||||
<Link href={`${getAdminAppPath("/portfolio")}?category=${category.id}`}>
|
<Link href={`${getAdminAppPath("/portfolio")}?category=${category.id}`}>
|
||||||
{category.name.de || category.name.en || category.name.ar}
|
{categoryLabel(category.name)}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 xl:grid-cols-2">
|
{projects.length === 0 ? (
|
||||||
{projects.map((project, index) => (
|
<AppCard level={3} padding="lg">
|
||||||
<MotionFade key={project.id} delay={0.06 + index * 0.03}>
|
<div className="flex flex-col items-center gap-4 py-12 text-center">
|
||||||
<AppCard interactive layer="single" className="h-full">
|
<div className="flex h-14 w-14 items-center justify-center rounded-pill border border-border/70 bg-surface-2 text-muted-foreground">
|
||||||
<CardContent className="flex flex-col gap-4 p-5 lg:flex-row lg:items-center lg:justify-between">
|
<FolderKanban className="h-6 w-6" />
|
||||||
<div className="space-y-2">
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="space-y-1">
|
||||||
<p className="text-xl font-semibold text-foreground">
|
<p className="text-base font-semibold text-foreground">
|
||||||
{getLocalizedValue(project.title, "de") || copy.untitled}
|
{isFiltered ? copy.emptyFiltered : copy.empty}
|
||||||
</p>
|
</p>
|
||||||
<Badge variant={project.isPublished ? "success" : "warning"}>
|
<p className="text-sm text-muted-foreground">{copy.emptyHint}</p>
|
||||||
{project.isPublished ? "Published" : "Draft"}
|
</div>
|
||||||
</Badge>
|
{isFiltered ? (
|
||||||
<Badge variant="outline">{project.viewMode}</Badge>
|
<Button asChild variant="outline">
|
||||||
</div>
|
<Link href={getAdminAppPath("/portfolio")}>{copy.all}</Link>
|
||||||
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
|
</Button>
|
||||||
<span>{project.category.name.de || project.category.name.en || project.category.name.ar}</span>
|
) : (
|
||||||
<span>{project.projectYear}</span>
|
<Button asChild>
|
||||||
</div>
|
<Link href={getAdminAppPath("/portfolio/projects/new")}>
|
||||||
</div>
|
<Plus className="h-4 w-4" />
|
||||||
|
{copy.newProject}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
) : (
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableHead className="w-[72px]">
|
||||||
|
<span className="sr-only">Cover</span>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>{copy.colProject}</TableHead>
|
||||||
|
<TableHead className="hidden md:table-cell">{copy.colCategory}</TableHead>
|
||||||
|
<TableHead className="hidden w-[80px] sm:table-cell">{copy.colYear}</TableHead>
|
||||||
|
<TableHead className="w-[130px]">{copy.colStatus}</TableHead>
|
||||||
|
<TableHead className="hidden w-[120px] lg:table-cell">{copy.colMode}</TableHead>
|
||||||
|
<TableHead className="w-[96px] text-right">{copy.colActions}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{projects.map((project) => {
|
||||||
|
const title = getLocalizedValue(project.title, "de") || copy.untitled;
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
return (
|
||||||
<Button asChild variant="outline">
|
<TableRow key={project.id}>
|
||||||
<Link
|
<TableCell>
|
||||||
href={getLocalizedPath("de", `/portfolio/${project.slug}`, siteSettings.defaultLocale)}
|
<div className="flex h-11 w-14 items-center justify-center overflow-hidden rounded-nested border border-border/70 bg-surface-2 text-muted-foreground">
|
||||||
target="_blank"
|
{project.coverImagePath ? (
|
||||||
rel="noreferrer"
|
<img
|
||||||
>
|
src={project.coverImagePath}
|
||||||
<ExternalLink className="h-4 w-4" />
|
alt={title}
|
||||||
{copy.openProject}
|
className="h-full w-full object-cover"
|
||||||
</Link>
|
/>
|
||||||
</Button>
|
) : (
|
||||||
<PortfolioProjectActions projectId={project.id} />
|
<ImageOff className="h-4 w-4" />
|
||||||
</div>
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</AppCard>
|
</TableCell>
|
||||||
</MotionFade>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{projects.length === 0 ? (
|
<TableCell>
|
||||||
<AppCard layer="single" className="xl:col-span-2">
|
<div className="flex min-w-0 flex-col">
|
||||||
<CardContent className="p-6 text-sm text-muted-foreground">
|
<span className="inline-flex items-center gap-1.5 font-medium text-foreground">
|
||||||
{copy.empty}
|
<span className="truncate">{title}</span>
|
||||||
</CardContent>
|
{project.isFeatured ? (
|
||||||
</AppCard>
|
<Star
|
||||||
) : null}
|
className="h-3.5 w-3.5 shrink-0 text-brand-primary"
|
||||||
</div>
|
fill="currentColor"
|
||||||
|
aria-label={copy.featured}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">/{project.slug}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="hidden text-sm text-muted-foreground md:table-cell">
|
||||||
|
{categoryLabel(project.category.name)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="hidden text-sm text-muted-foreground sm:table-cell">
|
||||||
|
{project.projectYear}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={project.isPublished ? "success" : "warning"}>
|
||||||
|
{project.isPublished ? copy.published : copy.draft}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="hidden lg:table-cell">
|
||||||
|
<Badge variant="outline">{project.viewMode}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
<Button asChild variant="ghost" size="icon" title={copy.view}>
|
||||||
|
<Link
|
||||||
|
href={getLocalizedPath(
|
||||||
|
"de",
|
||||||
|
`/portfolio/${project.slug}`,
|
||||||
|
siteSettings.defaultLocale,
|
||||||
|
)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
<span className="sr-only">{copy.view}</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<PortfolioProjectActions projectId={project.id} />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ function SiteSettingsMediaRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppCard level={2} layer="single" padding="sm">
|
<AppCard level={2} padding="sm">
|
||||||
<div className="grid gap-4 lg:grid-cols-[180px_minmax(0,1fr)]">
|
<div className="grid gap-4 lg:grid-cols-[180px_minmax(0,1fr)]">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-semibold text-foreground">{title}</p>
|
<p className="text-sm font-semibold text-foreground">{title}</p>
|
||||||
@@ -393,7 +393,7 @@ function LocalizedFieldsSection({
|
|||||||
|
|
||||||
<div className="grid gap-5 lg:grid-cols-3">
|
<div className="grid gap-5 lg:grid-cols-3">
|
||||||
{localeFields.map((locale) => (
|
{localeFields.map((locale) => (
|
||||||
<AppCard key={locale.key} level={2} layer="single" padding="sm" className="space-y-4">
|
<AppCard key={locale.key} level={2} padding="sm" contentClassName="space-y-4">
|
||||||
<p className="text-sm font-semibold text-foreground">{locale.label}</p>
|
<p className="text-sm font-semibold text-foreground">{locale.label}</p>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -558,7 +558,7 @@ export function SiteSettingsForm({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AppCard level={2} layer="single" padding="sm" className="space-y-4">
|
<AppCard level={2} padding="sm" contentClassName="space-y-4">
|
||||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
|
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="primaryColor" className="sr-only">Primary Color</Label>
|
<Label htmlFor="primaryColor" className="sr-only">Primary Color</Label>
|
||||||
@@ -679,7 +679,7 @@ export function SiteSettingsForm({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AppCard level={2} layer="single" padding="sm" className="space-y-4">
|
<AppCard level={2} padding="sm" contentClassName="space-y-4">
|
||||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="defaultLocaleTrigger" className="sr-only">Default Locale</Label>
|
<Label htmlFor="defaultLocaleTrigger" className="sr-only">Default Locale</Label>
|
||||||
@@ -729,7 +729,7 @@ export function SiteSettingsForm({
|
|||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
{mode === "brand" ? (
|
{mode === "brand" ? (
|
||||||
<>
|
<>
|
||||||
<AppCard level={2} layer="single" padding="sm" className="space-y-2">
|
<AppCard level={2} padding="sm" contentClassName="space-y-2">
|
||||||
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Search Result</p>
|
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Search Result</p>
|
||||||
<div className="rounded-nested border border-border/70 bg-background p-3">
|
<div className="rounded-nested border border-border/70 bg-background p-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -750,7 +750,7 @@ export function SiteSettingsForm({
|
|||||||
</div>
|
</div>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
|
||||||
<AppCard level={2} layer="single" padding="sm" className="space-y-2">
|
<AppCard level={2} padding="sm" contentClassName="space-y-2">
|
||||||
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Brand Assets</p>
|
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Brand Assets</p>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<AppCard level={1} layer="single" padding="sm">
|
<AppCard level={1} layer="single" padding="sm">
|
||||||
@@ -773,7 +773,7 @@ export function SiteSettingsForm({
|
|||||||
</div>
|
</div>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
|
||||||
<AppCard level={2} layer="single" padding="sm" className="space-y-2">
|
<AppCard level={2} padding="sm" contentClassName="space-y-2">
|
||||||
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Social Preview</p>
|
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Social Preview</p>
|
||||||
<div className="overflow-hidden rounded-nested border border-border/70 bg-background">
|
<div className="overflow-hidden rounded-nested border border-border/70 bg-background">
|
||||||
<div className="h-1.5" style={{ backgroundColor: settings.brand.primaryColor }} />
|
<div className="h-1.5" style={{ backgroundColor: settings.brand.primaryColor }} />
|
||||||
@@ -797,7 +797,7 @@ export function SiteSettingsForm({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<AppCard level={2} layer="single" padding="sm" className="space-y-2">
|
<AppCard level={2} padding="sm" contentClassName="space-y-2">
|
||||||
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Visitor Routing</p>
|
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Visitor Routing</p>
|
||||||
<div className="rounded-nested border border-border/70 bg-background p-3">
|
<div className="rounded-nested border border-border/70 bg-background p-3">
|
||||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||||
@@ -810,7 +810,7 @@ export function SiteSettingsForm({
|
|||||||
</div>
|
</div>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
|
||||||
<AppCard level={2} layer="single" padding="sm" className="space-y-2">
|
<AppCard level={2} padding="sm" contentClassName="space-y-2">
|
||||||
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Locale Summary</p>
|
<p className="text-xs uppercase tracking-[0.14em] text-muted-foreground">Locale Summary</p>
|
||||||
<div className="rounded-nested border border-border/70 bg-background">
|
<div className="rounded-nested border border-border/70 bg-background">
|
||||||
<Table>
|
<Table>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function SMTPSettingsForm({
|
|||||||
return (
|
return (
|
||||||
<form id="smtp-settings-form" action={action} className="space-y-6">
|
<form id="smtp-settings-form" action={action} className="space-y-6">
|
||||||
<div className="grid gap-6 xl:grid-cols-2">
|
<div className="grid gap-6 xl:grid-cols-2">
|
||||||
<AppCard layer="single">
|
<AppCard>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>SMTP Connection</CardTitle>
|
<CardTitle>SMTP Connection</CardTitle>
|
||||||
<CardDescription>Server und Login.</CardDescription>
|
<CardDescription>Server und Login.</CardDescription>
|
||||||
@@ -109,7 +109,7 @@ export function SMTPSettingsForm({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
|
||||||
<AppCard layer="single">
|
<AppCard>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Sender And Recipients</CardTitle>
|
<CardTitle>Sender And Recipients</CardTitle>
|
||||||
<CardDescription>Absender und Ziele.</CardDescription>
|
<CardDescription>Absender und Ziele.</CardDescription>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function WorkspaceHero({
|
|||||||
aside?: ReactNode;
|
aside?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<AppCard level={3} layer="single">
|
<AppCard level={3}>
|
||||||
<CardContent className="flex flex-col gap-4 p-6 lg:flex-row lg:items-end lg:justify-between lg:p-8">
|
<CardContent className="flex flex-col gap-4 p-6 lg:flex-row lg:items-end lg:justify-between lg:p-8">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
@@ -40,7 +40,7 @@ export function WorkspaceSidebarPanel({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<AppCard level={2} layer="single">
|
<AppCard level={2}>
|
||||||
<CardHeader className="pb-4">
|
<CardHeader className="pb-4">
|
||||||
<CardTitle className="text-base">{title}</CardTitle>
|
<CardTitle className="text-base">{title}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -100,7 +100,7 @@ export function WorkspaceLocaleCard({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<AppCard level={2} layer="single">
|
<AppCard level={2}>
|
||||||
<CardHeader className="pb-4">
|
<CardHeader className="pb-4">
|
||||||
<CardTitle className="text-lg">{title}</CardTitle>
|
<CardTitle className="text-lg">{title}</CardTitle>
|
||||||
<CardDescription>{hint}</CardDescription>
|
<CardDescription>{hint}</CardDescription>
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
UserRound,
|
UserRound,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { MotionFade } from "@/components/motion-fade";
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
|
import { PortfolioCover } from "@/components/site/portfolio-cover";
|
||||||
import { AppCard } from "@/components/ui/app-card";
|
import { AppCard } from "@/components/ui/app-card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CardContent } from "@/components/ui/card";
|
import { CardContent } from "@/components/ui/card";
|
||||||
@@ -16,7 +16,6 @@ import { getLocalizedPath, type AppLocale } from "@/lib/locale";
|
|||||||
import {
|
import {
|
||||||
getLocalizedValue,
|
getLocalizedValue,
|
||||||
resolvePortfolioProjectViewMode,
|
resolvePortfolioProjectViewMode,
|
||||||
type PortfolioAssetView,
|
|
||||||
type PortfolioProjectView,
|
type PortfolioProjectView,
|
||||||
type PortfolioSectionView,
|
type PortfolioSectionView,
|
||||||
} from "@/lib/portfolio";
|
} from "@/lib/portfolio";
|
||||||
@@ -28,27 +27,76 @@ type PortfolioProjectDetailProps = {
|
|||||||
t: (key: "back" | "preview" | "openLink" | "gallery" | "download") => string;
|
t: (key: "back" | "preview" | "openLink" | "gallery" | "download") => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function PortfolioImage({
|
type ProjectImage = {
|
||||||
src,
|
key: string;
|
||||||
alt,
|
src: string | null;
|
||||||
className,
|
label: string;
|
||||||
width,
|
};
|
||||||
height,
|
|
||||||
|
/**
|
||||||
|
* All image sources for a project, in reading order: cover, gallery-section
|
||||||
|
* images, then gallery assets. `src` may be null — PortfolioCover then renders
|
||||||
|
* the branded placeholder, so layouts look intentional even without artwork.
|
||||||
|
*/
|
||||||
|
function collectImages(item: PortfolioProjectView, locale: AppLocale): ProjectImage[] {
|
||||||
|
const images: ProjectImage[] = [];
|
||||||
|
const coverTitle = getLocalizedValue(item.title, locale);
|
||||||
|
|
||||||
|
images.push({ key: "cover", src: item.coverImagePath ?? null, label: coverTitle });
|
||||||
|
|
||||||
|
for (const section of item.sections) {
|
||||||
|
if (section.type === "GALLERY") {
|
||||||
|
images.push({
|
||||||
|
key: `section-${section.id}`,
|
||||||
|
src: section.imagePath ?? null,
|
||||||
|
label: getLocalizedValue(section.title, locale),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const asset of item.assets) {
|
||||||
|
if (asset.kind === "IMAGE") {
|
||||||
|
images.push({
|
||||||
|
key: `asset-${asset.id}`,
|
||||||
|
src: asset.filePath,
|
||||||
|
label: getLocalizedValue(asset.alt, locale),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return images;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text-bearing sections (everything that is not a gallery image). */
|
||||||
|
function textSections(item: PortfolioProjectView): PortfolioSectionView[] {
|
||||||
|
return item.sections.filter((section) => section.type !== "GALLERY");
|
||||||
|
}
|
||||||
|
|
||||||
|
function MediaFrame({
|
||||||
|
image,
|
||||||
|
aspect = "aspect-[16/10]",
|
||||||
|
chrome = false,
|
||||||
|
priority = false,
|
||||||
}: {
|
}: {
|
||||||
src: string;
|
image: ProjectImage;
|
||||||
alt: string;
|
aspect?: string;
|
||||||
className: string;
|
chrome?: boolean;
|
||||||
width: number;
|
priority?: boolean;
|
||||||
height: number;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Image
|
<figure className="overflow-hidden rounded-surface border border-border bg-surface-2 shadow-card">
|
||||||
src={src}
|
{chrome ? (
|
||||||
alt={alt}
|
<div className="flex items-center gap-2 border-b border-border bg-surface-3 px-4 py-3">
|
||||||
width={width}
|
<span className="h-2.5 w-2.5 rounded-full bg-border-strong" />
|
||||||
height={height}
|
<span className="h-2.5 w-2.5 rounded-full bg-border-strong" />
|
||||||
className={className}
|
<span className="h-2.5 w-2.5 rounded-full bg-border-strong" />
|
||||||
/>
|
<span className="ms-3 h-5 flex-1 rounded-md bg-surface-1" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className={`group relative ${aspect}`}>
|
||||||
|
<PortfolioCover src={image.src} title={image.label} priority={priority} />
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,22 +108,10 @@ function ProjectMeta({
|
|||||||
locale: AppLocale;
|
locale: AppLocale;
|
||||||
}) {
|
}) {
|
||||||
const metadata = [
|
const metadata = [
|
||||||
{
|
{ icon: Tag, label: getLocalizedValue(item.category.name, locale) },
|
||||||
icon: Tag,
|
{ icon: CalendarDays, label: String(item.projectYear) },
|
||||||
label: getLocalizedValue(item.category.name, locale),
|
{ icon: FolderKanban, label: getLocalizedValue(item.serviceLabel, locale) },
|
||||||
},
|
{ icon: UserRound, label: item.clientName },
|
||||||
{
|
|
||||||
icon: CalendarDays,
|
|
||||||
label: String(item.projectYear),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: FolderKanban,
|
|
||||||
label: getLocalizedValue(item.serviceLabel, locale),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: UserRound,
|
|
||||||
label: item.clientName,
|
|
||||||
},
|
|
||||||
].filter((entry) => entry.label);
|
].filter((entry) => entry.label);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -84,19 +120,111 @@ function ProjectMeta({
|
|||||||
const Icon = meta.icon;
|
const Icon = meta.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppCard key={`${meta.label}-${Icon.name}`} level={2}>
|
<span
|
||||||
<CardContent className="flex items-center gap-2 p-3">
|
key={`${meta.label}-${Icon.name}`}
|
||||||
<Icon className="h-4 w-4 text-brand-primary" />
|
className="inline-flex items-center gap-2 rounded-pill border border-border/70 bg-surface-2 px-3.5 py-1.5"
|
||||||
{meta.label}
|
>
|
||||||
</CardContent>
|
<Icon className="h-4 w-4 text-brand-primary" />
|
||||||
</AppCard>
|
{meta.label}
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionBlock({
|
function BackLink({
|
||||||
|
locale,
|
||||||
|
defaultLocale,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
locale: AppLocale;
|
||||||
|
defaultLocale: AppLocale;
|
||||||
|
t: PortfolioProjectDetailProps["t"];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button asChild variant="ghost" className="h-auto px-0 py-0 text-sm text-muted-foreground">
|
||||||
|
<Link href={getLocalizedPath(locale, "/portfolio", defaultLocale)}>{t("back")}</Link>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewButton({
|
||||||
|
item,
|
||||||
|
t,
|
||||||
|
full = false,
|
||||||
|
}: {
|
||||||
|
item: PortfolioProjectView;
|
||||||
|
t: PortfolioProjectDetailProps["t"];
|
||||||
|
full?: boolean;
|
||||||
|
}) {
|
||||||
|
if (!item.previewUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button asChild className={full ? "w-full justify-center" : undefined}>
|
||||||
|
<Link href={item.previewUrl} target="_blank" rel="noreferrer">
|
||||||
|
{t("preview")}
|
||||||
|
<ArrowUpRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectIntro({ item, locale }: { item: PortfolioProjectView; locale: AppLocale }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-overline uppercase tracking-[0.18em] text-brand-primary">
|
||||||
|
{getLocalizedValue(item.category.name, locale)} · {item.projectYear}
|
||||||
|
</p>
|
||||||
|
<h1 className="text-h1 text-foreground">{getLocalizedValue(item.title, locale)}</h1>
|
||||||
|
<p className="max-w-2xl text-body-lg text-muted-foreground">
|
||||||
|
{getLocalizedValue(item.summary, locale)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoPanel({
|
||||||
|
item,
|
||||||
|
locale,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
item: PortfolioProjectView;
|
||||||
|
locale: AppLocale;
|
||||||
|
t: PortfolioProjectDetailProps["t"];
|
||||||
|
}) {
|
||||||
|
const rows = [
|
||||||
|
{ label: "Client", value: item.clientName },
|
||||||
|
{ label: "Year", value: String(item.projectYear) },
|
||||||
|
{ label: "Service", value: getLocalizedValue(item.serviceLabel, locale) },
|
||||||
|
{ label: "Category", value: getLocalizedValue(item.category.name, locale) },
|
||||||
|
].filter((row) => row.value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppCard level={2} className="lg:sticky lg:top-24">
|
||||||
|
<CardContent className="space-y-5 p-6">
|
||||||
|
<dl className="space-y-0">
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
<div
|
||||||
|
key={row.label}
|
||||||
|
className={`flex items-center justify-between gap-4 py-2.5 text-sm ${
|
||||||
|
index < rows.length - 1 ? "border-b border-dashed border-border" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<dt className="text-muted-foreground">{row.label}</dt>
|
||||||
|
<dd className="font-semibold text-foreground">{row.value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
<PreviewButton item={item} t={t} full />
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextSection({
|
||||||
section,
|
section,
|
||||||
locale,
|
locale,
|
||||||
t,
|
t,
|
||||||
@@ -108,326 +236,204 @@ function SectionBlock({
|
|||||||
const title = getLocalizedValue(section.title, locale);
|
const title = getLocalizedValue(section.title, locale);
|
||||||
const body = getLocalizedValue(section.body, locale);
|
const body = getLocalizedValue(section.body, locale);
|
||||||
|
|
||||||
if (section.type === "GALLERY") {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-xl font-semibold text-foreground">{title}</h3>
|
|
||||||
{section.imagePath ? (
|
|
||||||
<PortfolioImage
|
|
||||||
src={section.imagePath}
|
|
||||||
alt={title}
|
|
||||||
width={1400}
|
|
||||||
height={880}
|
|
||||||
className="h-72 w-full rounded-surface object-cover"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (section.type === "LINK") {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-xl font-semibold text-foreground">{title}</h3>
|
|
||||||
{body ? <p className="whitespace-pre-line text-sm leading-7 text-muted-foreground">{body}</p> : null}
|
|
||||||
{section.linkUrl ? (
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href={section.linkUrl} target="_blank" rel="noreferrer">
|
|
||||||
{t("openLink")}
|
|
||||||
<ArrowUpRight className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<h3 className="text-xl font-semibold text-foreground">{title}</h3>
|
<h3 className="text-h3 text-foreground">{title}</h3>
|
||||||
<p className="whitespace-pre-line text-sm leading-7 text-muted-foreground">{body}</p>
|
{body ? (
|
||||||
|
<p className="whitespace-pre-line text-body leading-7 text-muted-foreground">{body}</p>
|
||||||
|
) : null}
|
||||||
|
{section.type === "LINK" && section.linkUrl ? (
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href={section.linkUrl} target="_blank" rel="noreferrer">
|
||||||
|
{t("openLink")}
|
||||||
|
<ArrowUpRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AssetGallery({
|
/* ============================================================
|
||||||
assets,
|
WEB (viewMode: CASE_STUDY)
|
||||||
locale,
|
Two columns: stacked full screenshots + sticky info panel.
|
||||||
t,
|
============================================================ */
|
||||||
}: {
|
function WebTemplate({ item, locale, defaultLocale, t }: PortfolioProjectDetailProps) {
|
||||||
assets: PortfolioAssetView[];
|
const images = collectImages(item, locale);
|
||||||
locale: AppLocale;
|
const texts = textSections(item);
|
||||||
t: PortfolioProjectDetailProps["t"];
|
|
||||||
}) {
|
|
||||||
if (assets.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MotionFade delay={0.12}>
|
<div className="space-y-8">
|
||||||
<AppCard>
|
<MotionFade>
|
||||||
<CardContent className="p-6 lg:p-8">
|
<div className="space-y-6">
|
||||||
<div className="flex items-end justify-between gap-4">
|
<BackLink locale={locale} defaultLocale={defaultLocale} t={t} />
|
||||||
<div>
|
<ProjectIntro item={item} locale={locale} />
|
||||||
<p className="text-sm uppercase tracking-[0.18em] text-muted-foreground">Assets</p>
|
</div>
|
||||||
<h2 className="mt-2 text-2xl font-semibold text-foreground">{t("gallery")}</h2>
|
</MotionFade>
|
||||||
</div>
|
|
||||||
<BadgeCount count={assets.length} />
|
|
||||||
</div>
|
|
||||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
|
||||||
{assets.map((asset) => (
|
|
||||||
<div key={asset.id} className="overflow-hidden rounded-surface border border-border bg-card">
|
|
||||||
{asset.kind === "IMAGE" ? (
|
|
||||||
<PortfolioImage
|
|
||||||
src={asset.filePath}
|
|
||||||
alt={getLocalizedValue(asset.alt, locale)}
|
|
||||||
width={1200}
|
|
||||||
height={760}
|
|
||||||
className="h-72 w-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex min-h-72 items-center justify-center bg-muted/30 p-6 text-center">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className="text-sm font-medium text-foreground">
|
|
||||||
{getLocalizedValue(asset.alt, locale)}
|
|
||||||
</p>
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href={asset.filePath} target="_blank" rel="noreferrer">
|
|
||||||
{t("download")}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</AppCard>
|
|
||||||
</MotionFade>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BadgeCount({ count }: { count: number }) {
|
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_340px] lg:items-start">
|
||||||
return (
|
<div className="order-2 space-y-6 lg:order-1">
|
||||||
<div className="rounded-pill border border-border/70 bg-background px-4 py-2 text-sm text-muted-foreground">
|
{images.map((image, index) => (
|
||||||
{count} {count === 1 ? "file" : "files"}
|
<MotionFade key={image.key} delay={0.04 * index}>
|
||||||
</div>
|
<MediaFrame image={image} chrome aspect="aspect-[16/11]" priority={index === 0} />
|
||||||
);
|
</MotionFade>
|
||||||
}
|
))}
|
||||||
|
|
||||||
function ProjectHeader({
|
{texts.length > 0 ? (
|
||||||
item,
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
locale,
|
{texts.map((section) => (
|
||||||
defaultLocale,
|
<AppCard key={section.id}>
|
||||||
t,
|
<CardContent className="p-5 lg:p-6">
|
||||||
}: {
|
<TextSection section={section} locale={locale} t={t} />
|
||||||
item: PortfolioProjectView;
|
</CardContent>
|
||||||
locale: AppLocale;
|
</AppCard>
|
||||||
defaultLocale: AppLocale;
|
))}
|
||||||
t: PortfolioProjectDetailProps["t"];
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<MotionFade>
|
|
||||||
<AppCard level={3}>
|
|
||||||
<CardContent className="p-6 lg:p-10">
|
|
||||||
<Button asChild variant="ghost" className="h-auto px-0 py-0 text-sm">
|
|
||||||
<Link href={getLocalizedPath(locale, "/portfolio", defaultLocale)}>{t("back")}</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<ProjectMeta item={item} locale={locale} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{item.previewUrl ? (
|
|
||||||
<div className="mt-8">
|
|
||||||
<Button asChild>
|
|
||||||
<Link href={item.previewUrl} target="_blank" rel="noreferrer">
|
|
||||||
{t("preview")}
|
|
||||||
<ArrowUpRight className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</div>
|
||||||
</AppCard>
|
|
||||||
</MotionFade>
|
<aside className="order-1 lg:order-2">
|
||||||
|
<InfoPanel item={item} locale={locale} t={t} />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GridTemplate({
|
/* ============================================================
|
||||||
item,
|
PRINT (viewMode: GRID)
|
||||||
locale,
|
Gallery: mixed-size grid of images (logos, posters, ...).
|
||||||
defaultLocale,
|
============================================================ */
|
||||||
t,
|
function PrintTemplate({ item, locale, defaultLocale, t }: PortfolioProjectDetailProps) {
|
||||||
}: PortfolioProjectDetailProps) {
|
const images = collectImages(item, locale);
|
||||||
|
const texts = textSections(item);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-10">
|
||||||
|
<MotionFade>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<BackLink locale={locale} defaultLocale={defaultLocale} t={t} />
|
||||||
|
<ProjectIntro item={item} locale={locale} />
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<ProjectMeta item={item} locale={locale} />
|
||||||
|
<PreviewButton item={item} t={t} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MotionFade>
|
||||||
|
|
||||||
|
<MotionFade delay={0.06}>
|
||||||
|
<div className="grid auto-rows-[minmax(0,1fr)] grid-cols-2 gap-4 md:grid-cols-3">
|
||||||
|
{images.map((image, index) => {
|
||||||
|
// First image spans a large feature tile; the rest alternate size.
|
||||||
|
const span =
|
||||||
|
index === 0
|
||||||
|
? "col-span-2 row-span-2"
|
||||||
|
: index % 4 === 0
|
||||||
|
? "col-span-2"
|
||||||
|
: "";
|
||||||
|
const aspect = index === 0 ? "aspect-square" : "aspect-[4/3]";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={image.key} className={span}>
|
||||||
|
<MediaFrame image={image} aspect={index === 0 ? "aspect-square" : aspect} priority={index === 0} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</MotionFade>
|
||||||
|
|
||||||
|
{texts.length > 0 ? (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{texts.map((section) => (
|
||||||
|
<MotionFade key={section.id} delay={0.04}>
|
||||||
|
<AppCard>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<TextSection section={section} locale={locale} t={t} />
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
</MotionFade>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
EDITORIAL (viewMode: STORY)
|
||||||
|
Full-bleed hero + alternating text / media sections.
|
||||||
|
============================================================ */
|
||||||
|
function EditorialTemplate({ item, locale, defaultLocale, t }: PortfolioProjectDetailProps) {
|
||||||
|
const cover = collectImages(item, locale)[0]!;
|
||||||
|
const sections = item.sections;
|
||||||
|
const galleryImages = collectImages(item, locale).slice(1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<ProjectHeader item={item} locale={locale} defaultLocale={defaultLocale} t={t} />
|
<MotionFade>
|
||||||
|
<BackLink locale={locale} defaultLocale={defaultLocale} t={t} />
|
||||||
|
</MotionFade>
|
||||||
|
|
||||||
{item.coverImagePath ? (
|
<MotionFade delay={0.04}>
|
||||||
<MotionFade delay={0.04}>
|
<div className="relative flex min-h-[420px] items-end overflow-hidden rounded-surface border border-border">
|
||||||
<AppCard>
|
<div className="absolute inset-0">
|
||||||
<CardContent className="p-3 lg:p-4">
|
<PortfolioCover src={cover.src} title={cover.label} priority sizes="100vw" />
|
||||||
<PortfolioImage
|
|
||||||
src={item.coverImagePath}
|
|
||||||
alt={getLocalizedValue(item.title, locale)}
|
|
||||||
width={1800}
|
|
||||||
height={1000}
|
|
||||||
className="h-auto w-full rounded-surface object-cover"
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</AppCard>
|
|
||||||
</MotionFade>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
|
||||||
{item.sections.map((section, index) => (
|
|
||||||
<MotionFade key={section.id} delay={0.06 * (index + 1)}>
|
|
||||||
<AppCard>
|
|
||||||
<CardContent className="p-5 lg:p-6">
|
|
||||||
<SectionBlock section={section} locale={locale} t={t} />
|
|
||||||
</CardContent>
|
|
||||||
</AppCard>
|
|
||||||
</MotionFade>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AssetGallery assets={item.assets} locale={locale} t={t} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StoryTemplate({
|
|
||||||
item,
|
|
||||||
locale,
|
|
||||||
defaultLocale,
|
|
||||||
t,
|
|
||||||
}: PortfolioProjectDetailProps) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
<ProjectHeader item={item} locale={locale} defaultLocale={defaultLocale} t={t} />
|
|
||||||
|
|
||||||
{item.coverImagePath ? (
|
|
||||||
<MotionFade delay={0.04}>
|
|
||||||
<div className="overflow-hidden rounded-[32px] border border-border/70 bg-card p-3 shadow-card">
|
|
||||||
<PortfolioImage
|
|
||||||
src={item.coverImagePath}
|
|
||||||
alt={getLocalizedValue(item.title, locale)}
|
|
||||||
width={1800}
|
|
||||||
height={1100}
|
|
||||||
className="h-[440px] w-full rounded-[28px] object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</MotionFade>
|
<div className="absolute inset-0 bg-gradient-to-t from-background/90 via-background/40 to-transparent" />
|
||||||
) : null}
|
<div className="relative z-10 max-w-3xl space-y-4 p-8 lg:p-12">
|
||||||
|
<p className="text-overline uppercase tracking-[0.18em] text-brand-primary">
|
||||||
|
{getLocalizedValue(item.category.name, locale)} · {item.projectYear}
|
||||||
|
</p>
|
||||||
|
<h1 className="text-display text-foreground">{getLocalizedValue(item.title, locale)}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MotionFade>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{item.sections.map((section, index) => (
|
<ProjectMeta item={item} locale={locale} />
|
||||||
<MotionFade key={section.id} delay={0.06 * (index + 1)}>
|
<PreviewButton item={item} t={t} />
|
||||||
<div className="grid gap-4 lg:grid-cols-[120px_minmax(0,1fr)]">
|
</div>
|
||||||
<div className="pt-4">
|
|
||||||
<div className="inline-flex rounded-pill border border-border/70 bg-background px-4 py-2 text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
<MotionFade delay={0.06}>
|
||||||
{String(index + 1).padStart(2, "0")}
|
<p className="max-w-3xl py-6 text-h3 font-medium leading-snug text-foreground/90">
|
||||||
</div>
|
{getLocalizedValue(item.summary, locale)}
|
||||||
|
</p>
|
||||||
|
</MotionFade>
|
||||||
|
|
||||||
|
<div className="space-y-16 py-4">
|
||||||
|
{sections.map((section, index) => {
|
||||||
|
if (section.type === "GALLERY") {
|
||||||
|
return (
|
||||||
|
<MotionFade key={section.id} delay={0.04}>
|
||||||
|
<MediaFrame
|
||||||
|
image={{
|
||||||
|
key: section.id,
|
||||||
|
src: section.imagePath ?? null,
|
||||||
|
label: getLocalizedValue(section.title, locale),
|
||||||
|
}}
|
||||||
|
aspect="aspect-[16/8]"
|
||||||
|
/>
|
||||||
|
</MotionFade>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MotionFade key={section.id} delay={0.04}>
|
||||||
|
<div
|
||||||
|
className={`grid items-center gap-8 lg:grid-cols-2 ${
|
||||||
|
index % 2 === 1 ? "lg:[&>*:first-child]:order-2" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<TextSection section={section} locale={locale} t={t} />
|
||||||
|
<MediaFrame
|
||||||
|
image={galleryImages[index % Math.max(galleryImages.length, 1)] ?? cover}
|
||||||
|
aspect="aspect-[4/3]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<AppCard className="overflow-hidden">
|
</MotionFade>
|
||||||
<CardContent className="p-6 lg:p-8">
|
);
|
||||||
<SectionBlock section={section} locale={locale} t={t} />
|
})}
|
||||||
</CardContent>
|
|
||||||
</AppCard>
|
|
||||||
</div>
|
|
||||||
</MotionFade>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AssetGallery assets={item.assets} locale={locale} t={t} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CaseStudyTemplate({
|
|
||||||
item,
|
|
||||||
locale,
|
|
||||||
defaultLocale,
|
|
||||||
t,
|
|
||||||
}: PortfolioProjectDetailProps) {
|
|
||||||
const [challenge, solution, outcome, ...restSections] = item.sections;
|
|
||||||
const leadSections = [challenge, solution, outcome].filter(
|
|
||||||
(section): section is PortfolioSectionView => Boolean(section),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
<ProjectHeader item={item} locale={locale} defaultLocale={defaultLocale} t={t} />
|
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_420px]">
|
|
||||||
<div className="space-y-6">
|
|
||||||
{item.coverImagePath ? (
|
|
||||||
<MotionFade delay={0.04}>
|
|
||||||
<AppCard className="overflow-hidden">
|
|
||||||
<CardContent className="p-3">
|
|
||||||
<PortfolioImage
|
|
||||||
src={item.coverImagePath}
|
|
||||||
alt={getLocalizedValue(item.title, locale)}
|
|
||||||
width={1800}
|
|
||||||
height={1100}
|
|
||||||
className="h-auto w-full rounded-surface object-cover"
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</AppCard>
|
|
||||||
</MotionFade>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{leadSections.map((section, index) => (
|
|
||||||
<MotionFade key={section.id} delay={0.06 * (index + 1)}>
|
|
||||||
<AppCard level={index === 1 ? 3 : 1}>
|
|
||||||
<CardContent className="space-y-4 p-6 lg:p-8">
|
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
|
||||||
{index === 0 ? "Challenge" : index === 1 ? "Solution" : "Outcome"}
|
|
||||||
</p>
|
|
||||||
<SectionBlock section={section} locale={locale} t={t} />
|
|
||||||
</CardContent>
|
|
||||||
</AppCard>
|
|
||||||
</MotionFade>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<MotionFade delay={0.08}>
|
|
||||||
<AppCard level={2} className="sticky top-24">
|
|
||||||
<CardContent className="space-y-5 p-6">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">Case Study Snapshot</p>
|
|
||||||
<h2 className="mt-2 text-2xl font-semibold text-foreground">
|
|
||||||
{getLocalizedValue(item.title, locale)}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm leading-7 text-muted-foreground">
|
|
||||||
{getLocalizedValue(item.summary, locale)}
|
|
||||||
</p>
|
|
||||||
<ProjectMeta item={item} locale={locale} />
|
|
||||||
</CardContent>
|
|
||||||
</AppCard>
|
|
||||||
</MotionFade>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{restSections.length > 0 ? (
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
|
||||||
{restSections.map((section, index) => (
|
|
||||||
<MotionFade key={section.id} delay={0.1 + index * 0.04}>
|
|
||||||
<AppCard>
|
|
||||||
<CardContent className="p-5 lg:p-6">
|
|
||||||
<SectionBlock section={section} locale={locale} t={t} />
|
|
||||||
</CardContent>
|
|
||||||
</AppCard>
|
|
||||||
</MotionFade>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<AssetGallery assets={item.assets} locale={locale} t={t} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -441,12 +447,12 @@ export function PortfolioProjectDetail({
|
|||||||
const viewMode = resolvePortfolioProjectViewMode(item.viewMode);
|
const viewMode = resolvePortfolioProjectViewMode(item.viewMode);
|
||||||
|
|
||||||
if (viewMode === "STORY") {
|
if (viewMode === "STORY") {
|
||||||
return <StoryTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
|
return <EditorialTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (viewMode === "CASE_STUDY") {
|
if (viewMode === "CASE_STUDY") {
|
||||||
return <CaseStudyTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
|
return <WebTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <GridTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
|
return <PrintTemplate item={item} locale={locale} defaultLocale={defaultLocale} t={t} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,10 +87,21 @@ export interface AppCardProps
|
|||||||
VariantProps<typeof appCardVariants>,
|
VariantProps<typeof appCardVariants>,
|
||||||
VariantProps<typeof appCardInnerVariants> {
|
VariantProps<typeof appCardInnerVariants> {
|
||||||
layer?: "double" | "single";
|
layer?: "double" | "single";
|
||||||
|
/**
|
||||||
|
* Classes applied to the element that directly wraps `children` (the inner
|
||||||
|
* shell in `double` layer, the single shell in `single` layer). Use it for
|
||||||
|
* content utilities such as `space-y-*` / `grid` / `flex` so they keep
|
||||||
|
* working after switching a card to the layered (double) look, where
|
||||||
|
* `className` lands on the outer shell instead.
|
||||||
|
*/
|
||||||
|
contentClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppCard = React.forwardRef<HTMLDivElement, AppCardProps>(
|
const AppCard = React.forwardRef<HTMLDivElement, AppCardProps>(
|
||||||
({ className, level, padding, interactive, layer = "double", children, ...props }, ref) => {
|
(
|
||||||
|
{ className, contentClassName, level, padding, interactive, layer = "double", children, ...props },
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
if (layer === "single") {
|
if (layer === "single") {
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -102,6 +113,7 @@ const AppCard = React.forwardRef<HTMLDivElement, AppCardProps>(
|
|||||||
interactive,
|
interactive,
|
||||||
}),
|
}),
|
||||||
className,
|
className,
|
||||||
|
contentClassName,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -129,6 +141,7 @@ const AppCard = React.forwardRef<HTMLDivElement, AppCardProps>(
|
|||||||
padding,
|
padding,
|
||||||
interactive,
|
interactive,
|
||||||
}),
|
}),
|
||||||
|
contentClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -98,22 +98,28 @@ function TabsTrigger({
|
|||||||
|
|
||||||
type TabsContentProps = React.HTMLAttributes<HTMLDivElement> & {
|
type TabsContentProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||||
value: string;
|
value: string;
|
||||||
|
// Keep the panel mounted while inactive (hidden via `hidden`) so its form
|
||||||
|
// fields still submit. Use for tabbed forms that share one submit button.
|
||||||
|
forceMount?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function TabsContent({
|
function TabsContent({
|
||||||
className,
|
className,
|
||||||
value,
|
value,
|
||||||
children,
|
children,
|
||||||
|
forceMount = false,
|
||||||
...props
|
...props
|
||||||
}: TabsContentProps) {
|
}: TabsContentProps) {
|
||||||
const { value: activeValue } = useTabsContext();
|
const { value: activeValue } = useTabsContext();
|
||||||
|
const isActive = activeValue === value;
|
||||||
|
|
||||||
if (activeValue !== value) {
|
if (!isActive && !forceMount) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
hidden={!isActive}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0",
|
"mt-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0",
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
|
import { existsSync } from "node:fs";
|
||||||
|
|
||||||
import { defineConfig } from "drizzle-kit";
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
|
// drizzle-kit (unlike the Next.js app) does not load .env automatically, so
|
||||||
|
// DATABASE_URL would be undefined and fall back to the wrong host. Load .env
|
||||||
|
// for local dev. In production the env is already provided (docker-compose)
|
||||||
|
// and no .env file exists, so this is skipped.
|
||||||
|
if (!process.env.DATABASE_URL && existsSync(".env")) {
|
||||||
|
process.loadEnvFile(".env");
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
schema: "./lib/db/schema.ts",
|
schema: "./lib/db/schema.ts",
|
||||||
out: "./lib/db/migrations",
|
out: "./lib/db/migrations",
|
||||||
|
|||||||
@@ -136,13 +136,21 @@ export function getPortfolioWizardProgress(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "sections",
|
key: "sections",
|
||||||
complete: input.sections.length > 0 && completedSections === input.sections.length,
|
// Optional: no sections is valid. If sections were added, each must be ready.
|
||||||
summary: `${completedSections}/${input.sections.length} sections ready.`,
|
complete: completedSections === input.sections.length,
|
||||||
|
summary:
|
||||||
|
input.sections.length === 0
|
||||||
|
? "Optional — no sections added."
|
||||||
|
: `${completedSections}/${input.sections.length} sections ready.`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "assets",
|
key: "assets",
|
||||||
complete: input.assets.length > 0 && completedAssets === input.assets.length,
|
// Optional: no gallery assets is valid. If assets were added, each must be ready.
|
||||||
summary: `${completedAssets}/${input.assets.length} assets ready.`,
|
complete: completedAssets === input.assets.length,
|
||||||
|
summary:
|
||||||
|
input.assets.length === 0
|
||||||
|
? "Optional — no gallery assets added."
|
||||||
|
: `${completedAssets}/${input.assets.length} assets ready.`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,3 +123,21 @@ export const projectInputSchema = z.object({
|
|||||||
sections: z.array(sectionInputSchema),
|
sections: z.array(sectionInputSchema),
|
||||||
assets: z.array(assetInputSchema),
|
assets: z.array(assetInputSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draft variant: the user-facing copy fields are optional so an unfinished
|
||||||
|
* project can be saved and completed later. The action still guarantees a
|
||||||
|
* slug, a category, and a year (auto-filled), and forces the project unpublished.
|
||||||
|
*/
|
||||||
|
export const projectDraftInputSchema = projectInputSchema.extend({
|
||||||
|
titleAr: optionalTrimmedText,
|
||||||
|
titleEn: optionalTrimmedText,
|
||||||
|
titleDe: optionalTrimmedText,
|
||||||
|
summaryAr: optionalTrimmedText,
|
||||||
|
summaryEn: optionalTrimmedText,
|
||||||
|
summaryDe: optionalTrimmedText,
|
||||||
|
serviceLabelAr: optionalTrimmedText,
|
||||||
|
serviceLabelEn: optionalTrimmedText,
|
||||||
|
serviceLabelDe: optionalTrimmedText,
|
||||||
|
clientName: optionalTrimmedText,
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
assetInputSchema,
|
assetInputSchema,
|
||||||
categoryInputSchema,
|
categoryInputSchema,
|
||||||
|
projectDraftInputSchema,
|
||||||
projectInputSchema,
|
projectInputSchema,
|
||||||
sectionInputSchema,
|
sectionInputSchema,
|
||||||
} from "../lib/portfolio-validation";
|
} from "../lib/portfolio-validation";
|
||||||
@@ -65,6 +66,64 @@ describe("portfolio validation", () => {
|
|||||||
).toThrow(/slug/i);
|
).toThrow(/slug/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("draft schema accepts empty user-facing copy that a strict save rejects", () => {
|
||||||
|
const draftPayload = {
|
||||||
|
categoryId: "cat_1",
|
||||||
|
slug: "draft-123",
|
||||||
|
viewMode: "GRID" as const,
|
||||||
|
titleAr: "",
|
||||||
|
titleEn: "",
|
||||||
|
titleDe: "",
|
||||||
|
summaryAr: "",
|
||||||
|
summaryEn: "",
|
||||||
|
summaryDe: "",
|
||||||
|
clientName: "",
|
||||||
|
projectYear: 2026,
|
||||||
|
serviceLabelAr: "",
|
||||||
|
serviceLabelEn: "",
|
||||||
|
serviceLabelDe: "",
|
||||||
|
previewUrl: "",
|
||||||
|
currentCoverImagePath: "",
|
||||||
|
sortOrder: 0,
|
||||||
|
isFeatured: false,
|
||||||
|
isPublished: false,
|
||||||
|
sections: [],
|
||||||
|
assets: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(projectDraftInputSchema.parse(draftPayload).slug).toBe("draft-123");
|
||||||
|
expect(() => projectInputSchema.parse(draftPayload)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draft schema still requires a slug and a valid year", () => {
|
||||||
|
const base = {
|
||||||
|
categoryId: "cat_1",
|
||||||
|
slug: "draft-1",
|
||||||
|
viewMode: "GRID" as const,
|
||||||
|
titleAr: "",
|
||||||
|
titleEn: "",
|
||||||
|
titleDe: "",
|
||||||
|
summaryAr: "",
|
||||||
|
summaryEn: "",
|
||||||
|
summaryDe: "",
|
||||||
|
clientName: "",
|
||||||
|
projectYear: 2026,
|
||||||
|
serviceLabelAr: "",
|
||||||
|
serviceLabelEn: "",
|
||||||
|
serviceLabelDe: "",
|
||||||
|
previewUrl: "",
|
||||||
|
currentCoverImagePath: "",
|
||||||
|
sortOrder: 0,
|
||||||
|
isFeatured: false,
|
||||||
|
isPublished: false,
|
||||||
|
sections: [],
|
||||||
|
assets: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => projectDraftInputSchema.parse({ ...base, slug: "" })).toThrow(/slug/i);
|
||||||
|
expect(() => projectDraftInputSchema.parse({ ...base, projectYear: 1999 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
it("accepts valid section and asset payloads", () => {
|
it("accepts valid section and asset payloads", () => {
|
||||||
expect(
|
expect(
|
||||||
sectionInputSchema.parse({
|
sectionInputSchema.parse({
|
||||||
|
|||||||
@@ -117,10 +117,29 @@ describe("getPortfolioWizardProgress", () => {
|
|||||||
expect(getFirstIncompleteWizardStep(progress)).toBe("content");
|
expect(getFirstIncompleteWizardStep(progress)).toBe("content");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sections/assets steps require at least one ready entry", () => {
|
it("treats sections/assets as optional: empty steps are complete and the project is saveable", () => {
|
||||||
const noEntries = getPortfolioWizardProgress({ ...completeInput, sections: [], assets: [] });
|
const noEntries = getPortfolioWizardProgress({ ...completeInput, sections: [], assets: [] });
|
||||||
expect(noEntries.find((s) => s.key === "sections")?.complete).toBe(false);
|
expect(noEntries.find((s) => s.key === "sections")?.complete).toBe(true);
|
||||||
expect(noEntries.find((s) => s.key === "assets")?.complete).toBe(false);
|
expect(noEntries.find((s) => s.key === "assets")?.complete).toBe(true);
|
||||||
|
// Basics + content only is enough to save.
|
||||||
|
expect(getFirstIncompleteWizardStep(noEntries)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an optional summary when no sections/assets are added", () => {
|
||||||
|
const noEntries = getPortfolioWizardProgress({ ...completeInput, sections: [], assets: [] });
|
||||||
|
expect(noEntries.find((s) => s.key === "sections")?.summary).toBe("Optional — no sections added.");
|
||||||
|
expect(noEntries.find((s) => s.key === "assets")?.summary).toBe(
|
||||||
|
"Optional — no gallery assets added.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks saving when an added section is incomplete", () => {
|
||||||
|
const progress = getPortfolioWizardProgress({
|
||||||
|
...completeInput,
|
||||||
|
sections: [section({ titleEn: "" })],
|
||||||
|
});
|
||||||
|
expect(progress.find((s) => s.key === "sections")?.complete).toBe(false);
|
||||||
|
expect(getFirstIncompleteWizardStep(progress)).toBe("sections");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("summarizes section/asset readiness counts", () => {
|
it("summarizes section/asset readiness counts", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user