Refactor portfolio admin and views
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -728,6 +728,9 @@ export function SiteSettingsForm({
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit">Save Settings</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user