Refactor portfolio admin and views

This commit is contained in:
MOH
2026-03-11 17:46:35 +01:00
parent 3c53430b12
commit 7956b073e9
27 changed files with 1972 additions and 1779 deletions
@@ -3,6 +3,7 @@
import { useState } from "react";
import { AppCard } from "@/components/ui/app-card";
import { Button } from "@/components/ui/button";
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
@@ -128,6 +129,9 @@ export function ContactProtectionForm({
</div>
</CardContent>
</AppCard>
<div className="xl:col-span-2 flex justify-end">
<Button type="submit">Save Protection</Button>
</div>
</form>
);
}
-304
View File
@@ -1,304 +0,0 @@
"use client";
import { LoaderCircle, Save } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
type FormSaveButtonProps = {
formId?: string;
formIds?: string[];
formSelector?: string;
formSelectors?: string[];
label?: string;
reloadDocumentOnSuccess?: boolean;
};
function serializeForm(form: HTMLFormElement) {
return JSON.stringify(
Array.from(new FormData(form).entries()).map(([key, value]) => [
key,
value instanceof File ? `${value.name}:${value.size}:${value.type}` : value,
]),
);
}
export function FormSaveButton({
formId,
formIds,
formSelector,
formSelectors,
label = "Speichern",
}: FormSaveButtonProps) {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const [activeFormId, setActiveFormId] = useState<string | null>(formId ?? null);
const [isDirty, setIsDirty] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const baselineRef = useRef<Map<string, string>>(new Map());
const dirtyFormsRef = useRef<Set<string>>(new Set());
const activeFormIdRef = useRef<string | null>(formId ?? null);
const frameRef = useRef<number | null>(null);
const pendingSubmissionRef = useRef<{
formId: string;
originUrl: string;
} | null>(null);
const currentUrl = `${pathname}?${searchParams.toString()}`;
useEffect(() => {
if (!formId && !formIds?.length && !formSelector && !formSelectors?.length) {
activeFormIdRef.current = null;
pendingSubmissionRef.current = null;
setActiveFormId(null);
setIsDirty(false);
setIsSubmitting(false);
dirtyFormsRef.current.clear();
}
}, [formId, formIds, formSelector, formSelectors]);
useEffect(() => {
const formsById = [formId, ...(formIds ?? [])]
.filter((value): value is string => Boolean(value))
.map((id) => document.getElementById(id))
.filter((form): form is HTMLFormElement => form instanceof HTMLFormElement);
const formsBySelector = [formSelector, ...(formSelectors ?? [])]
.filter((value): value is string => Boolean(value))
.flatMap((selector) => Array.from(document.querySelectorAll(selector)))
.filter((form): form is HTMLFormElement => form instanceof HTMLFormElement);
const forms = Array.from(new Map([...formsById, ...formsBySelector].map((form) => [form.id, form])).values());
if (forms.length === 0) {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
baselineRef.current.clear();
dirtyFormsRef.current.clear();
activeFormIdRef.current = formId ?? null;
setActiveFormId(formId ?? null);
setIsDirty(false);
setIsSubmitting(false);
return undefined;
}
const availableIds = forms.map((form) => form.id).filter(Boolean);
const fallbackFormId = availableIds[0] ?? null;
const selectDirtyFormId = () => {
const dirtyIds = availableIds.filter((id) => dirtyFormsRef.current.has(id));
if (dirtyIds.length === 0) {
return activeFormIdRef.current && availableIds.includes(activeFormIdRef.current)
? activeFormIdRef.current
: fallbackFormId;
}
if (activeFormIdRef.current && dirtyFormsRef.current.has(activeFormIdRef.current)) {
return activeFormIdRef.current;
}
return dirtyIds[0] ?? fallbackFormId;
};
const syncDirtyState = () => {
const nextActiveFormId = selectDirtyFormId();
activeFormIdRef.current = nextActiveFormId;
setActiveFormId(nextActiveFormId);
setIsDirty(dirtyFormsRef.current.size > 0);
};
const evaluateForm = (form: HTMLFormElement) => {
const baseline = baselineRef.current.get(form.id);
const current = serializeForm(form);
if (baseline === undefined) {
return;
}
if (current !== baseline) {
dirtyFormsRef.current.add(form.id);
return;
}
dirtyFormsRef.current.delete(form.id);
};
const scheduleSync = (nextActiveFormId?: string) => {
if (nextActiveFormId) {
activeFormIdRef.current = nextActiveFormId;
}
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
}
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null;
for (const form of forms) {
evaluateForm(form);
}
syncDirtyState();
});
};
const syncBaseline = (form: HTMLFormElement) => {
baselineRef.current.set(form.id, serializeForm(form));
dirtyFormsRef.current.delete(form.id);
syncDirtyState();
setIsSubmitting(false);
};
const handleFormActivity = (form: HTMLFormElement) => {
setIsSubmitting(false);
scheduleSync(form.id);
};
const handleSubmit = (form: HTMLFormElement, event: SubmitEvent) => {
if (pendingSubmissionRef.current) {
event.preventDefault();
event.stopPropagation();
return;
}
activeFormIdRef.current = form.id;
pendingSubmissionRef.current = {
formId: form.id,
originUrl: currentUrl,
};
setActiveFormId(form.id);
setIsSubmitting(true);
setIsDirty(false);
};
for (const form of forms) {
if (!form.id) {
continue;
}
syncBaseline(form);
const onFocusIn = () => handleFormActivity(form);
const onInput = () => handleFormActivity(form);
const onChange = () => handleFormActivity(form);
const onReset = () => syncBaseline(form);
const onSubmit = (event: Event) => handleSubmit(form, event as SubmitEvent);
form.addEventListener("focusin", onFocusIn);
form.addEventListener("input", onInput);
form.addEventListener("change", onChange);
form.addEventListener("reset", onReset);
form.addEventListener("submit", onSubmit);
(form as HTMLFormElement & {
__saveButtonHandlers?: {
onFocusIn: () => void;
onInput: () => void;
onChange: () => void;
onReset: () => void;
onSubmit: (event: Event) => void;
};
}).__saveButtonHandlers = { onFocusIn, onInput, onChange, onReset, onSubmit };
}
const nextActive =
activeFormIdRef.current && availableIds.includes(activeFormIdRef.current)
? activeFormIdRef.current
: fallbackFormId;
activeFormIdRef.current = nextActive;
setActiveFormId(nextActive);
for (const form of forms) {
evaluateForm(form);
}
syncDirtyState();
return () => {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
for (const form of forms) {
const handlers = (form as HTMLFormElement & {
__saveButtonHandlers?: {
onFocusIn: () => void;
onInput: () => void;
onChange: () => void;
onReset: () => void;
onSubmit: (event: Event) => void;
};
}).__saveButtonHandlers;
if (!handlers) {
continue;
}
form.removeEventListener("focusin", handlers.onFocusIn);
form.removeEventListener("input", handlers.onInput);
form.removeEventListener("change", handlers.onChange);
form.removeEventListener("reset", handlers.onReset);
form.removeEventListener("submit", handlers.onSubmit);
delete (
form as HTMLFormElement & {
__saveButtonHandlers?: {
onFocusIn: () => void;
onInput: () => void;
onChange: () => void;
onReset: () => void;
onSubmit: (event: Event) => void;
};
}
).__saveButtonHandlers;
}
};
}, [currentUrl, formId, formIds, formSelector, formSelectors, pathname, searchParams]);
useEffect(() => {
const pendingSubmission = pendingSubmissionRef.current;
if (!pendingSubmission) {
return;
}
const hasError = searchParams.has("error");
const hasSuccess = searchParams.has("success") || searchParams.has("__saved");
const navigated = currentUrl !== pendingSubmission.originUrl;
if (hasError) {
pendingSubmissionRef.current = null;
setIsSubmitting(false);
dirtyFormsRef.current.delete(pendingSubmission.formId);
return;
}
if (!hasSuccess && !navigated) {
return;
}
pendingSubmissionRef.current = null;
setIsSubmitting(false);
dirtyFormsRef.current.delete(pendingSubmission.formId);
if (!searchParams.has("__saved")) {
return;
}
const nextParams = new URLSearchParams(searchParams.toString());
nextParams.delete("__saved");
const nextQuery = nextParams.toString();
router.replace(nextQuery ? `${pathname}?${nextQuery}` : pathname, {
scroll: false,
});
}, [currentUrl, pathname, router, searchParams]);
return (
<Button type="submit" form={activeFormId ?? undefined} disabled={!activeFormId || !isDirty || isSubmitting}>
{isSubmitting ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{isSubmitting ? "Speichert..." : label}
</Button>
);
}
@@ -1,4 +1,5 @@
import { AppCard } from "@/components/ui/app-card";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import type { MarqueeSettings } from "@/lib/marquee-settings";
@@ -39,6 +40,9 @@ export function MarqueeSettingsForm({
</AppCard>
))}
</div>
<div className="flex justify-end">
<Button type="submit">Save Marquee</Button>
</div>
</form>
);
}
+109 -173
View File
@@ -3,14 +3,23 @@
/* eslint-disable @next/next/no-img-element */
import type { MediaKind } from "@prisma/client";
import { Check, Link2, Search, Type, Upload } from "lucide-react";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { Check, ImageIcon, Search, Trash2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { MediaOption } from "@/lib/media";
import { cn } from "@/lib/utils";
import { AppCard } from "@/components/ui/app-card";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
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";
export type MediaFieldState = {
mode: "upload" | "external" | "library";
@@ -29,11 +38,6 @@ type MediaFieldPickerProps = {
hasInitialValue?: boolean;
inputName: string;
fileFieldName: string;
fileLabel?: string;
externalLabel?: string;
libraryLabel?: string;
accept?: string;
allowExternal?: boolean;
allowClear?: boolean;
clearLabel?: string;
emptyValue?: Partial<MediaFieldState>;
@@ -46,58 +50,32 @@ export function MediaFieldPicker({
options,
hasInitialValue = false,
inputName,
fileFieldName,
fileLabel,
externalLabel,
libraryLabel,
accept,
allowExternal = true,
allowClear = false,
clearLabel = "Remove",
emptyValue,
}: MediaFieldPickerProps) {
const hiddenInputRef = useRef<HTMLInputElement | null>(null);
const searchId = useId();
const [libraryQuery, setLibraryQuery] = useState("");
const modeOptions: Array<MediaFieldState["mode"]> = allowExternal
? ["upload", "external", "library"]
: ["upload", "library"];
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const filteredOptions = options.filter((option) => option.kind === value.kind);
const selectedOption = filteredOptions.find((option) => option.id === value.assetId) ?? null;
const visibleLibraryOptions = useMemo(() => {
const normalizedQuery = libraryQuery.trim().toLowerCase();
const visibleOptions = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) {
return filteredOptions;
}
return filteredOptions.filter((option) => option.label.toLowerCase().includes(normalizedQuery));
}, [filteredOptions, libraryQuery]);
const resolvedLabel =
value.mode === "library"
? selectedOption?.label ?? value.label
: value.label;
}, [filteredOptions, query]);
const serializedValue = JSON.stringify({
mode: value.mode,
assetId: value.assetId,
url: value.url,
label: resolvedLabel,
label: value.label,
kind: value.kind,
});
const previewUrl =
value.isCleared
? ""
: value.mode === "library"
? selectedOption?.url ?? ""
: value.mode === "external"
? value.url
: ""
;
const hasCurrentValue =
!value.isCleared &&
((value.mode === "library" && value.assetId.trim() !== "") ||
(value.mode === "external" && value.url.trim() !== ""));
const canClear = allowClear && (hasCurrentValue || (hasInitialValue && !value.isCleared));
const canClear = allowClear && (Boolean(value.assetId) || (hasInitialValue && !value.isCleared));
useEffect(() => {
const hiddenInput = hiddenInputRef.current;
@@ -111,174 +89,132 @@ export function MediaFieldPicker({
}, [serializedValue]);
return (
<AppCard padding="sm" className="space-y-3">
<div className="space-y-2">
<Label className="sr-only">{title}</Label>
<div className="flex flex-wrap gap-2">
{modeOptions.map((mode) => (
<button
key={mode}
type="button"
onClick={() =>
onChange({
...value,
mode,
assetId: mode === "library" ? value.assetId : "",
url: mode === "external" ? value.url : "",
isCleared: false,
})
}
className={cn(
"rounded-nested border px-4 py-2 text-sm transition-colors",
value.mode === mode
? "border-input bg-primary text-primary-foreground"
: "border-input bg-background text-foreground/75 hover:bg-accent hover:text-accent-foreground",
)}
>
{mode}
</button>
))}
<AppCard level={2} padding="sm" className="space-y-4">
<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>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={() => setOpen(true)}>
<ImageIcon className="h-4 w-4" />
Select from Media
</Button>
{canClear ? (
<button
<Button
type="button"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() =>
onChange({
...value,
mode: emptyValue?.mode ?? "upload",
assetId: emptyValue?.assetId ?? "",
url: emptyValue?.url ?? "",
label: emptyValue?.label ?? value.label,
label: emptyValue?.label ?? "",
isCleared: true,
})
}
className="rounded-nested border border-destructive/25 px-4 py-2 text-sm text-destructive transition-colors hover:bg-destructive/5"
>
<Trash2 className="h-4 w-4" />
{clearLabel}
</button>
</Button>
) : null}
</div>
</div>
<input
ref={hiddenInputRef}
type="hidden"
name={inputName}
value={serializedValue}
/>
{value.mode !== "library" ? (
<div className="space-y-2">
<Label className="sr-only">Label</Label>
<div className="relative">
<Type className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={value.label}
onChange={(event) => onChange({ ...value, label: event.target.value, isCleared: false })}
placeholder="Label"
className="pl-9"
<AppCard 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>
) : null}
{value.mode === "upload" ? (
<div className="space-y-2">
<Label className="sr-only">{fileLabel ?? fileFieldName}</Label>
<div className="relative">
<Upload className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input name={fileFieldName} type="file" accept={accept} className="pl-9 file:mr-3" />
) : (
<div className="rounded-nested border border-dashed border-border/70 px-4 py-6 text-sm text-muted-foreground">
No media selected.
</div>
</div>
) : null}
)}
</AppCard>
{value.mode === "external" ? (
<div className="space-y-2">
<Label className="sr-only">{externalLabel ?? "External URL"}</Label>
<div className="relative">
<Link2 className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={value.url}
onChange={(event) => onChange({ ...value, url: event.target.value, isCleared: false })}
placeholder={externalLabel ?? "External URL"}
className="pl-9"
/>
</div>
</div>
) : null}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>Select an existing item from the media library.</DialogDescription>
</DialogHeader>
{value.mode === "library" ? (
<div className="space-y-3">
<Label className="sr-only">{libraryLabel ?? "Media Library"}</Label>
<div className="space-y-2 rounded-nested border border-border/80 bg-card p-3">
<div className="space-y-4">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id={searchId}
value={libraryQuery}
onChange={(event) => setLibraryQuery(event.target.value)}
className="pl-9"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search media"
className="pl-9"
/>
</div>
<div className="max-h-64 space-y-2 overflow-y-auto">
{visibleLibraryOptions.length > 0 ? (
visibleLibraryOptions.map((option) => {
const isActive = option.id === value.assetId;
<div className="grid max-h-[55vh] gap-3 overflow-y-auto md:grid-cols-2 xl:grid-cols-3">
{visibleOptions.map((option) => {
const isActive = option.id === value.assetId;
return (
<button
key={option.id}
type="button"
onClick={() =>
onChange({
...value,
assetId: option.id,
label: option.label,
isCleared: false,
})
}
className={cn(
"flex w-full items-center gap-3 rounded-nested border px-3 py-2 text-left transition-colors",
isActive
? "border-input bg-accent/40"
: "border-input bg-background hover:bg-accent/20",
)}
>
{option.url ? (
<img src={option.url} alt={option.label} className="h-12 w-12 rounded-nested object-cover" />
) : (
<div className="h-12 w-12 rounded-nested border border-border bg-background" />
)}
<div className="min-w-0 flex-1">
return (
<button
key={option.id}
type="button"
onClick={() => {
onChange({
...value,
mode: "library",
assetId: option.id,
url: option.url,
label: option.label,
isCleared: false,
});
setOpen(false);
}}
className={cn(
"overflow-hidden rounded-surface border text-left transition-colors",
isActive
? "border-input bg-accent/20"
: "border-border/70 bg-card hover:border-input hover:bg-accent/10",
)}
>
<img src={option.url} alt={option.label} className="h-40 w-full object-cover" />
<div className="flex items-center justify-between gap-3 p-4">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">{option.label}</p>
<p className="truncate text-xs text-muted-foreground">{option.source}</p>
</div>
{isActive ? <Check className="h-4 w-4 text-brand-primary" /> : null}
</button>
);
})
) : (
<div className="rounded-nested border border-dashed border-input px-3 py-4 text-sm text-muted-foreground">
No media found.
</div>
)}
</div>
</button>
);
})}
</div>
</div>
</div>
) : null}
{previewUrl ? (
value.kind === "IMAGE" ? (
<AppCard className="overflow-hidden rounded-nested">
<img src={previewUrl} alt={value.label || title} className="h-40 w-full object-cover" />
</AppCard>
) : (
<AppCard className="rounded-nested px-4 py-3 text-sm text-muted-foreground">
{previewUrl}
</AppCard>
)
) : null}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</AppCard>
);
}
+256 -181
View File
@@ -2,7 +2,16 @@
import { useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { FileText, FolderPlus, Hash, Layers3, Pencil, Text, Trash2 } from "lucide-react";
import {
FileText,
FolderPlus,
Hash,
Layers3,
Pencil,
Sparkles,
Text,
Trash2,
} from "lucide-react";
import type { deleteCategoryAction, upsertCategoryAction } from "@/app/root/portfolio/actions";
import { StatsCard } from "@/components/dashboard/stats-card";
@@ -23,35 +32,48 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import type { PortfolioCategoryView } from "@/lib/portfolio";
const locales = [
{ key: "Ar", lowerKey: "ar" as const, label: "Arabic" },
{ key: "En", lowerKey: "en" as const, label: "English" },
{ key: "De", lowerKey: "de" as const, label: "German" },
{ key: "Ar", label: "Arabic" },
{ key: "En", label: "English" },
{ key: "De", label: "German" },
] as const;
const copy = {
addCategory: "Kategorie hinzufuegen",
saveCategory: "Kategorie speichern",
save: "Speichern",
delete: "Loeschen",
active: "Aktiv",
sortOrder: "Sortierung",
projects: "Projekte",
description: "Beschreibung",
currentCategories: "Aktuelle Kategorien",
modalDescription: "Neue Kategorie direkt im Popup anlegen.",
editDescription: "Kategorie im Popup aendern oder loeschen.",
empty: "Noch keine Kategorien vorhanden.",
deleteBlocked: "Loeschen erst moeglich, wenn keine Projekte mehr zugeordnet sind.",
editCategory: "Kategorie bearbeiten",
addCategory: "Add Category",
saveCategory: "Save Category",
save: "Save",
delete: "Delete",
active: "Active",
sortOrder: "Sort Order",
projects: "Projects",
description: "Description",
currentCategories: "Current Categories",
modalDescription: "Create a new category with a faster flow for basics, localization, and status.",
editDescription: "Update category content, change status, or remove the category if it has no assigned projects.",
empty: "No categories yet.",
deleteBlocked: "Delete becomes available only when no projects are assigned.",
editCategory: "Edit Category",
};
type CategoryAction = typeof upsertCategoryAction;
type CategoryDeleteAction = typeof deleteCategoryAction;
type CategoryFormValues = {
slug?: string;
sortOrder?: number;
isActive?: boolean;
nameAr?: string;
nameEn?: string;
nameDe?: string;
descriptionAr?: string;
descriptionEn?: string;
descriptionDe?: string;
};
type PortfolioCategoriesManagerProps = {
categories: Array<PortfolioCategoryView & { projectCount: number }>;
activeCount: number;
@@ -65,64 +87,134 @@ function CategoryLocaleFields({
values,
}: {
idPrefix: string;
values?: {
nameAr?: string;
nameEn?: string;
nameDe?: string;
descriptionAr?: string;
descriptionEn?: string;
descriptionDe?: string;
};
values?: CategoryFormValues;
}) {
return (
<div className="grid gap-4 xl:grid-cols-3">
{locales.map((locale) => {
const nameKey = `name${locale.key}` as const;
const descriptionKey = `description${locale.key}` as const;
<div className="space-y-4">
<div className="space-y-1">
<p className="text-sm font-semibold text-foreground">Localized Content</p>
<p className="text-sm text-muted-foreground">
Keep names and descriptions ready in all supported locales.
</p>
</div>
return (
<AppCard key={`${idPrefix}-${locale.key}`} level={2} padding="sm" className="space-y-4 rounded-nested">
<div className="space-y-1">
<div className="grid gap-4 xl:grid-cols-3">
{locales.map((locale) => {
const nameKey = `name${locale.key}` as const;
const descriptionKey = `description${locale.key}` as const;
return (
<AppCard key={`${idPrefix}-${locale.key}`} level={2} padding="sm" className="space-y-4 rounded-nested">
<p className="text-sm font-medium text-foreground">{locale.label}</p>
</div>
<div className="space-y-2">
<Label htmlFor={`${idPrefix}-${nameKey}`} className="sr-only">{`Name ${locale.label}`}</Label>
<div className="relative">
<Text className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id={`${idPrefix}-${nameKey}`}
name={nameKey}
defaultValue={values?.[nameKey] ?? ""}
required
placeholder={`Name ${locale.label}`}
className="pl-9"
/>
<div className="space-y-2">
<Label htmlFor={`${idPrefix}-${nameKey}`}>Name</Label>
<div className="relative">
<Text className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id={`${idPrefix}-${nameKey}`}
name={nameKey}
defaultValue={values?.[nameKey] ?? ""}
required
placeholder={`Name ${locale.label}`}
className="pl-9"
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`${idPrefix}-${descriptionKey}`} className="sr-only">{`${copy.description} ${locale.label}`}</Label>
<div className="relative">
<FileText className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Textarea
id={`${idPrefix}-${descriptionKey}`}
name={descriptionKey}
rows={5}
defaultValue={values?.[descriptionKey] ?? ""}
required
placeholder={`${copy.description} ${locale.label}`}
className="pl-9"
/>
<div className="space-y-2">
<Label htmlFor={`${idPrefix}-${descriptionKey}`}>{copy.description}</Label>
<div className="relative">
<FileText className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Textarea
id={`${idPrefix}-${descriptionKey}`}
name={descriptionKey}
rows={5}
defaultValue={values?.[descriptionKey] ?? ""}
required
placeholder={`${copy.description} ${locale.label}`}
className="pl-9"
/>
</div>
</div>
</div>
</AppCard>
);
})}
</AppCard>
);
})}
</div>
</div>
);
}
function CategoryForm({
formId,
action,
values,
categoryId,
}: {
formId: string;
action: CategoryAction;
values?: CategoryFormValues;
categoryId?: string;
}) {
return (
<form id={formId} action={action} className="space-y-6">
{categoryId ? <input type="hidden" name="id" value={categoryId} /> : null}
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
<div className="space-y-4">
<div className="space-y-1">
<p className="text-sm font-semibold text-foreground">Basics</p>
<p className="text-sm text-muted-foreground">
Set the stable identifier and display order first.
</p>
</div>
<div className="grid gap-4 lg:grid-cols-3">
<div className="space-y-2">
<Label htmlFor={`${formId}-slug`}>Slug</Label>
<div className="relative">
<Text className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id={`${formId}-slug`}
name="slug"
defaultValue={values?.slug ?? ""}
required
placeholder="Slug"
className="pl-9"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`${formId}-sortOrder`}>{copy.sortOrder}</Label>
<div className="relative">
<Hash className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id={`${formId}-sortOrder`}
name="sortOrder"
type="number"
min="0"
defaultValue={values?.sortOrder ?? 0}
required
className="pl-9"
/>
</div>
</div>
<label className="flex items-center gap-3 rounded-nested border border-input bg-card px-4 py-3 text-sm">
<Checkbox name="isActive" defaultChecked={values?.isActive ?? true} />
{copy.active}
</label>
</div>
</div>
<Separator />
<CategoryLocaleFields idPrefix={formId} values={values} />
</form>
);
}
function EditCategoryDialog({
category,
open,
@@ -136,6 +228,8 @@ function EditCategoryDialog({
saveCategoryAction: CategoryAction;
removeCategoryAction: CategoryDeleteAction;
}) {
const formId = `portfolio-category-form-${category.id}`;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
@@ -144,57 +238,45 @@ function EditCategoryDialog({
<DialogDescription>{copy.editDescription}</DialogDescription>
</DialogHeader>
<form id={`portfolio-category-form-${category.id}`} action={saveCategoryAction} className="space-y-6">
<input type="hidden" name="id" value={category.id} />
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
<div className="grid gap-4 lg:grid-cols-3">
<div className="space-y-2">
<Label htmlFor={`slug-${category.id}`}>Slug</Label>
<div className="relative">
<Text className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input id={`slug-${category.id}`} name="slug" defaultValue={category.slug} required className="pl-9" />
</div>
<div className="grid gap-3 sm:grid-cols-3">
<AppCard level={2} padding="sm" className="rounded-nested">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Projects</p>
<p className="mt-2 text-lg font-semibold text-foreground">{category.projectCount}</p>
</AppCard>
<AppCard level={2} padding="sm" className="rounded-nested">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Status</p>
<div className="mt-2">
<Badge variant={category.isActive ? "success" : "warning"}>
{category.isActive ? "Active" : "Inactive"}
</Badge>
</div>
</AppCard>
<AppCard level={2} padding="sm" className="rounded-nested">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Order</p>
<p className="mt-2 text-lg font-semibold text-foreground">{category.sortOrder}</p>
</AppCard>
</div>
<div className="space-y-2">
<Label htmlFor={`sortOrder-${category.id}`}>{copy.sortOrder}</Label>
<div className="relative">
<Hash className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id={`sortOrder-${category.id}`}
name="sortOrder"
type="number"
min="0"
defaultValue={category.sortOrder}
required
className="pl-9"
/>
</div>
</div>
<label className="flex items-center gap-3 rounded-nested border border-input bg-card px-4 py-3 text-sm">
<Checkbox name="isActive" defaultChecked={category.isActive} />
{copy.active}
</label>
</div>
<CategoryLocaleFields
idPrefix={`category-${category.id}`}
values={{
nameAr: category.name.ar,
nameEn: category.name.en,
nameDe: category.name.de,
descriptionAr: category.description.ar,
descriptionEn: category.description.en,
descriptionDe: category.description.de,
}}
/>
</form>
<CategoryForm
formId={formId}
action={saveCategoryAction}
categoryId={category.id}
values={{
slug: category.slug,
sortOrder: category.sortOrder,
isActive: category.isActive,
nameAr: category.name.ar,
nameEn: category.name.en,
nameDe: category.name.de,
descriptionAr: category.description.ar,
descriptionEn: category.description.en,
descriptionDe: category.description.de,
}}
/>
<DialogFooter className="items-center justify-between sm:flex-row">
<p className="text-sm text-muted-foreground">
{category.projectCount > 0 ? copy.deleteBlocked : "Kategorie kann geloescht werden."}
{category.projectCount > 0 ? copy.deleteBlocked : "Category can be deleted."}
</p>
<div className="flex w-full flex-col-reverse gap-2 sm:w-auto sm:flex-row">
<form action={removeCategoryAction}>
@@ -205,7 +287,7 @@ function EditCategoryDialog({
{copy.delete}
</Button>
</form>
<Button type="submit" form={`portfolio-category-form-${category.id}`}>
<Button type="submit" form={formId}>
{copy.save}
</Button>
</div>
@@ -235,7 +317,7 @@ export function PortfolioCategoriesManager({
return (
<div className="space-y-6">
<AppCard level={3}>
<AppCard level={3}>
<CardContent className="flex flex-col gap-6 p-6 lg:flex-row lg:items-end lg:justify-between lg:p-8">
<div className="grid gap-4 sm:grid-cols-3">
<StatsCard title="Total" value={String(categories.length)} />
@@ -256,38 +338,34 @@ export function PortfolioCategoriesManager({
<DialogDescription>{copy.modalDescription}</DialogDescription>
</DialogHeader>
<form id="portfolio-category-create-form" action={saveCategoryAction} className="space-y-6">
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
<div className="grid gap-3 sm:grid-cols-3">
<AppCard level={2} padding="sm" className="rounded-nested">
<Sparkles className="h-4 w-4 text-brand-primary" />
<p className="mt-3 text-sm font-medium text-foreground">Start with basics</p>
<p className="mt-1 text-sm text-muted-foreground">Slug and sort order first.</p>
</AppCard>
<AppCard level={2} padding="sm" className="rounded-nested">
<Layers3 className="h-4 w-4 text-brand-primary" />
<p className="mt-3 text-sm font-medium text-foreground">Fill all locales</p>
<p className="mt-1 text-sm text-muted-foreground">Keep names and descriptions complete.</p>
</AppCard>
<AppCard level={2} padding="sm" className="rounded-nested">
<Pencil className="h-4 w-4 text-brand-primary" />
<p className="mt-3 text-sm font-medium text-foreground">Publish when ready</p>
<p className="mt-1 text-sm text-muted-foreground">Categories stay manageable from day one.</p>
</AppCard>
</div>
<div className="grid gap-4 lg:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="create-slug">Slug</Label>
<div className="relative">
<Text className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input id="create-slug" name="slug" required placeholder="Slug" className="pl-9" />
</div>
</div>
<CategoryForm
formId="portfolio-category-create-form"
action={saveCategoryAction}
/>
<div className="space-y-2">
<Label htmlFor="create-sortOrder">{copy.sortOrder}</Label>
<div className="relative">
<Hash className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input id="create-sortOrder" name="sortOrder" type="number" min="0" defaultValue="0" required className="pl-9" />
</div>
</div>
<label className="flex items-center gap-3 rounded-nested border border-input bg-card px-4 py-3 text-sm">
<Checkbox name="isActive" defaultChecked />
{copy.active}
</label>
</div>
<CategoryLocaleFields idPrefix="create" />
<DialogFooter>
<Button type="submit">{copy.saveCategory}</Button>
</DialogFooter>
</form>
<DialogFooter>
<Button type="submit" form="portfolio-category-create-form">
{copy.saveCategory}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
@@ -307,62 +385,59 @@ export function PortfolioCategoriesManager({
{categories.map((category) => (
<AccordionItem key={category.id} value={category.id}>
<AccordionTrigger className="bg-surface-2 hover:no-underline">
<div className="flex min-w-0 flex-1 flex-col gap-2 text-left lg:flex-row lg:items-center lg:justify-between">
<div className="flex min-w-0 flex-1 flex-col gap-3 text-left lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-base font-semibold text-foreground">
{category.name.de || category.name.en || category.name.ar}
</span>
<Badge variant={category.isActive ? "success" : "warning"}>
{category.isActive ? "Aktiv" : "Inaktiv"}
{category.isActive ? "Active" : "Inactive"}
</Badge>
</div>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>{category.slug}</span>
<span></span>
<span>{copy.sortOrder} {category.sortOrder}</span>
<span>{copy.projects}: {category.projectCount}</span>
<span>{copy.sortOrder}: {category.sortOrder}</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{category.projectCount} {copy.projects}</Badge>
<Button
type="button"
variant="outline"
size="sm"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setEditingCategoryId(category.id);
}}
>
<Pencil className="h-3.5 w-3.5" />
{copy.editCategory}
</Button>
</div>
<Button
type="button"
variant="outline"
onClick={(event) => {
event.preventDefault();
setEditingCategoryId(category.id);
}}
>
<Pencil className="h-4 w-4" />
Edit
</Button>
</div>
</AccordionTrigger>
<AccordionContent className="bg-background">
<div className="space-y-3">
<div className="grid gap-3 md:grid-cols-3">
{locales.map((locale) => (
<AppCard key={`${category.id}-${locale.key}`} level={1} padding="sm" className="rounded-nested">
<p className="text-sm font-medium text-foreground">{locale.label}</p>
<p className="mt-3 text-sm text-foreground">{category.name[locale.lowerKey]}</p>
<p className="mt-2 text-sm text-muted-foreground">{category.description[locale.lowerKey]}</p>
</AppCard>
))}
</div>
<AccordionContent className="space-y-4">
<div className="grid gap-4 xl:grid-cols-3">
{locales.map((locale) => (
<AppCard key={`${category.id}-${locale.key}`} level={2} padding="sm" className="space-y-3 rounded-nested">
<p className="text-sm font-medium text-foreground">{locale.label}</p>
<p className="text-sm font-semibold text-foreground">
{category.name[locale.key.toLowerCase() as "ar" | "en" | "de"]}
</p>
<p className="text-sm leading-6 text-muted-foreground">
{category.description[locale.key.toLowerCase() as "ar" | "en" | "de"]}
</p>
</AppCard>
))}
</div>
</AccordionContent>
<EditCategoryDialog
category={category}
open={editingCategoryId === category.id}
onOpenChange={(open) => setEditingCategoryId(open ? category.id : null)}
saveCategoryAction={saveCategoryAction}
removeCategoryAction={removeCategoryAction}
/>
<EditCategoryDialog
category={category}
open={editingCategoryId === category.id}
onOpenChange={(open) => setEditingCategoryId(open ? category.id : null)}
saveCategoryAction={saveCategoryAction}
removeCategoryAction={removeCategoryAction}
/>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
@@ -0,0 +1,80 @@
"use client";
import { FolderKanban, MoreVertical, Trash2 } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { deleteProjectAction } from "@/app/root/portfolio/actions";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const copy = {
editProject: "Bearbeiten",
deleteProject: "Loeschen",
};
export function PortfolioProjectActions({ projectId }: { projectId: string }) {
const [confirmOpen, setConfirmOpen] = useState(false);
return (
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" size="icon">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/root/portfolio/projects/${projectId}`}>
<FolderKanban className="mr-2 h-4 w-4" />
{copy.editProject}
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={(event) => {
event.preventDefault();
setConfirmOpen(true);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
{copy.deleteProject}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DialogContent>
<DialogHeader>
<DialogTitle>{copy.deleteProject}</DialogTitle>
<DialogDescription>
This action permanently removes the project from the portfolio.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<form action={deleteProjectAction}>
<input type="hidden" name="id" value={projectId} />
<Button type="submit" variant="destructive">
Confirm Delete
</Button>
</form>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
File diff suppressed because it is too large Load Diff
+56 -115
View File
@@ -1,20 +1,13 @@
import { ExternalLink, Filter, FolderKanban, Plus, Tags } from "lucide-react";
import { ExternalLink, Plus, 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/root/portfolio-project-actions";
import { AppCard } from "@/components/ui/app-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { CardContent } from "@/components/ui/card";
import { getLocalizedPath } from "@/lib/locale";
import { getLocalizedValue, type PortfolioCategoryView, type PortfolioProjectView } from "@/lib/portfolio";
@@ -26,16 +19,10 @@ type PortfolioProjectsOverviewProps = {
};
const copy = {
category: "Kategorie",
all: "Alle",
status: "Status",
draft: "Entwurf",
published: "Veroeffentlicht",
filter: "Filtern",
newProject: "Neues Projekt",
newCategory: "Neues Kategorie",
openProject: "Ansehen",
editProject: "Bearbeiten",
untitled: "Unbenanntes Projekt",
empty: "Noch keine Projekte vorhanden.",
};
@@ -44,112 +31,71 @@ export function PortfolioProjectsOverview({
categories,
projects,
selectedCategory,
selectedStatus,
}: PortfolioProjectsOverviewProps) {
return (
<div className="space-y-6">
<MotionFade delay={0.05}>
<AppCard level={3}>
<CardContent className="flex flex-col gap-6 p-6 lg:flex-row lg:items-end lg:justify-between lg:p-8">
<div className="grid gap-4 sm:grid-cols-3">
<StatsCard title="Projects" value={String(projects.length)} />
<StatsCard title="Categories" value={String(categories.length)} />
<StatsCard
title="Published"
value={String(projects.filter((project) => project.isPublished).length)}
/>
</div>
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="grid gap-4 md:grid-cols-3 xl:min-w-[520px]">
<StatsCard title="Projects" value={String(projects.length)} />
<StatsCard title="Categories" value={String(categories.length)} />
<StatsCard
title="Published"
value={String(projects.filter((project) => project.isPublished).length)}
/>
</div>
<div className="flex flex-wrap gap-3">
<Button asChild>
<Link href="/root/portfolio/projects/new">
<Plus className="h-4 w-4" />
{copy.newProject}
</Link>
</Button>
<Button asChild variant="outline">
<Link href="/root/portfolio/categories">
<Tags className="h-4 w-4" />
{copy.newCategory}
</Link>
</Button>
</div>
</CardContent>
</AppCard>
</MotionFade>
<div className="flex flex-wrap gap-2">
<Button asChild>
<Link href="/root/portfolio/projects/new">
<Plus className="h-4 w-4" />
{copy.newProject}
</Link>
</Button>
<Button asChild variant="outline">
<Link href="/root/portfolio/categories">
<Tags className="h-4 w-4" />
{copy.newCategory}
</Link>
</Button>
</div>
</div>
<MotionFade delay={0.1}>
<AppCard>
<CardContent className="p-6">
<form className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
<div className="space-y-2">
<Label htmlFor="portfolio-filter-category" className="sr-only">
{copy.category}
</Label>
<div className="relative">
<Tags className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Select name="category" defaultValue={selectedCategory || "__all__"}>
<SelectTrigger id="portfolio-filter-category" className="pl-9">
<SelectValue placeholder={copy.category} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">{copy.all}</SelectItem>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name.de}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="portfolio-filter-status" className="sr-only">
{copy.status}
</Label>
<div className="relative">
<Filter className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Select name="status" defaultValue={selectedStatus}>
<SelectTrigger id="portfolio-filter-status" className="pl-9">
<SelectValue placeholder={copy.status} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{copy.all}</SelectItem>
<SelectItem value="draft">{copy.draft}</SelectItem>
<SelectItem value="published">{copy.published}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-end">
<Button type="submit" variant="outline">
{copy.filter}
</Button>
</div>
</form>
</CardContent>
</AppCard>
</MotionFade>
<div className="flex flex-wrap gap-2">
<Button asChild variant={selectedCategory === "" ? "default" : "outline"}>
<Link href="/root/portfolio">{copy.all}</Link>
</Button>
{categories.map((category) => (
<Button
key={category.id}
asChild
variant={selectedCategory === category.id ? "default" : "outline"}
>
<Link href={`/root/portfolio?category=${category.id}`}>
{category.name.de || category.name.en || category.name.ar}
</Link>
</Button>
))}
</div>
<div className="grid gap-4">
{projects.map((project, index) => (
<MotionFade key={project.id} delay={0.14 + index * 0.03}>
<MotionFade key={project.id} delay={0.06 + index * 0.03}>
<AppCard interactive>
<CardHeader className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1">
<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">
<CardTitle className="text-xl">
<p className="text-xl font-semibold text-foreground">
{getLocalizedValue(project.title, "de") || copy.untitled}
</CardTitle>
</p>
<Badge variant={project.isPublished ? "success" : "warning"}>
{project.isPublished ? copy.published : copy.draft}
{project.isPublished ? "Published" : "Draft"}
</Badge>
<Badge variant="outline">{project.viewMode}</Badge>
</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>
<p className="text-sm text-muted-foreground">
{project.category.name.de}
</p>
</div>
<div className="flex flex-wrap gap-3">
@@ -163,14 +109,9 @@ export function PortfolioProjectsOverview({
{copy.openProject}
</Link>
</Button>
<Button asChild>
<Link href={`/root/portfolio/projects/${project.id}`}>
<FolderKanban className="h-4 w-4" />
{copy.editProject}
</Link>
</Button>
<PortfolioProjectActions projectId={project.id} />
</div>
</CardHeader>
</CardContent>
</AppCard>
</MotionFade>
))}
+11 -32
View File
@@ -15,7 +15,6 @@ import Link from "next/link";
import { DashboardLayout } from "@/components/dashboard/dashboard-layout";
import { MotionFade } from "@/components/motion-fade";
import { FormSaveButton } from "@/components/root/form-save-button";
import { SidebarMaintenanceControl } from "@/components/root/sidebar-maintenance-control";
import { SoundToggle } from "@/components/sound-toggle";
import { ThemeToggle } from "@/components/theme-toggle";
@@ -50,10 +49,6 @@ type RootDashboardShellProps = {
logoutAction: () => Promise<void>;
headerTitle: string;
headerDescription: string;
saveFormId?: string;
saveFormSelector?: string;
saveButtonLabel?: string;
reloadDocumentOnSave?: boolean;
headerActions?: ReactNode;
sidebarTopContent?: ReactNode;
toolbar?: ReactNode;
@@ -68,10 +63,6 @@ export async function RootDashboardShell({
logoutAction,
headerTitle,
headerDescription,
saveFormId,
saveFormSelector,
saveButtonLabel,
reloadDocumentOnSave = false,
headerActions,
sidebarTopContent,
toolbar,
@@ -112,28 +103,6 @@ export async function RootDashboardShell({
: portfolioChild === "new-project"
? PlusSquare
: FolderKanban;
const sharedActions = (
<>
<FormSaveButton
formIds={saveFormId ? ["sidebar-maintenance-form", saveFormId] : ["sidebar-maintenance-form"]}
formSelectors={saveFormSelector ? [saveFormSelector] : undefined}
label={saveButtonLabel ?? "Speichern"}
reloadDocumentOnSuccess={reloadDocumentOnSave}
/>
<SoundToggle
ariaLabel="Mute sounds"
mutedAriaLabel="Unmute sounds"
mutedToastLabel="Sound muted"
unmutedToastLabel="Sound enabled"
/>
<ThemeToggle
ariaLabel="Theme wechseln"
lightToastLabel="Light mode enabled"
darkToastLabel="Dark mode enabled"
/>
</>
);
return (
<DashboardLayout
title={headerTitle}
@@ -175,7 +144,17 @@ export async function RootDashboardShell({
headerActions={
<div className="flex flex-wrap items-center justify-end gap-2">
{headerActions}
{sharedActions}
<SoundToggle
ariaLabel="Mute sounds"
mutedAriaLabel="Unmute sounds"
mutedToastLabel="Sound muted"
unmutedToastLabel="Sound enabled"
/>
<ThemeToggle
ariaLabel="Theme wechseln"
lightToastLabel="Light mode enabled"
darkToastLabel="Dark mode enabled"
/>
</div>
}
>
+3
View File
@@ -728,6 +728,9 @@ export function SiteSettingsForm({
</section>
</div>
</div>
<div className="flex justify-end">
<Button type="submit">Save Settings</Button>
</div>
</form>
);
}
+4
View File
@@ -4,6 +4,7 @@ import { KeyRound, Mail, Send, Server } from "lucide-react";
import { useState } from "react";
import { AppCard } from "@/components/ui/app-card";
import { Button } from "@/components/ui/button";
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
@@ -171,6 +172,9 @@ export function SMTPSettingsForm({
</CardContent>
</AppCard>
</div>
<div className="flex justify-end">
<Button type="submit">Save SMTP</Button>
</div>
</form>
);
}
@@ -0,0 +1,457 @@
import {
ArrowUpRight,
CalendarDays,
FolderKanban,
Tag,
UserRound,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { MotionFade } from "@/components/motion-fade";
import { AppCard } from "@/components/ui/app-card";
import { Button } from "@/components/ui/button";
import { CardContent } from "@/components/ui/card";
import { getLocalizedPath, type AppLocale } from "@/lib/locale";
import {
getLocalizedValue,
resolvePortfolioProjectViewMode,
type PortfolioAssetView,
type PortfolioProjectView,
type PortfolioSectionView,
} from "@/lib/portfolio";
type PortfolioProjectDetailProps = {
item: PortfolioProjectView;
locale: AppLocale;
t: (key: "back" | "preview" | "openLink" | "gallery" | "download") => string;
};
function PortfolioImage({
src,
alt,
className,
width,
height,
}: {
src: string;
alt: string;
className: string;
width: number;
height: number;
}) {
return (
<Image
src={src}
alt={alt}
width={width}
height={height}
unoptimized
className={className}
/>
);
}
function ProjectMeta({
item,
locale,
}: {
item: PortfolioProjectView;
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,
},
].filter((entry) => entry.label);
return (
<div className="flex flex-wrap gap-3 text-sm text-foreground/80">
{metadata.map((meta) => {
const Icon = meta.icon;
return (
<AppCard key={`${meta.label}-${Icon.name}`} level={2}>
<CardContent className="flex items-center gap-2 p-3">
<Icon className="h-4 w-4 text-brand-primary" />
{meta.label}
</CardContent>
</AppCard>
);
})}
</div>
);
}
function SectionBlock({
section,
locale,
t,
}: {
section: PortfolioSectionView;
locale: AppLocale;
t: PortfolioProjectDetailProps["t"];
}) {
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="rounded-surface border border-dashed border-border px-4 py-12 text-center text-sm text-muted-foreground">
No image configured.
</div>
)}
</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 (
<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;
}
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}
</div>
);
}
function ProjectHeader({
item,
locale,
t,
}: {
item: PortfolioProjectView;
locale: 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")}>{t("back")}</Link>
</Button>
<h1 className="mt-5 text-3xl font-semibold text-foreground sm:text-4xl lg:text-5xl">
{getLocalizedValue(item.title, locale)}
</h1>
<p className="mt-4 max-w-3xl text-base leading-8 text-muted-foreground sm:text-lg">
{getLocalizedValue(item.summary, locale)}
</p>
<div className="mt-8">
<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,
t,
}: PortfolioProjectDetailProps) {
return (
<div className="space-y-6">
<ProjectHeader item={item} locale={locale} 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>
</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,
t,
}: PortfolioProjectDetailProps) {
return (
<div className="space-y-8">
<ProjectHeader item={item} locale={locale} 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} />
</div>
);
}
function CaseStudyTemplate({
item,
locale,
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} 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>
);
}
export function PortfolioProjectDetail({
item,
locale,
t,
}: PortfolioProjectDetailProps) {
const viewMode = resolvePortfolioProjectViewMode(item.viewMode);
if (viewMode === "STORY") {
return <StoryTemplate item={item} locale={locale} t={t} />;
}
if (viewMode === "CASE_STUDY") {
return <CaseStudyTemplate item={item} locale={locale} t={t} />;
}
return <GridTemplate item={item} locale={locale} t={t} />;
}