Refactor portfolio admin and views
This commit is contained in:
@@ -1,22 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import { ArrowLeft, ArrowUpRight, CalendarDays, FolderKanban, Tag, UserRound } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { Container } from "@/components/layout/container";
|
||||
import { PageHero } from "@/components/layout/page-hero";
|
||||
import { MotionFade } from "@/components/motion-fade";
|
||||
import { PortfolioProjectDetail } from "@/components/site/portfolio-project-detail";
|
||||
import { buildLocalizedMetadata } from "@/lib/metadata";
|
||||
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
|
||||
import { resolveLocale } from "@/lib/locale";
|
||||
import {
|
||||
getLocalizedValue,
|
||||
getPublishedPortfolioProjectBySlug,
|
||||
} from "@/lib/portfolio";
|
||||
import { AppCard } from "@/components/ui/app-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CardContent } from "@/components/ui/card";
|
||||
|
||||
type PortfolioItemPageProps = {
|
||||
params: {
|
||||
@@ -27,98 +21,6 @@ type PortfolioItemPageProps = {
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
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 renderSectionContent(
|
||||
section: NonNullable<Awaited<ReturnType<typeof getPublishedPortfolioProjectBySlug>>>["sections"][number],
|
||||
localeKey: ReturnType<typeof resolveLocale>,
|
||||
t: Awaited<ReturnType<typeof getTranslations>>,
|
||||
) {
|
||||
const title = getLocalizedValue(section.title, localeKey);
|
||||
const body = getLocalizedValue(section.body, localeKey);
|
||||
|
||||
if (section.type === "GALLERY") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
|
||||
{section.imagePath ? (
|
||||
<PortfolioImage
|
||||
src={section.imagePath}
|
||||
alt={title}
|
||||
width={1200}
|
||||
height={720}
|
||||
className="h-56 w-full rounded-md object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
No image configured.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (section.type === "LINK") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
|
||||
{body ? (
|
||||
<p className="whitespace-pre-line text-sm 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>
|
||||
);
|
||||
}
|
||||
|
||||
if (section.type === "STATS" || section.type === "DELIVERABLES") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
|
||||
<div className="rounded-md border border-input bg-background p-4">
|
||||
<p className="whitespace-pre-line text-sm text-muted-foreground">{body}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
|
||||
<p className="whitespace-pre-line text-sm text-muted-foreground">{body}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params: { locale, slug },
|
||||
}: PortfolioItemPageProps): Promise<Metadata> {
|
||||
@@ -161,129 +63,8 @@ export default async function PortfolioItemPage({
|
||||
description={getLocalizedValue(item.summary, localeKey)}
|
||||
/>
|
||||
|
||||
<Container size="wide" className="flex flex-col gap-section pb-12 lg:pb-16">
|
||||
<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(localeKey, "/portfolio")}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t("back")}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<h1 className="mt-5 text-3xl font-semibold text-foreground sm:text-4xl">
|
||||
{getLocalizedValue(item.title, localeKey)}
|
||||
</h1>
|
||||
<p className="mt-4 text-base text-muted-foreground sm:text-lg">
|
||||
{getLocalizedValue(item.summary, localeKey)}
|
||||
</p>
|
||||
|
||||
{item.coverImagePath ? (
|
||||
<div className="mt-6 overflow-hidden rounded-lg border border-border bg-card">
|
||||
<PortfolioImage
|
||||
src={item.coverImagePath}
|
||||
alt={getLocalizedValue(item.title, localeKey)}
|
||||
width={1600}
|
||||
height={900}
|
||||
className="h-auto w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3 text-sm text-foreground/80">
|
||||
{[
|
||||
{
|
||||
icon: Tag,
|
||||
label: getLocalizedValue(item.category.name, localeKey),
|
||||
},
|
||||
{
|
||||
icon: CalendarDays,
|
||||
label: String(item.projectYear),
|
||||
},
|
||||
{
|
||||
icon: FolderKanban,
|
||||
label: getLocalizedValue(item.serviceLabel, localeKey),
|
||||
},
|
||||
{
|
||||
icon: UserRound,
|
||||
label: item.clientName,
|
||||
},
|
||||
].map((meta) => {
|
||||
const Icon = meta.icon;
|
||||
|
||||
return (
|
||||
<AppCard key={meta.label} 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>
|
||||
|
||||
{item.previewUrl ? (
|
||||
<div className="mt-6">
|
||||
<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>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{item.sections.map((section, index) => (
|
||||
<MotionFade key={section.id} delay={0.05 * (index + 1)}>
|
||||
<AppCard>
|
||||
<CardContent className="p-5">
|
||||
{renderSectionContent(section, localeKey, t)}
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</MotionFade>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{item.assets.length > 0 ? (
|
||||
<MotionFade delay={0.1}>
|
||||
<AppCard>
|
||||
<CardContent className="p-6 lg:p-8">
|
||||
<h2 className="text-xl font-semibold text-foreground">{t("gallery")}</h2>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
{item.assets.map((asset) => (
|
||||
<div key={asset.id} className="overflow-hidden rounded-lg border border-border bg-card">
|
||||
{asset.kind === "IMAGE" ? (
|
||||
<PortfolioImage
|
||||
src={asset.filePath}
|
||||
alt={getLocalizedValue(asset.alt, localeKey)}
|
||||
width={1200}
|
||||
height={720}
|
||||
className="h-64 w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-64 items-center justify-center bg-muted/40 p-6 text-center text-sm text-muted-foreground">
|
||||
<div className="space-y-3">
|
||||
<p>{getLocalizedValue(asset.alt, localeKey)}</p>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={asset.filePath} target="_blank" rel="noreferrer">
|
||||
{t("download")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</MotionFade>
|
||||
) : null}
|
||||
<Container size="wide" className="pb-12 lg:pb-16">
|
||||
<PortfolioProjectDetail item={item} locale={localeKey} t={t} />
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -46,7 +46,6 @@ export default async function RootMarqueePage() {
|
||||
logoutAction={logoutAction}
|
||||
headerTitle={copy.title}
|
||||
headerDescription={copy.subtitle}
|
||||
saveFormId="marquee-settings-form"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<MotionFade delay={0.14}>
|
||||
|
||||
@@ -220,6 +220,7 @@ export async function saveProjectAction(formData: FormData) {
|
||||
id: String(formData.get("id") ?? "").trim() || undefined,
|
||||
categoryId: String(formData.get("categoryId") ?? ""),
|
||||
slug: String(formData.get("slug") ?? ""),
|
||||
viewMode: String(formData.get("viewMode") ?? "GRID"),
|
||||
titleAr: String(formData.get("titleAr") ?? ""),
|
||||
titleEn: String(formData.get("titleEn") ?? ""),
|
||||
titleDe: String(formData.get("titleDe") ?? ""),
|
||||
@@ -369,6 +370,7 @@ export async function saveProjectAction(formData: FormData) {
|
||||
data: {
|
||||
categoryId: parsed.categoryId,
|
||||
slug: parsed.slug,
|
||||
viewMode: parsed.viewMode,
|
||||
titleAr: parsed.titleAr,
|
||||
titleEn: parsed.titleEn,
|
||||
titleDe: parsed.titleDe,
|
||||
@@ -396,6 +398,7 @@ export async function saveProjectAction(formData: FormData) {
|
||||
data: {
|
||||
categoryId: parsed.categoryId,
|
||||
slug: parsed.slug,
|
||||
viewMode: parsed.viewMode,
|
||||
titleAr: parsed.titleAr,
|
||||
titleEn: parsed.titleEn,
|
||||
titleDe: parsed.titleDe,
|
||||
|
||||
@@ -6,6 +6,15 @@ import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
|
||||
import { AppCard } from "@/components/ui/app-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||
import { getMediaOptions } from "@/lib/media";
|
||||
import {
|
||||
@@ -72,8 +81,6 @@ export default async function RootPortfolioProjectPage({
|
||||
logoutAction={logoutAction}
|
||||
headerTitle={copy.title}
|
||||
headerDescription={copy.subtitle}
|
||||
saveFormId="portfolio-project-form"
|
||||
saveButtonLabel={copy.saveProject}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<MotionFade delay={0.15}>
|
||||
@@ -88,20 +95,37 @@ export default async function RootPortfolioProjectPage({
|
||||
</MotionFade>
|
||||
|
||||
<MotionFade delay={0.2}>
|
||||
<AppCard>
|
||||
<CardContent className="flex items-center justify-between gap-4 p-6">
|
||||
<AppCard level={2}>
|
||||
<CardContent className="flex flex-col gap-4 p-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">{copy.dangerZone}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{copy.dangerText}
|
||||
</p>
|
||||
</div>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="destructive">
|
||||
{copy.deleteProject}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copy.deleteProject}</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action permanently removes the project data from the database.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<form action={deleteProjectAction}>
|
||||
<input type="hidden" name="id" value={project.id} />
|
||||
<Button type="submit" variant="destructive">
|
||||
{copy.deleteProject}
|
||||
Confirm Delete
|
||||
</Button>
|
||||
</form>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</MotionFade>
|
||||
|
||||
@@ -49,7 +49,6 @@ export default async function RootNewPortfolioProjectPage() {
|
||||
logoutAction={logoutAction}
|
||||
headerTitle={copy.title}
|
||||
headerDescription={copy.subtitle}
|
||||
saveFormId="portfolio-project-form"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<MotionFade delay={0.15}>
|
||||
|
||||
@@ -54,7 +54,6 @@ export default async function RootSiteSettingsPage() {
|
||||
logoutAction={logoutAction}
|
||||
headerTitle={copy.title}
|
||||
headerDescription={copy.subtitle}
|
||||
saveFormId="site-settings-form"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<MotionFade delay={0.16}>
|
||||
|
||||
@@ -47,7 +47,6 @@ export default async function RootSMTPProtectionPage() {
|
||||
logoutAction={logoutAction}
|
||||
headerTitle={copy.title}
|
||||
headerDescription={copy.subtitle}
|
||||
saveFormId="contact-protection-form"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<MotionFade delay={0.16}>
|
||||
|
||||
@@ -48,7 +48,6 @@ export default async function RootSMTPPage() {
|
||||
logoutAction={logoutAction}
|
||||
headerTitle={copy.title}
|
||||
headerDescription={copy.subtitle}
|
||||
saveFormId="smtp-settings-form"
|
||||
headerActions={(
|
||||
<form action={sendTestEmailAction}>
|
||||
<Button type="submit" variant="outline">
|
||||
|
||||
@@ -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}
|
||||
<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 className="rounded-nested border border-dashed border-border/70 px-4 py-6 text-sm text-muted-foreground">
|
||||
No media selected.
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
|
||||
{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"
|
||||
/>
|
||||
</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 === "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>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{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}
|
||||
|
||||
{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) => {
|
||||
<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={() =>
|
||||
onClick={() => {
|
||||
onChange({
|
||||
...value,
|
||||
mode: "library",
|
||||
assetId: option.id,
|
||||
url: option.url,
|
||||
label: option.label,
|
||||
isCleared: false,
|
||||
})
|
||||
}
|
||||
});
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-nested border px-3 py-2 text-left transition-colors",
|
||||
"overflow-hidden rounded-surface border text-left transition-colors",
|
||||
isActive
|
||||
? "border-input bg-accent/40"
|
||||
: "border-input bg-background hover:bg-accent/20",
|
||||
? "border-input bg-accent/20"
|
||||
: "border-border/70 bg-card hover:border-input hover:bg-accent/10",
|
||||
)}
|
||||
>
|
||||
{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">
|
||||
<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}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="rounded-nested border border-dashed border-input px-3 py-4 text-sm text-muted-foreground">
|
||||
No media found.
|
||||
</div>
|
||||
)}
|
||||
})}
|
||||
</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,16 +87,17 @@ function CategoryLocaleFields({
|
||||
values,
|
||||
}: {
|
||||
idPrefix: string;
|
||||
values?: {
|
||||
nameAr?: string;
|
||||
nameEn?: string;
|
||||
nameDe?: string;
|
||||
descriptionAr?: string;
|
||||
descriptionEn?: string;
|
||||
descriptionDe?: string;
|
||||
};
|
||||
values?: CategoryFormValues;
|
||||
}) {
|
||||
return (
|
||||
<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>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{locales.map((locale) => {
|
||||
const nameKey = `name${locale.key}` as const;
|
||||
@@ -82,12 +105,10 @@ function CategoryLocaleFields({
|
||||
|
||||
return (
|
||||
<AppCard key={`${idPrefix}-${locale.key}`} level={2} padding="sm" className="space-y-4 rounded-nested">
|
||||
<div className="space-y-1">
|
||||
<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>
|
||||
<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
|
||||
@@ -102,7 +123,7 @@ function CategoryLocaleFields({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${idPrefix}-${descriptionKey}`} className="sr-only">{`${copy.description} ${locale.label}`}</Label>
|
||||
<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
|
||||
@@ -120,6 +141,77 @@ function CategoryLocaleFields({
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,44 +238,33 @@ 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 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}`}
|
||||
<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,
|
||||
@@ -190,11 +273,10 @@ function EditCategoryDialog({
|
||||
descriptionDe: category.description.de,
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<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>
|
||||
@@ -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-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 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="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" />
|
||||
<CategoryForm
|
||||
formId="portfolio-category-create-form"
|
||||
action={saveCategoryAction}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit">{copy.saveCategory}</Button>
|
||||
<Button type="submit" form="portfolio-category-create-form">
|
||||
{copy.saveCategory}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
@@ -307,54 +385,50 @@ 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}
|
||||
<Pencil className="h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="bg-background">
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<AccordionContent className="space-y-4">
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{locales.map((locale) => (
|
||||
<AppCard key={`${category.id}-${locale.key}`} level={1} padding="sm" className="rounded-nested">
|
||||
<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="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>
|
||||
<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>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
|
||||
<EditCategoryDialog
|
||||
category={category}
|
||||
@@ -363,6 +437,7 @@ export function PortfolioCategoriesManager({
|
||||
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,14 +31,11 @@ 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">
|
||||
<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
|
||||
@@ -60,7 +44,7 @@ export function PortfolioProjectsOverview({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild>
|
||||
<Link href="/root/portfolio/projects/new">
|
||||
<Plus className="h-4 w-4" />
|
||||
@@ -74,82 +58,44 @@ export function PortfolioProjectsOverview({
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</MotionFade>
|
||||
|
||||
<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}
|
||||
<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>
|
||||
</form>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</MotionFade>
|
||||
|
||||
<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>
|
||||
<Badge variant={project.isPublished ? "success" : "warning"}>
|
||||
{project.isPublished ? copy.published : copy.draft}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.category.name.de}
|
||||
</p>
|
||||
<Badge variant={project.isPublished ? "success" : "warning"}>
|
||||
{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>
|
||||
</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} />;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PortfolioAssetKind, PortfolioSectionType } from "@prisma/client";
|
||||
import { PortfolioSectionType } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import { mediaFieldInputSchema } from "./media-validation";
|
||||
@@ -10,6 +10,7 @@ const requiredText = (label: string) =>
|
||||
.min(1, `${label} is required.`);
|
||||
|
||||
const optionalTrimmedText = z.string().trim().optional().transform((value) => value ?? "");
|
||||
const portfolioProjectViewModes = ["GRID", "STORY", "CASE_STUDY"] as const;
|
||||
|
||||
export const categoryInputSchema = z.object({
|
||||
id: z.string().trim().optional(),
|
||||
@@ -83,7 +84,7 @@ export const sectionInputSchema = z
|
||||
|
||||
export const assetInputSchema = z.object({
|
||||
id: z.string().trim().optional(),
|
||||
kind: z.nativeEnum(PortfolioAssetKind),
|
||||
kind: z.literal("IMAGE"),
|
||||
filePath: optionalTrimmedText,
|
||||
fileFieldName: optionalTrimmedText,
|
||||
media: mediaFieldInputSchema.optional(),
|
||||
@@ -98,6 +99,7 @@ export const projectInputSchema = z.object({
|
||||
categoryId: requiredText("Project categoryId"),
|
||||
slug: requiredText("Project slug")
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Project slug must be lowercase and hyphenated."),
|
||||
viewMode: z.enum(portfolioProjectViewModes).default("GRID"),
|
||||
titleAr: requiredText("Project titleAr"),
|
||||
titleEn: requiredText("Project titleEn"),
|
||||
titleDe: requiredText("Project titleDe"),
|
||||
|
||||
+24
-1
@@ -1,4 +1,10 @@
|
||||
import type { Category, PortfolioAsset, PortfolioProject, PortfolioSection } from "@prisma/client";
|
||||
import type {
|
||||
Category,
|
||||
PortfolioAsset,
|
||||
PortfolioProject,
|
||||
PortfolioProjectViewMode,
|
||||
PortfolioSection,
|
||||
} from "@prisma/client";
|
||||
|
||||
import { getPortfolioMediaBindings } from "@/lib/media";
|
||||
import type { AppLocale } from "@/lib/locale";
|
||||
@@ -42,6 +48,7 @@ type ProjectRecord = Pick<
|
||||
PortfolioProject,
|
||||
| "id"
|
||||
| "slug"
|
||||
| "viewMode"
|
||||
| "titleAr"
|
||||
| "titleEn"
|
||||
| "titleDe"
|
||||
@@ -99,6 +106,7 @@ export type PortfolioAssetView = {
|
||||
export type PortfolioProjectView = {
|
||||
id: string;
|
||||
slug: string;
|
||||
viewMode: PortfolioProjectViewMode;
|
||||
title: LocalizedContent;
|
||||
summary: LocalizedContent;
|
||||
clientName: string;
|
||||
@@ -116,6 +124,20 @@ export type PortfolioProjectView = {
|
||||
assets: PortfolioAssetView[];
|
||||
};
|
||||
|
||||
export function resolvePortfolioProjectViewMode(
|
||||
value: string | null | undefined,
|
||||
): PortfolioProjectViewMode {
|
||||
switch (value) {
|
||||
case "STORY":
|
||||
return "STORY";
|
||||
case "CASE_STUDY":
|
||||
return "CASE_STUDY";
|
||||
case "GRID":
|
||||
default:
|
||||
return "GRID";
|
||||
}
|
||||
}
|
||||
|
||||
function mapLocalizedContent(record: Record<string, unknown>, prefix: string): LocalizedContent {
|
||||
return {
|
||||
ar: String(record[`${prefix}Ar`] ?? ""),
|
||||
@@ -174,6 +196,7 @@ function mapProject(
|
||||
return {
|
||||
id: record.id,
|
||||
slug: record.slug,
|
||||
viewMode: resolvePortfolioProjectViewMode(record.viewMode),
|
||||
title: mapLocalizedContent(record, "title"),
|
||||
summary: mapLocalizedContent(record, "summary"),
|
||||
clientName: record.clientName,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
CREATE TYPE "PortfolioProjectViewMode" AS ENUM ('GRID', 'STORY', 'CASE_STUDY');
|
||||
|
||||
ALTER TABLE "PortfolioProject"
|
||||
ADD COLUMN "viewMode" "PortfolioProjectViewMode" NOT NULL DEFAULT 'GRID';
|
||||
@@ -27,6 +27,12 @@ enum PortfolioAssetKind {
|
||||
DOCUMENT
|
||||
}
|
||||
|
||||
enum PortfolioProjectViewMode {
|
||||
GRID
|
||||
STORY
|
||||
CASE_STUDY
|
||||
}
|
||||
|
||||
enum MediaSource {
|
||||
UPLOAD
|
||||
EXTERNAL
|
||||
@@ -64,6 +70,7 @@ model PortfolioProject {
|
||||
id String @id @default(cuid())
|
||||
categoryId String
|
||||
slug String @unique
|
||||
viewMode PortfolioProjectViewMode @default(GRID)
|
||||
titleAr String
|
||||
titleEn String
|
||||
titleDe String
|
||||
|
||||
+429
-108
@@ -9,7 +9,121 @@ const connectionString =
|
||||
const pool = new Pool({ connectionString });
|
||||
const prisma = new PrismaClient({ adapter: new PrismaPg(pool) });
|
||||
|
||||
async function upsertMediaAsset(input) {
|
||||
const existing = await prisma.mediaAsset.findFirst({
|
||||
where: {
|
||||
label: input.label,
|
||||
url: input.url,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return prisma.mediaAsset.update({
|
||||
where: { id: existing.id },
|
||||
data: input,
|
||||
});
|
||||
}
|
||||
|
||||
return prisma.mediaAsset.create({
|
||||
data: input,
|
||||
});
|
||||
}
|
||||
|
||||
async function syncProjectContent(projectId, sections, assets) {
|
||||
await prisma.portfolioSection.deleteMany({
|
||||
where: { projectId },
|
||||
});
|
||||
|
||||
await prisma.portfolioAsset.deleteMany({
|
||||
where: { projectId },
|
||||
});
|
||||
|
||||
const createdSections = [];
|
||||
|
||||
for (const section of sections) {
|
||||
const createdSection = await prisma.portfolioSection.create({
|
||||
data: {
|
||||
projectId,
|
||||
...section,
|
||||
},
|
||||
});
|
||||
|
||||
createdSections.push(createdSection);
|
||||
}
|
||||
|
||||
const createdAssets = [];
|
||||
|
||||
for (const asset of assets) {
|
||||
const createdAsset = await prisma.portfolioAsset.create({
|
||||
data: {
|
||||
projectId,
|
||||
...asset,
|
||||
},
|
||||
});
|
||||
|
||||
createdAssets.push(createdAsset);
|
||||
}
|
||||
|
||||
return { createdSections, createdAssets };
|
||||
}
|
||||
|
||||
async function syncProjectMediaUsages(projectId, mediaMap) {
|
||||
await prisma.mediaUsage.deleteMany({
|
||||
where: {
|
||||
entityType: "portfolio-project",
|
||||
entityId: projectId,
|
||||
},
|
||||
});
|
||||
|
||||
const usages = [];
|
||||
|
||||
if (mediaMap.coverAssetId) {
|
||||
usages.push({
|
||||
assetId: mediaMap.coverAssetId,
|
||||
usageType: "PORTFOLIO_COVER",
|
||||
entityType: "portfolio-project",
|
||||
entityId: projectId,
|
||||
fieldKey: "cover",
|
||||
});
|
||||
}
|
||||
|
||||
for (const sectionUsage of mediaMap.sectionUsages) {
|
||||
usages.push({
|
||||
assetId: sectionUsage.assetId,
|
||||
usageType: "PORTFOLIO_SECTION",
|
||||
entityType: "portfolio-project",
|
||||
entityId: projectId,
|
||||
fieldKey: sectionUsage.fieldKey,
|
||||
});
|
||||
}
|
||||
|
||||
for (const assetUsage of mediaMap.assetUsages) {
|
||||
usages.push({
|
||||
assetId: assetUsage.assetId,
|
||||
usageType: "PORTFOLIO_ASSET",
|
||||
entityType: "portfolio-project",
|
||||
entityId: projectId,
|
||||
fieldKey: assetUsage.fieldKey,
|
||||
});
|
||||
}
|
||||
|
||||
if (usages.length > 0) {
|
||||
await prisma.mediaUsage.createMany({ data: usages });
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await prisma.mediaUsage.deleteMany({
|
||||
where: {
|
||||
entityType: "portfolio-project",
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.portfolioSection.deleteMany();
|
||||
await prisma.portfolioAsset.deleteMany();
|
||||
await prisma.portfolioProject.deleteMany();
|
||||
await prisma.category.deleteMany();
|
||||
|
||||
await prisma.appConfig.upsert({
|
||||
where: { key: "siteName" },
|
||||
update: { value: "moh-sass" },
|
||||
@@ -121,63 +235,226 @@ async function main() {
|
||||
},
|
||||
});
|
||||
|
||||
const project = await prisma.portfolioProject.upsert({
|
||||
where: { slug: "brand-redesign" },
|
||||
const commerceCategory = await prisma.category.upsert({
|
||||
where: { slug: "commerce" },
|
||||
update: {
|
||||
categoryId: brandCategory.id,
|
||||
titleAr: "إعادة تصميم الهوية",
|
||||
titleEn: "Brand Redesign",
|
||||
titleDe: "Brand Redesign",
|
||||
summaryAr: "إعادة بناء لهوية رقمية مع نظام مرئي أوضح ومسارات استخدام أسرع.",
|
||||
summaryEn: "A digital brand refresh with a clearer visual system and faster journeys.",
|
||||
summaryDe: "Ein digitales Redesign mit klarerem visuellen System und schnelleren Journeys.",
|
||||
clientName: "Studio Client",
|
||||
projectYear: 2025,
|
||||
serviceLabelAr: "هوية بصرية",
|
||||
serviceLabelEn: "Brand Identity",
|
||||
serviceLabelDe: "Brand Identity",
|
||||
previewUrl: "https://example.com/preview/brand-redesign",
|
||||
coverImagePath: "/uploads/portfolio/demo-cover.svg",
|
||||
isFeatured: true,
|
||||
isPublished: true,
|
||||
publishedAt: new Date("2025-01-10T09:00:00.000Z"),
|
||||
sortOrder: 1,
|
||||
nameAr: "التجارة الرقمية",
|
||||
nameEn: "Commerce",
|
||||
nameDe: "Commerce",
|
||||
descriptionAr: "متاجر وتجارب شراء رقمية مع تركيز على الوضوح والتحويل.",
|
||||
descriptionEn: "Commerce experiences with a focus on clarity and conversion.",
|
||||
descriptionDe: "Commerce-Projekte mit Fokus auf Klarheit und Conversion.",
|
||||
sortOrder: 3,
|
||||
isActive: true,
|
||||
},
|
||||
create: {
|
||||
categoryId: brandCategory.id,
|
||||
slug: "commerce",
|
||||
nameAr: "التجارة الرقمية",
|
||||
nameEn: "Commerce",
|
||||
nameDe: "Commerce",
|
||||
descriptionAr: "متاجر وتجارب شراء رقمية مع تركيز على الوضوح والتحويل.",
|
||||
descriptionEn: "Commerce experiences with a focus on clarity and conversion.",
|
||||
descriptionDe: "Commerce-Projekte mit Fokus auf Klarheit und Conversion.",
|
||||
sortOrder: 3,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
const gridCover = await upsertMediaAsset({
|
||||
source: "UPLOAD",
|
||||
kind: "IMAGE",
|
||||
url: "/uploads/portfolio/demo-cover.svg",
|
||||
fileName: "demo-cover.svg",
|
||||
label: "Portfolio Grid Cover",
|
||||
altText: "Portfolio Grid Cover",
|
||||
mimeType: "image/svg+xml",
|
||||
size: 1024,
|
||||
});
|
||||
|
||||
const storyCover = await upsertMediaAsset({
|
||||
source: "UPLOAD",
|
||||
kind: "IMAGE",
|
||||
url: "/uploads/portfolio/demo-cover.svg",
|
||||
fileName: "demo-cover.svg",
|
||||
label: "Portfolio Story Cover",
|
||||
altText: "Portfolio Story Cover",
|
||||
mimeType: "image/svg+xml",
|
||||
size: 1024,
|
||||
});
|
||||
|
||||
const caseStudyCover = await upsertMediaAsset({
|
||||
source: "UPLOAD",
|
||||
kind: "IMAGE",
|
||||
url: "/uploads/portfolio/demo-cover.svg",
|
||||
fileName: "demo-cover.svg",
|
||||
label: "Portfolio Case Study Cover",
|
||||
altText: "Portfolio Case Study Cover",
|
||||
mimeType: "image/svg+xml",
|
||||
size: 1024,
|
||||
});
|
||||
|
||||
const projects = [
|
||||
{
|
||||
slug: "grid-product-launch",
|
||||
categoryId: commerceCategory.id,
|
||||
viewMode: "GRID",
|
||||
titleAr: "إطلاق منتج رقمي",
|
||||
titleEn: "Grid Product Launch",
|
||||
titleDe: "Grid Product Launch",
|
||||
summaryAr: "مثال عرض شبكي لمشروع سريع مع أقسام قصيرة وأصول داعمة.",
|
||||
summaryEn: "Grid view example for a fast product launch page.",
|
||||
summaryDe: "Grid-Ansicht als Beispiel fuer einen schnellen Produktlaunch.",
|
||||
clientName: "Launch Studio",
|
||||
projectYear: 2026,
|
||||
serviceLabelAr: "تجربة إطلاق",
|
||||
serviceLabelEn: "Launch Experience",
|
||||
serviceLabelDe: "Launch Experience",
|
||||
previewUrl: "https://example.com/preview/grid-product-launch",
|
||||
coverImagePath: gridCover.url,
|
||||
isFeatured: true,
|
||||
isPublished: true,
|
||||
publishedAt: new Date("2026-01-12T09:00:00.000Z"),
|
||||
sortOrder: 1,
|
||||
coverAssetId: gridCover.id,
|
||||
sections: [
|
||||
{
|
||||
type: "RICH_TEXT",
|
||||
titleAr: "الفكرة",
|
||||
titleEn: "Concept",
|
||||
titleDe: "Konzept",
|
||||
bodyAr: "واجهة سريعة لعرض المنتج والتركيز على الرسالة الأساسية.",
|
||||
bodyEn: "A fast modular presentation focused on the main launch message.",
|
||||
bodyDe: "Eine schnelle modulare Darstellung mit Fokus auf die Hauptbotschaft.",
|
||||
imagePath: null,
|
||||
linkUrl: null,
|
||||
sortOrder: 0,
|
||||
mediaAssetId: null,
|
||||
},
|
||||
{
|
||||
type: "GALLERY",
|
||||
titleAr: "الصورة الرئيسية",
|
||||
titleEn: "Hero Visual",
|
||||
titleDe: "Hero Visual",
|
||||
bodyAr: "",
|
||||
bodyEn: "",
|
||||
bodyDe: "",
|
||||
imagePath: gridCover.url,
|
||||
linkUrl: null,
|
||||
sortOrder: 1,
|
||||
mediaAssetId: gridCover.id,
|
||||
},
|
||||
],
|
||||
assets: [
|
||||
{
|
||||
kind: "IMAGE",
|
||||
filePath: gridCover.url,
|
||||
altAr: "غلاف مشروع Grid",
|
||||
altEn: "Grid project cover",
|
||||
altDe: "Grid Projekt Cover",
|
||||
sortOrder: 0,
|
||||
mediaAssetId: gridCover.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "campaign-site",
|
||||
categoryId: webCategory.id,
|
||||
viewMode: "STORY",
|
||||
titleAr: "موقع حملة",
|
||||
titleEn: "Campaign Site",
|
||||
titleDe: "Campaign Site",
|
||||
summaryAr: "مثال عرض قصصي لمشروع ويب مع تسلسل سردي أوضح.",
|
||||
summaryEn: "Story view example for a launch campaign website.",
|
||||
summaryDe: "Story-Ansicht als Beispiel fuer eine Kampagnenseite.",
|
||||
clientName: "Launch Client",
|
||||
projectYear: 2024,
|
||||
serviceLabelAr: "موقع تسويقي",
|
||||
serviceLabelEn: "Marketing Website",
|
||||
serviceLabelDe: "Marketing Website",
|
||||
previewUrl: "https://example.com/preview/campaign-site",
|
||||
coverImagePath: storyCover.url,
|
||||
isFeatured: false,
|
||||
isPublished: true,
|
||||
publishedAt: new Date("2024-09-05T09:00:00.000Z"),
|
||||
sortOrder: 2,
|
||||
coverAssetId: storyCover.id,
|
||||
sections: [
|
||||
{
|
||||
type: "RICH_TEXT",
|
||||
titleAr: "السياق",
|
||||
titleEn: "Context",
|
||||
titleDe: "Kontext",
|
||||
bodyAr: "الحملة احتاجت صفحة مرنة وسريعة تتبدل بين أكثر من مرحلة.",
|
||||
bodyEn: "The campaign needed a flexible page that could adapt across phases.",
|
||||
bodyDe: "Die Kampagne brauchte eine flexible Seite fuer mehrere Phasen.",
|
||||
imagePath: null,
|
||||
linkUrl: null,
|
||||
sortOrder: 0,
|
||||
mediaAssetId: null,
|
||||
},
|
||||
{
|
||||
type: "GALLERY",
|
||||
titleAr: "العرض البصري",
|
||||
titleEn: "Visual Flow",
|
||||
titleDe: "Visueller Ablauf",
|
||||
bodyAr: "",
|
||||
bodyEn: "",
|
||||
bodyDe: "",
|
||||
imagePath: storyCover.url,
|
||||
linkUrl: null,
|
||||
sortOrder: 1,
|
||||
mediaAssetId: storyCover.id,
|
||||
},
|
||||
{
|
||||
type: "LINK",
|
||||
titleAr: "المعاينة",
|
||||
titleEn: "Preview",
|
||||
titleDe: "Vorschau",
|
||||
bodyAr: "رابط العرض المباشر.",
|
||||
bodyEn: "Direct preview link.",
|
||||
bodyDe: "Direkter Vorschau-Link.",
|
||||
imagePath: null,
|
||||
linkUrl: "https://example.com/preview/campaign-site",
|
||||
sortOrder: 2,
|
||||
mediaAssetId: null,
|
||||
},
|
||||
],
|
||||
assets: [
|
||||
{
|
||||
kind: "IMAGE",
|
||||
filePath: storyCover.url,
|
||||
altAr: "غلاف مشروع Story",
|
||||
altEn: "Story project cover",
|
||||
altDe: "Story Projekt Cover",
|
||||
sortOrder: 0,
|
||||
mediaAssetId: storyCover.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "brand-redesign",
|
||||
categoryId: brandCategory.id,
|
||||
viewMode: "CASE_STUDY",
|
||||
titleAr: "إعادة تصميم الهوية",
|
||||
titleEn: "Brand Redesign",
|
||||
titleDe: "Brand Redesign",
|
||||
summaryAr: "إعادة بناء لهوية رقمية مع نظام مرئي أوضح ومسارات استخدام أسرع.",
|
||||
summaryEn: "A digital brand refresh with a clearer visual system and faster journeys.",
|
||||
summaryDe: "Ein digitales Redesign mit klarerem visuellen System und schnelleren Journeys.",
|
||||
summaryAr: "مثال عرض دراسة حالة يركز على التحدي والحل والنتيجة.",
|
||||
summaryEn: "Case study example focused on challenge, solution, and outcome.",
|
||||
summaryDe: "Case-Study-Ansicht mit Fokus auf Herausforderung, Loesung und Ergebnis.",
|
||||
clientName: "Studio Client",
|
||||
projectYear: 2025,
|
||||
serviceLabelAr: "هوية بصرية",
|
||||
serviceLabelEn: "Brand Identity",
|
||||
serviceLabelDe: "Brand Identity",
|
||||
previewUrl: "https://example.com/preview/brand-redesign",
|
||||
coverImagePath: "/uploads/portfolio/demo-cover.svg",
|
||||
coverImagePath: caseStudyCover.url,
|
||||
isFeatured: true,
|
||||
isPublished: true,
|
||||
publishedAt: new Date("2025-01-10T09:00:00.000Z"),
|
||||
sortOrder: 1,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.portfolioSection.deleteMany({
|
||||
where: { projectId: project.id },
|
||||
});
|
||||
|
||||
await prisma.portfolioAsset.deleteMany({
|
||||
where: { projectId: project.id },
|
||||
});
|
||||
|
||||
await prisma.portfolioSection.createMany({
|
||||
data: [
|
||||
sortOrder: 3,
|
||||
coverAssetId: caseStudyCover.id,
|
||||
sections: [
|
||||
{
|
||||
projectId: project.id,
|
||||
type: "RICH_TEXT",
|
||||
titleAr: "التحدي",
|
||||
titleEn: "Challenge",
|
||||
@@ -185,10 +462,12 @@ async function main() {
|
||||
bodyAr: "كان المطلوب تحديث الهوية بدون خسارة التعرف البصري الحالي.",
|
||||
bodyEn: "The brief required a refreshed identity without losing recognition.",
|
||||
bodyDe: "Die Marke sollte modernisiert werden, ohne die Wiedererkennbarkeit zu verlieren.",
|
||||
sortOrder: 1,
|
||||
imagePath: null,
|
||||
linkUrl: null,
|
||||
sortOrder: 0,
|
||||
mediaAssetId: null,
|
||||
},
|
||||
{
|
||||
projectId: project.id,
|
||||
type: "RICH_TEXT",
|
||||
titleAr: "الحل",
|
||||
titleEn: "Solution",
|
||||
@@ -196,81 +475,123 @@ async function main() {
|
||||
bodyAr: "تم بناء نظام مرئي أوضح مع قواعد استخدام قابلة للتوسع.",
|
||||
bodyEn: "A clearer visual system with scalable usage rules was created.",
|
||||
bodyDe: "Es wurde ein klareres visuelles System mit skalierbaren Regeln aufgebaut.",
|
||||
sortOrder: 2,
|
||||
},
|
||||
{
|
||||
projectId: project.id,
|
||||
type: "LINK",
|
||||
titleAr: "المعاينة",
|
||||
titleEn: "Preview",
|
||||
titleDe: "Vorschau",
|
||||
bodyAr: "رابط مباشر لعرض المشروع.",
|
||||
bodyEn: "Direct link for reviewing the work.",
|
||||
bodyDe: "Direkter Link zur Projektansicht.",
|
||||
linkUrl: "https://example.com/preview/brand-redesign",
|
||||
sortOrder: 3,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.portfolioAsset.createMany({
|
||||
data: [
|
||||
{
|
||||
projectId: project.id,
|
||||
kind: "IMAGE",
|
||||
filePath: "/uploads/portfolio/demo-cover.svg",
|
||||
altAr: "غلاف مشروع إعادة تصميم الهوية",
|
||||
altEn: "Brand redesign cover artwork",
|
||||
altDe: "Titelgrafik fuer Brand Redesign",
|
||||
imagePath: null,
|
||||
linkUrl: null,
|
||||
sortOrder: 1,
|
||||
mediaAssetId: null,
|
||||
},
|
||||
{
|
||||
type: "GALLERY",
|
||||
titleAr: "التنفيذ البصري",
|
||||
titleEn: "Visual Execution",
|
||||
titleDe: "Visuelle Umsetzung",
|
||||
bodyAr: "",
|
||||
bodyEn: "",
|
||||
bodyDe: "",
|
||||
imagePath: caseStudyCover.url,
|
||||
linkUrl: null,
|
||||
sortOrder: 2,
|
||||
mediaAssetId: caseStudyCover.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
assets: [
|
||||
{
|
||||
kind: "IMAGE",
|
||||
filePath: caseStudyCover.url,
|
||||
altAr: "غلاف مشروع Case Study",
|
||||
altEn: "Case study project cover",
|
||||
altDe: "Case Study Projekt Cover",
|
||||
sortOrder: 0,
|
||||
mediaAssetId: caseStudyCover.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
await prisma.portfolioProject.upsert({
|
||||
where: { slug: "campaign-site" },
|
||||
for (const projectConfig of projects) {
|
||||
const project = await prisma.portfolioProject.upsert({
|
||||
where: { slug: projectConfig.slug },
|
||||
update: {
|
||||
categoryId: webCategory.id,
|
||||
titleAr: "موقع حملة",
|
||||
titleEn: "Campaign Site",
|
||||
titleDe: "Campaign Site",
|
||||
summaryAr: "صفحة إطلاق مرنة لحملة رقمية مع تركيز على السرعة والتحويل.",
|
||||
summaryEn: "A launch site built for speed, iteration, and conversion.",
|
||||
summaryDe: "Eine Kampagnenseite mit Fokus auf Tempo, Iteration und Conversion.",
|
||||
clientName: "Launch Client",
|
||||
projectYear: 2024,
|
||||
serviceLabelAr: "موقع تسويقي",
|
||||
serviceLabelEn: "Marketing Website",
|
||||
serviceLabelDe: "Marketing Website",
|
||||
previewUrl: "https://example.com/preview/campaign-site",
|
||||
coverImagePath: "/uploads/portfolio/demo-cover.svg",
|
||||
isFeatured: false,
|
||||
isPublished: true,
|
||||
publishedAt: new Date("2024-09-05T09:00:00.000Z"),
|
||||
sortOrder: 2,
|
||||
categoryId: projectConfig.categoryId,
|
||||
viewMode: projectConfig.viewMode,
|
||||
titleAr: projectConfig.titleAr,
|
||||
titleEn: projectConfig.titleEn,
|
||||
titleDe: projectConfig.titleDe,
|
||||
summaryAr: projectConfig.summaryAr,
|
||||
summaryEn: projectConfig.summaryEn,
|
||||
summaryDe: projectConfig.summaryDe,
|
||||
clientName: projectConfig.clientName,
|
||||
projectYear: projectConfig.projectYear,
|
||||
serviceLabelAr: projectConfig.serviceLabelAr,
|
||||
serviceLabelEn: projectConfig.serviceLabelEn,
|
||||
serviceLabelDe: projectConfig.serviceLabelDe,
|
||||
previewUrl: projectConfig.previewUrl,
|
||||
coverImagePath: projectConfig.coverImagePath,
|
||||
isFeatured: projectConfig.isFeatured,
|
||||
isPublished: projectConfig.isPublished,
|
||||
publishedAt: projectConfig.publishedAt,
|
||||
sortOrder: projectConfig.sortOrder,
|
||||
},
|
||||
create: {
|
||||
categoryId: webCategory.id,
|
||||
slug: "campaign-site",
|
||||
titleAr: "موقع حملة",
|
||||
titleEn: "Campaign Site",
|
||||
titleDe: "Campaign Site",
|
||||
summaryAr: "صفحة إطلاق مرنة لحملة رقمية مع تركيز على السرعة والتحويل.",
|
||||
summaryEn: "A launch site built for speed, iteration, and conversion.",
|
||||
summaryDe: "Eine Kampagnenseite mit Fokus auf Tempo, Iteration und Conversion.",
|
||||
clientName: "Launch Client",
|
||||
projectYear: 2024,
|
||||
serviceLabelAr: "موقع تسويقي",
|
||||
serviceLabelEn: "Marketing Website",
|
||||
serviceLabelDe: "Marketing Website",
|
||||
previewUrl: "https://example.com/preview/campaign-site",
|
||||
coverImagePath: "/uploads/portfolio/demo-cover.svg",
|
||||
isFeatured: false,
|
||||
isPublished: true,
|
||||
publishedAt: new Date("2024-09-05T09:00:00.000Z"),
|
||||
sortOrder: 2,
|
||||
slug: projectConfig.slug,
|
||||
categoryId: projectConfig.categoryId,
|
||||
viewMode: projectConfig.viewMode,
|
||||
titleAr: projectConfig.titleAr,
|
||||
titleEn: projectConfig.titleEn,
|
||||
titleDe: projectConfig.titleDe,
|
||||
summaryAr: projectConfig.summaryAr,
|
||||
summaryEn: projectConfig.summaryEn,
|
||||
summaryDe: projectConfig.summaryDe,
|
||||
clientName: projectConfig.clientName,
|
||||
projectYear: projectConfig.projectYear,
|
||||
serviceLabelAr: projectConfig.serviceLabelAr,
|
||||
serviceLabelEn: projectConfig.serviceLabelEn,
|
||||
serviceLabelDe: projectConfig.serviceLabelDe,
|
||||
previewUrl: projectConfig.previewUrl,
|
||||
coverImagePath: projectConfig.coverImagePath,
|
||||
isFeatured: projectConfig.isFeatured,
|
||||
isPublished: projectConfig.isPublished,
|
||||
publishedAt: projectConfig.publishedAt,
|
||||
sortOrder: projectConfig.sortOrder,
|
||||
},
|
||||
});
|
||||
|
||||
const created = await syncProjectContent(project.id, projectConfig.sections.map((section) => ({
|
||||
type: section.type,
|
||||
titleAr: section.titleAr,
|
||||
titleEn: section.titleEn,
|
||||
titleDe: section.titleDe,
|
||||
bodyAr: section.bodyAr,
|
||||
bodyEn: section.bodyEn,
|
||||
bodyDe: section.bodyDe,
|
||||
imagePath: section.imagePath,
|
||||
linkUrl: section.linkUrl,
|
||||
sortOrder: section.sortOrder,
|
||||
})), projectConfig.assets.map((asset) => ({
|
||||
kind: asset.kind,
|
||||
filePath: asset.filePath,
|
||||
altAr: asset.altAr,
|
||||
altEn: asset.altEn,
|
||||
altDe: asset.altDe,
|
||||
sortOrder: asset.sortOrder,
|
||||
})));
|
||||
|
||||
await syncProjectMediaUsages(project.id, {
|
||||
coverAssetId: projectConfig.coverAssetId,
|
||||
sectionUsages: created.createdSections
|
||||
.map((sectionRow, index) => ({
|
||||
fieldKey: sectionRow.id,
|
||||
assetId: projectConfig.sections[index]?.mediaAssetId,
|
||||
}))
|
||||
.filter((entry) => entry.assetId),
|
||||
assetUsages: created.createdAssets
|
||||
.map((assetRow, index) => ({
|
||||
fieldKey: assetRow.id,
|
||||
assetId: projectConfig.assets[index]?.mediaAssetId,
|
||||
}))
|
||||
.filter((entry) => entry.assetId),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@@ -29,6 +29,7 @@ describe("portfolio validation", () => {
|
||||
projectInputSchema.parse({
|
||||
categoryId: "cat_1",
|
||||
slug: "Invalid Slug",
|
||||
viewMode: "GRID",
|
||||
titleAr: "عنوان",
|
||||
titleEn: "Title",
|
||||
titleDe: "Titel",
|
||||
@@ -123,6 +124,41 @@ describe("portfolio validation", () => {
|
||||
).toBe("IMAGE");
|
||||
});
|
||||
|
||||
it("accepts valid project view modes", () => {
|
||||
expect(
|
||||
projectInputSchema.parse({
|
||||
categoryId: "cat_1",
|
||||
slug: "case-study-entry",
|
||||
viewMode: "CASE_STUDY",
|
||||
titleAr: "عنوان",
|
||||
titleEn: "Title",
|
||||
titleDe: "Titel",
|
||||
summaryAr: "ملخص",
|
||||
summaryEn: "Summary",
|
||||
summaryDe: "Zusammenfassung",
|
||||
clientName: "Client",
|
||||
projectYear: 2025,
|
||||
serviceLabelAr: "خدمة",
|
||||
serviceLabelEn: "Service",
|
||||
serviceLabelDe: "Service",
|
||||
previewUrl: "https://example.com",
|
||||
currentCoverImagePath: "",
|
||||
coverMedia: {
|
||||
mode: "external",
|
||||
assetId: "",
|
||||
url: "https://example.com/cover.jpg",
|
||||
label: "Cover",
|
||||
kind: "IMAGE",
|
||||
},
|
||||
sortOrder: 1,
|
||||
isFeatured: false,
|
||||
isPublished: true,
|
||||
sections: [],
|
||||
assets: [],
|
||||
}).viewMode,
|
||||
).toBe("CASE_STUDY");
|
||||
});
|
||||
|
||||
it("rejects invalid media payloads", () => {
|
||||
expect(() =>
|
||||
assetInputSchema.parse({
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolvePortfolioProjectViewMode } from "../lib/portfolio";
|
||||
|
||||
describe("portfolio helpers", () => {
|
||||
it("falls back to GRID when view mode is missing", () => {
|
||||
expect(resolvePortfolioProjectViewMode(undefined)).toBe("GRID");
|
||||
expect(resolvePortfolioProjectViewMode("unexpected")).toBe("GRID");
|
||||
});
|
||||
|
||||
it("keeps supported view modes", () => {
|
||||
expect(resolvePortfolioProjectViewMode("STORY")).toBe("STORY");
|
||||
expect(resolvePortfolioProjectViewMode("CASE_STUDY")).toBe("CASE_STUDY");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user