Refactor portfolio admin and views
This commit is contained in:
@@ -1,22 +1,16 @@
|
|||||||
import type { Metadata } from "next";
|
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 { getTranslations } from "next-intl/server";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
import { Container } from "@/components/layout/container";
|
import { Container } from "@/components/layout/container";
|
||||||
import { PageHero } from "@/components/layout/page-hero";
|
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 { buildLocalizedMetadata } from "@/lib/metadata";
|
||||||
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
|
import { resolveLocale } from "@/lib/locale";
|
||||||
import {
|
import {
|
||||||
getLocalizedValue,
|
getLocalizedValue,
|
||||||
getPublishedPortfolioProjectBySlug,
|
getPublishedPortfolioProjectBySlug,
|
||||||
} from "@/lib/portfolio";
|
} from "@/lib/portfolio";
|
||||||
import { AppCard } from "@/components/ui/app-card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { CardContent } from "@/components/ui/card";
|
|
||||||
|
|
||||||
type PortfolioItemPageProps = {
|
type PortfolioItemPageProps = {
|
||||||
params: {
|
params: {
|
||||||
@@ -27,98 +21,6 @@ type PortfolioItemPageProps = {
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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({
|
export async function generateMetadata({
|
||||||
params: { locale, slug },
|
params: { locale, slug },
|
||||||
}: PortfolioItemPageProps): Promise<Metadata> {
|
}: PortfolioItemPageProps): Promise<Metadata> {
|
||||||
@@ -161,129 +63,8 @@ export default async function PortfolioItemPage({
|
|||||||
description={getLocalizedValue(item.summary, localeKey)}
|
description={getLocalizedValue(item.summary, localeKey)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Container size="wide" className="flex flex-col gap-section pb-12 lg:pb-16">
|
<Container size="wide" className="pb-12 lg:pb-16">
|
||||||
<MotionFade>
|
<PortfolioProjectDetail item={item} locale={localeKey} t={t} />
|
||||||
<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>
|
</Container>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ export default async function RootMarqueePage() {
|
|||||||
logoutAction={logoutAction}
|
logoutAction={logoutAction}
|
||||||
headerTitle={copy.title}
|
headerTitle={copy.title}
|
||||||
headerDescription={copy.subtitle}
|
headerDescription={copy.subtitle}
|
||||||
saveFormId="marquee-settings-form"
|
|
||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<MotionFade delay={0.14}>
|
<MotionFade delay={0.14}>
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ export async function saveProjectAction(formData: FormData) {
|
|||||||
id: String(formData.get("id") ?? "").trim() || undefined,
|
id: String(formData.get("id") ?? "").trim() || undefined,
|
||||||
categoryId: String(formData.get("categoryId") ?? ""),
|
categoryId: String(formData.get("categoryId") ?? ""),
|
||||||
slug: String(formData.get("slug") ?? ""),
|
slug: String(formData.get("slug") ?? ""),
|
||||||
|
viewMode: String(formData.get("viewMode") ?? "GRID"),
|
||||||
titleAr: String(formData.get("titleAr") ?? ""),
|
titleAr: String(formData.get("titleAr") ?? ""),
|
||||||
titleEn: String(formData.get("titleEn") ?? ""),
|
titleEn: String(formData.get("titleEn") ?? ""),
|
||||||
titleDe: String(formData.get("titleDe") ?? ""),
|
titleDe: String(formData.get("titleDe") ?? ""),
|
||||||
@@ -369,6 +370,7 @@ export async function saveProjectAction(formData: FormData) {
|
|||||||
data: {
|
data: {
|
||||||
categoryId: parsed.categoryId,
|
categoryId: parsed.categoryId,
|
||||||
slug: parsed.slug,
|
slug: parsed.slug,
|
||||||
|
viewMode: parsed.viewMode,
|
||||||
titleAr: parsed.titleAr,
|
titleAr: parsed.titleAr,
|
||||||
titleEn: parsed.titleEn,
|
titleEn: parsed.titleEn,
|
||||||
titleDe: parsed.titleDe,
|
titleDe: parsed.titleDe,
|
||||||
@@ -396,6 +398,7 @@ export async function saveProjectAction(formData: FormData) {
|
|||||||
data: {
|
data: {
|
||||||
categoryId: parsed.categoryId,
|
categoryId: parsed.categoryId,
|
||||||
slug: parsed.slug,
|
slug: parsed.slug,
|
||||||
|
viewMode: parsed.viewMode,
|
||||||
titleAr: parsed.titleAr,
|
titleAr: parsed.titleAr,
|
||||||
titleEn: parsed.titleEn,
|
titleEn: parsed.titleEn,
|
||||||
titleDe: parsed.titleDe,
|
titleDe: parsed.titleDe,
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
|
|||||||
import { AppCard } from "@/components/ui/app-card";
|
import { AppCard } from "@/components/ui/app-card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CardContent } from "@/components/ui/card";
|
import { CardContent } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||||
import { getMediaOptions } from "@/lib/media";
|
import { getMediaOptions } from "@/lib/media";
|
||||||
import {
|
import {
|
||||||
@@ -72,8 +81,6 @@ export default async function RootPortfolioProjectPage({
|
|||||||
logoutAction={logoutAction}
|
logoutAction={logoutAction}
|
||||||
headerTitle={copy.title}
|
headerTitle={copy.title}
|
||||||
headerDescription={copy.subtitle}
|
headerDescription={copy.subtitle}
|
||||||
saveFormId="portfolio-project-form"
|
|
||||||
saveButtonLabel={copy.saveProject}
|
|
||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<MotionFade delay={0.15}>
|
<MotionFade delay={0.15}>
|
||||||
@@ -88,20 +95,37 @@ export default async function RootPortfolioProjectPage({
|
|||||||
</MotionFade>
|
</MotionFade>
|
||||||
|
|
||||||
<MotionFade delay={0.2}>
|
<MotionFade delay={0.2}>
|
||||||
<AppCard>
|
<AppCard level={2}>
|
||||||
<CardContent className="flex items-center justify-between gap-4 p-6">
|
<CardContent className="flex flex-col gap-4 p-6 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-foreground">{copy.dangerZone}</p>
|
<p className="text-sm font-medium text-foreground">{copy.dangerZone}</p>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
{copy.dangerText}
|
{copy.dangerText}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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}>
|
<form action={deleteProjectAction}>
|
||||||
<input type="hidden" name="id" value={project.id} />
|
<input type="hidden" name="id" value={project.id} />
|
||||||
<Button type="submit" variant="destructive">
|
<Button type="submit" variant="destructive">
|
||||||
{copy.deleteProject}
|
Confirm Delete
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
</MotionFade>
|
</MotionFade>
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ export default async function RootNewPortfolioProjectPage() {
|
|||||||
logoutAction={logoutAction}
|
logoutAction={logoutAction}
|
||||||
headerTitle={copy.title}
|
headerTitle={copy.title}
|
||||||
headerDescription={copy.subtitle}
|
headerDescription={copy.subtitle}
|
||||||
saveFormId="portfolio-project-form"
|
|
||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<MotionFade delay={0.15}>
|
<MotionFade delay={0.15}>
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ export default async function RootSiteSettingsPage() {
|
|||||||
logoutAction={logoutAction}
|
logoutAction={logoutAction}
|
||||||
headerTitle={copy.title}
|
headerTitle={copy.title}
|
||||||
headerDescription={copy.subtitle}
|
headerDescription={copy.subtitle}
|
||||||
saveFormId="site-settings-form"
|
|
||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<MotionFade delay={0.16}>
|
<MotionFade delay={0.16}>
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ export default async function RootSMTPProtectionPage() {
|
|||||||
logoutAction={logoutAction}
|
logoutAction={logoutAction}
|
||||||
headerTitle={copy.title}
|
headerTitle={copy.title}
|
||||||
headerDescription={copy.subtitle}
|
headerDescription={copy.subtitle}
|
||||||
saveFormId="contact-protection-form"
|
|
||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<MotionFade delay={0.16}>
|
<MotionFade delay={0.16}>
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ export default async function RootSMTPPage() {
|
|||||||
logoutAction={logoutAction}
|
logoutAction={logoutAction}
|
||||||
headerTitle={copy.title}
|
headerTitle={copy.title}
|
||||||
headerDescription={copy.subtitle}
|
headerDescription={copy.subtitle}
|
||||||
saveFormId="smtp-settings-form"
|
|
||||||
headerActions={(
|
headerActions={(
|
||||||
<form action={sendTestEmailAction}>
|
<form action={sendTestEmailAction}>
|
||||||
<Button type="submit" variant="outline">
|
<Button type="submit" variant="outline">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { AppCard } from "@/components/ui/app-card";
|
import { AppCard } from "@/components/ui/app-card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -128,6 +129,9 @@ export function ContactProtectionForm({
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
<div className="xl:col-span-2 flex justify-end">
|
||||||
|
<Button type="submit">Save Protection</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</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 { AppCard } from "@/components/ui/app-card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import type { MarqueeSettings } from "@/lib/marquee-settings";
|
import type { MarqueeSettings } from "@/lib/marquee-settings";
|
||||||
@@ -39,6 +40,9 @@ export function MarqueeSettingsForm({
|
|||||||
</AppCard>
|
</AppCard>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit">Save Marquee</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,23 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
|
||||||
import type { MediaKind } from "@prisma/client";
|
import type { MediaKind } from "@prisma/client";
|
||||||
import { Check, Link2, Search, Type, Upload } from "lucide-react";
|
import { Check, ImageIcon, Search, Trash2 } from "lucide-react";
|
||||||
import { useEffect, useId, useMemo, useRef, useState } from "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 { 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 { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import type { MediaOption } from "@/lib/media";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export type MediaFieldState = {
|
export type MediaFieldState = {
|
||||||
mode: "upload" | "external" | "library";
|
mode: "upload" | "external" | "library";
|
||||||
@@ -29,11 +38,6 @@ type MediaFieldPickerProps = {
|
|||||||
hasInitialValue?: boolean;
|
hasInitialValue?: boolean;
|
||||||
inputName: string;
|
inputName: string;
|
||||||
fileFieldName: string;
|
fileFieldName: string;
|
||||||
fileLabel?: string;
|
|
||||||
externalLabel?: string;
|
|
||||||
libraryLabel?: string;
|
|
||||||
accept?: string;
|
|
||||||
allowExternal?: boolean;
|
|
||||||
allowClear?: boolean;
|
allowClear?: boolean;
|
||||||
clearLabel?: string;
|
clearLabel?: string;
|
||||||
emptyValue?: Partial<MediaFieldState>;
|
emptyValue?: Partial<MediaFieldState>;
|
||||||
@@ -46,58 +50,32 @@ export function MediaFieldPicker({
|
|||||||
options,
|
options,
|
||||||
hasInitialValue = false,
|
hasInitialValue = false,
|
||||||
inputName,
|
inputName,
|
||||||
fileFieldName,
|
|
||||||
fileLabel,
|
|
||||||
externalLabel,
|
|
||||||
libraryLabel,
|
|
||||||
accept,
|
|
||||||
allowExternal = true,
|
|
||||||
allowClear = false,
|
allowClear = false,
|
||||||
clearLabel = "Remove",
|
clearLabel = "Remove",
|
||||||
emptyValue,
|
emptyValue,
|
||||||
}: MediaFieldPickerProps) {
|
}: MediaFieldPickerProps) {
|
||||||
const hiddenInputRef = useRef<HTMLInputElement | null>(null);
|
const hiddenInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const searchId = useId();
|
const [open, setOpen] = useState(false);
|
||||||
const [libraryQuery, setLibraryQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const modeOptions: Array<MediaFieldState["mode"]> = allowExternal
|
|
||||||
? ["upload", "external", "library"]
|
|
||||||
: ["upload", "library"];
|
|
||||||
const filteredOptions = options.filter((option) => option.kind === value.kind);
|
const filteredOptions = options.filter((option) => option.kind === value.kind);
|
||||||
const selectedOption = filteredOptions.find((option) => option.id === value.assetId) ?? null;
|
const selectedOption = filteredOptions.find((option) => option.id === value.assetId) ?? null;
|
||||||
const visibleLibraryOptions = useMemo(() => {
|
const visibleOptions = useMemo(() => {
|
||||||
const normalizedQuery = libraryQuery.trim().toLowerCase();
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
|
||||||
if (!normalizedQuery) {
|
if (!normalizedQuery) {
|
||||||
return filteredOptions;
|
return filteredOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
return filteredOptions.filter((option) => option.label.toLowerCase().includes(normalizedQuery));
|
return filteredOptions.filter((option) => option.label.toLowerCase().includes(normalizedQuery));
|
||||||
}, [filteredOptions, libraryQuery]);
|
}, [filteredOptions, query]);
|
||||||
const resolvedLabel =
|
|
||||||
value.mode === "library"
|
|
||||||
? selectedOption?.label ?? value.label
|
|
||||||
: value.label;
|
|
||||||
const serializedValue = JSON.stringify({
|
const serializedValue = JSON.stringify({
|
||||||
mode: value.mode,
|
mode: value.mode,
|
||||||
assetId: value.assetId,
|
assetId: value.assetId,
|
||||||
url: value.url,
|
url: value.url,
|
||||||
label: resolvedLabel,
|
label: value.label,
|
||||||
kind: value.kind,
|
kind: value.kind,
|
||||||
});
|
});
|
||||||
const previewUrl =
|
const canClear = allowClear && (Boolean(value.assetId) || (hasInitialValue && !value.isCleared));
|
||||||
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));
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hiddenInput = hiddenInputRef.current;
|
const hiddenInput = hiddenInputRef.current;
|
||||||
@@ -111,174 +89,132 @@ export function MediaFieldPicker({
|
|||||||
}, [serializedValue]);
|
}, [serializedValue]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppCard padding="sm" className="space-y-3">
|
<AppCard level={2} padding="sm" className="space-y-4">
|
||||||
<div className="space-y-2">
|
<input ref={hiddenInputRef} type="hidden" name={inputName} value={serializedValue} />
|
||||||
<Label className="sr-only">{title}</Label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex items-start justify-between gap-4">
|
||||||
{modeOptions.map((mode) => (
|
<div className="space-y-1">
|
||||||
<button
|
<Label className="text-sm font-semibold text-foreground">{title}</Label>
|
||||||
key={mode}
|
<p className="text-sm text-muted-foreground">
|
||||||
type="button"
|
Media must be selected from the
|
||||||
onClick={() =>
|
{" "}
|
||||||
onChange({
|
Media Library
|
||||||
...value,
|
.
|
||||||
mode,
|
</p>
|
||||||
assetId: mode === "library" ? value.assetId : "",
|
</div>
|
||||||
url: mode === "external" ? value.url : "",
|
<div className="flex gap-2">
|
||||||
isCleared: false,
|
<Button type="button" variant="outline" onClick={() => setOpen(true)}>
|
||||||
})
|
<ImageIcon className="h-4 w-4" />
|
||||||
}
|
Select from Media
|
||||||
className={cn(
|
</Button>
|
||||||
"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>
|
|
||||||
))}
|
|
||||||
{canClear ? (
|
{canClear ? (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onChange({
|
onChange({
|
||||||
...value,
|
...value,
|
||||||
mode: emptyValue?.mode ?? "upload",
|
mode: emptyValue?.mode ?? "upload",
|
||||||
assetId: emptyValue?.assetId ?? "",
|
assetId: emptyValue?.assetId ?? "",
|
||||||
url: emptyValue?.url ?? "",
|
url: emptyValue?.url ?? "",
|
||||||
label: emptyValue?.label ?? value.label,
|
label: emptyValue?.label ?? "",
|
||||||
isCleared: true,
|
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}
|
{clearLabel}
|
||||||
</button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<AppCard padding="sm" className="rounded-nested border-border/70">
|
||||||
ref={hiddenInputRef}
|
{selectedOption ? (
|
||||||
type="hidden"
|
<div className="flex items-center gap-4">
|
||||||
name={inputName}
|
<img
|
||||||
value={serializedValue}
|
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" ? (
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<div className="space-y-2">
|
<DialogContent className="max-w-4xl">
|
||||||
<Label className="sr-only">Label</Label>
|
<DialogHeader>
|
||||||
<div className="relative">
|
<DialogTitle>{title}</DialogTitle>
|
||||||
<Type className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<DialogDescription>Select an existing item from the media library.</DialogDescription>
|
||||||
<Input
|
</DialogHeader>
|
||||||
value={value.label}
|
|
||||||
onChange={(event) => onChange({ ...value, label: event.target.value, isCleared: false })}
|
|
||||||
placeholder="Label"
|
|
||||||
className="pl-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{value.mode === "upload" ? (
|
<div className="space-y-4">
|
||||||
<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="relative">
|
<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" />
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id={searchId}
|
value={query}
|
||||||
value={libraryQuery}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
onChange={(event) => setLibraryQuery(event.target.value)}
|
|
||||||
className="pl-9"
|
|
||||||
placeholder="Search media"
|
placeholder="Search media"
|
||||||
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-64 space-y-2 overflow-y-auto">
|
<div className="grid max-h-[55vh] gap-3 overflow-y-auto md:grid-cols-2 xl:grid-cols-3">
|
||||||
{visibleLibraryOptions.length > 0 ? (
|
{visibleOptions.map((option) => {
|
||||||
visibleLibraryOptions.map((option) => {
|
|
||||||
const isActive = option.id === value.assetId;
|
const isActive = option.id === value.assetId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={option.id}
|
key={option.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
onChange({
|
onChange({
|
||||||
...value,
|
...value,
|
||||||
|
mode: "library",
|
||||||
assetId: option.id,
|
assetId: option.id,
|
||||||
|
url: option.url,
|
||||||
label: option.label,
|
label: option.label,
|
||||||
isCleared: false,
|
isCleared: false,
|
||||||
})
|
});
|
||||||
}
|
setOpen(false);
|
||||||
|
}}
|
||||||
className={cn(
|
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
|
isActive
|
||||||
? "border-input bg-accent/40"
|
? "border-input bg-accent/20"
|
||||||
: "border-input bg-background hover: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-40 w-full object-cover" />
|
||||||
<img src={option.url} alt={option.label} className="h-12 w-12 rounded-nested object-cover" />
|
<div className="flex items-center justify-between gap-3 p-4">
|
||||||
) : (
|
<div className="min-w-0">
|
||||||
<div className="h-12 w-12 rounded-nested border border-border bg-background" />
|
|
||||||
)}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="truncate text-sm font-medium text-foreground">{option.label}</p>
|
<p className="truncate text-sm font-medium text-foreground">{option.label}</p>
|
||||||
<p className="truncate text-xs text-muted-foreground">{option.source}</p>
|
<p className="truncate text-xs text-muted-foreground">{option.source}</p>
|
||||||
</div>
|
</div>
|
||||||
{isActive ? <Check className="h-4 w-4 text-brand-primary" /> : null}
|
{isActive ? <Check className="h-4 w-4 text-brand-primary" /> : null}
|
||||||
|
</div>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{previewUrl ? (
|
<DialogFooter>
|
||||||
value.kind === "IMAGE" ? (
|
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||||||
<AppCard className="overflow-hidden rounded-nested">
|
Close
|
||||||
<img src={previewUrl} alt={value.label || title} className="h-40 w-full object-cover" />
|
</Button>
|
||||||
</AppCard>
|
</DialogFooter>
|
||||||
) : (
|
</DialogContent>
|
||||||
<AppCard className="rounded-nested px-4 py-3 text-sm text-muted-foreground">
|
</Dialog>
|
||||||
{previewUrl}
|
|
||||||
</AppCard>
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
</AppCard>
|
</AppCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,16 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
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 type { deleteCategoryAction, upsertCategoryAction } from "@/app/root/portfolio/actions";
|
||||||
import { StatsCard } from "@/components/dashboard/stats-card";
|
import { StatsCard } from "@/components/dashboard/stats-card";
|
||||||
@@ -23,35 +32,48 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import type { PortfolioCategoryView } from "@/lib/portfolio";
|
import type { PortfolioCategoryView } from "@/lib/portfolio";
|
||||||
|
|
||||||
const locales = [
|
const locales = [
|
||||||
{ key: "Ar", lowerKey: "ar" as const, label: "Arabic" },
|
{ key: "Ar", label: "Arabic" },
|
||||||
{ key: "En", lowerKey: "en" as const, label: "English" },
|
{ key: "En", label: "English" },
|
||||||
{ key: "De", lowerKey: "de" as const, label: "German" },
|
{ key: "De", label: "German" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const copy = {
|
const copy = {
|
||||||
addCategory: "Kategorie hinzufuegen",
|
addCategory: "Add Category",
|
||||||
saveCategory: "Kategorie speichern",
|
saveCategory: "Save Category",
|
||||||
save: "Speichern",
|
save: "Save",
|
||||||
delete: "Loeschen",
|
delete: "Delete",
|
||||||
active: "Aktiv",
|
active: "Active",
|
||||||
sortOrder: "Sortierung",
|
sortOrder: "Sort Order",
|
||||||
projects: "Projekte",
|
projects: "Projects",
|
||||||
description: "Beschreibung",
|
description: "Description",
|
||||||
currentCategories: "Aktuelle Kategorien",
|
currentCategories: "Current Categories",
|
||||||
modalDescription: "Neue Kategorie direkt im Popup anlegen.",
|
modalDescription: "Create a new category with a faster flow for basics, localization, and status.",
|
||||||
editDescription: "Kategorie im Popup aendern oder loeschen.",
|
editDescription: "Update category content, change status, or remove the category if it has no assigned projects.",
|
||||||
empty: "Noch keine Kategorien vorhanden.",
|
empty: "No categories yet.",
|
||||||
deleteBlocked: "Loeschen erst moeglich, wenn keine Projekte mehr zugeordnet sind.",
|
deleteBlocked: "Delete becomes available only when no projects are assigned.",
|
||||||
editCategory: "Kategorie bearbeiten",
|
editCategory: "Edit Category",
|
||||||
};
|
};
|
||||||
|
|
||||||
type CategoryAction = typeof upsertCategoryAction;
|
type CategoryAction = typeof upsertCategoryAction;
|
||||||
type CategoryDeleteAction = typeof deleteCategoryAction;
|
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 = {
|
type PortfolioCategoriesManagerProps = {
|
||||||
categories: Array<PortfolioCategoryView & { projectCount: number }>;
|
categories: Array<PortfolioCategoryView & { projectCount: number }>;
|
||||||
activeCount: number;
|
activeCount: number;
|
||||||
@@ -65,16 +87,17 @@ function CategoryLocaleFields({
|
|||||||
values,
|
values,
|
||||||
}: {
|
}: {
|
||||||
idPrefix: string;
|
idPrefix: string;
|
||||||
values?: {
|
values?: CategoryFormValues;
|
||||||
nameAr?: string;
|
|
||||||
nameEn?: string;
|
|
||||||
nameDe?: string;
|
|
||||||
descriptionAr?: string;
|
|
||||||
descriptionEn?: string;
|
|
||||||
descriptionDe?: string;
|
|
||||||
};
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
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">
|
<div className="grid gap-4 xl:grid-cols-3">
|
||||||
{locales.map((locale) => {
|
{locales.map((locale) => {
|
||||||
const nameKey = `name${locale.key}` as const;
|
const nameKey = `name${locale.key}` as const;
|
||||||
@@ -82,12 +105,10 @@ function CategoryLocaleFields({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AppCard key={`${idPrefix}-${locale.key}`} level={2} padding="sm" className="space-y-4 rounded-nested">
|
<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>
|
<p className="text-sm font-medium text-foreground">{locale.label}</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<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">
|
<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" />
|
<Text className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
@@ -102,7 +123,7 @@ function CategoryLocaleFields({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<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">
|
<div className="relative">
|
||||||
<FileText className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
<FileText className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -120,6 +141,77 @@ function CategoryLocaleFields({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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;
|
saveCategoryAction: CategoryAction;
|
||||||
removeCategoryAction: CategoryDeleteAction;
|
removeCategoryAction: CategoryDeleteAction;
|
||||||
}) {
|
}) {
|
||||||
|
const formId = `portfolio-category-form-${category.id}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||||
@@ -144,44 +238,33 @@ function EditCategoryDialog({
|
|||||||
<DialogDescription>{copy.editDescription}</DialogDescription>
|
<DialogDescription>{copy.editDescription}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form id={`portfolio-category-form-${category.id}`} action={saveCategoryAction} className="space-y-6">
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
<input type="hidden" name="id" value={category.id} />
|
<AppCard level={2} padding="sm" className="rounded-nested">
|
||||||
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
|
<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>
|
||||||
<div className="grid gap-4 lg:grid-cols-3">
|
</AppCard>
|
||||||
<div className="space-y-2">
|
<AppCard level={2} padding="sm" className="rounded-nested">
|
||||||
<Label htmlFor={`slug-${category.id}`}>Slug</Label>
|
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Status</p>
|
||||||
<div className="relative">
|
<div className="mt-2">
|
||||||
<Text className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Badge variant={category.isActive ? "success" : "warning"}>
|
||||||
<Input id={`slug-${category.id}`} name="slug" defaultValue={category.slug} required className="pl-9" />
|
{category.isActive ? "Active" : "Inactive"}
|
||||||
|
</Badge>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<CategoryForm
|
||||||
<Label htmlFor={`sortOrder-${category.id}`}>{copy.sortOrder}</Label>
|
formId={formId}
|
||||||
<div className="relative">
|
action={saveCategoryAction}
|
||||||
<Hash className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
categoryId={category.id}
|
||||||
<Input
|
|
||||||
id={`sortOrder-${category.id}`}
|
|
||||||
name="sortOrder"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
defaultValue={category.sortOrder}
|
|
||||||
required
|
|
||||||
className="pl-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="flex items-center gap-3 rounded-nested border border-input bg-card px-4 py-3 text-sm">
|
|
||||||
<Checkbox name="isActive" defaultChecked={category.isActive} />
|
|
||||||
{copy.active}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CategoryLocaleFields
|
|
||||||
idPrefix={`category-${category.id}`}
|
|
||||||
values={{
|
values={{
|
||||||
|
slug: category.slug,
|
||||||
|
sortOrder: category.sortOrder,
|
||||||
|
isActive: category.isActive,
|
||||||
nameAr: category.name.ar,
|
nameAr: category.name.ar,
|
||||||
nameEn: category.name.en,
|
nameEn: category.name.en,
|
||||||
nameDe: category.name.de,
|
nameDe: category.name.de,
|
||||||
@@ -190,11 +273,10 @@ function EditCategoryDialog({
|
|||||||
descriptionDe: category.description.de,
|
descriptionDe: category.description.de,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</form>
|
|
||||||
|
|
||||||
<DialogFooter className="items-center justify-between sm:flex-row">
|
<DialogFooter className="items-center justify-between sm:flex-row">
|
||||||
<p className="text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
<div className="flex w-full flex-col-reverse gap-2 sm:w-auto sm:flex-row">
|
<div className="flex w-full flex-col-reverse gap-2 sm:w-auto sm:flex-row">
|
||||||
<form action={removeCategoryAction}>
|
<form action={removeCategoryAction}>
|
||||||
@@ -205,7 +287,7 @@ function EditCategoryDialog({
|
|||||||
{copy.delete}
|
{copy.delete}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
<Button type="submit" form={`portfolio-category-form-${category.id}`}>
|
<Button type="submit" form={formId}>
|
||||||
{copy.save}
|
{copy.save}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -256,38 +338,34 @@ export function PortfolioCategoriesManager({
|
|||||||
<DialogDescription>{copy.modalDescription}</DialogDescription>
|
<DialogDescription>{copy.modalDescription}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form id="portfolio-category-create-form" action={saveCategoryAction} className="space-y-6">
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
|
<AppCard level={2} padding="sm" className="rounded-nested">
|
||||||
|
<Sparkles className="h-4 w-4 text-brand-primary" />
|
||||||
<div className="grid gap-4 lg:grid-cols-3">
|
<p className="mt-3 text-sm font-medium text-foreground">Start with basics</p>
|
||||||
<div className="space-y-2">
|
<p className="mt-1 text-sm text-muted-foreground">Slug and sort order first.</p>
|
||||||
<Label htmlFor="create-slug">Slug</Label>
|
</AppCard>
|
||||||
<div className="relative">
|
<AppCard level={2} padding="sm" className="rounded-nested">
|
||||||
<Text className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Layers3 className="h-4 w-4 text-brand-primary" />
|
||||||
<Input id="create-slug" name="slug" required placeholder="Slug" className="pl-9" />
|
<p className="mt-3 text-sm font-medium text-foreground">Fill all locales</p>
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<CategoryForm
|
||||||
<Label htmlFor="create-sortOrder">{copy.sortOrder}</Label>
|
formId="portfolio-category-create-form"
|
||||||
<div className="relative">
|
action={saveCategoryAction}
|
||||||
<Hash className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
/>
|
||||||
<Input id="create-sortOrder" name="sortOrder" type="number" min="0" defaultValue="0" required className="pl-9" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="flex items-center gap-3 rounded-nested border border-input bg-card px-4 py-3 text-sm">
|
|
||||||
<Checkbox name="isActive" defaultChecked />
|
|
||||||
{copy.active}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CategoryLocaleFields idPrefix="create" />
|
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="submit">{copy.saveCategory}</Button>
|
<Button type="submit" form="portfolio-category-create-form">
|
||||||
|
{copy.saveCategory}
|
||||||
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -307,54 +385,50 @@ export function PortfolioCategoriesManager({
|
|||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<AccordionItem key={category.id} value={category.id}>
|
<AccordionItem key={category.id} value={category.id}>
|
||||||
<AccordionTrigger className="bg-surface-2 hover:no-underline">
|
<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="space-y-1">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-base font-semibold text-foreground">
|
<span className="text-base font-semibold text-foreground">
|
||||||
{category.name.de || category.name.en || category.name.ar}
|
{category.name.de || category.name.en || category.name.ar}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant={category.isActive ? "success" : "warning"}>
|
<Badge variant={category.isActive ? "success" : "warning"}>
|
||||||
{category.isActive ? "Aktiv" : "Inaktiv"}
|
{category.isActive ? "Active" : "Inactive"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||||
<span>{category.slug}</span>
|
<span>{category.slug}</span>
|
||||||
<span>•</span>
|
<span>{copy.projects}: {category.projectCount}</span>
|
||||||
<span>{copy.sortOrder} {category.sortOrder}</span>
|
<span>{copy.sortOrder}: {category.sortOrder}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Badge variant="outline">{category.projectCount} {copy.projects}</Badge>
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
|
||||||
setEditingCategoryId(category.id);
|
setEditingCategoryId(category.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-4 w-4" />
|
||||||
{copy.editCategory}
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="bg-background">
|
<AccordionContent className="space-y-4">
|
||||||
<div className="space-y-3">
|
<div className="grid gap-4 xl:grid-cols-3">
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
|
||||||
{locales.map((locale) => (
|
{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="text-sm font-medium text-foreground">{locale.label}</p>
|
||||||
<p className="mt-3 text-sm text-foreground">{category.name[locale.lowerKey]}</p>
|
<p className="text-sm font-semibold text-foreground">
|
||||||
<p className="mt-2 text-sm text-muted-foreground">{category.description[locale.lowerKey]}</p>
|
{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>
|
</AppCard>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</AccordionContent>
|
|
||||||
|
|
||||||
<EditCategoryDialog
|
<EditCategoryDialog
|
||||||
category={category}
|
category={category}
|
||||||
@@ -363,6 +437,7 @@ export function PortfolioCategoriesManager({
|
|||||||
saveCategoryAction={saveCategoryAction}
|
saveCategoryAction={saveCategoryAction}
|
||||||
removeCategoryAction={removeCategoryAction}
|
removeCategoryAction={removeCategoryAction}
|
||||||
/>
|
/>
|
||||||
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
))}
|
))}
|
||||||
</Accordion>
|
</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 Link from "next/link";
|
||||||
|
|
||||||
import { StatsCard } from "@/components/dashboard/stats-card";
|
import { StatsCard } from "@/components/dashboard/stats-card";
|
||||||
import { MotionFade } from "@/components/motion-fade";
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
|
import { PortfolioProjectActions } from "@/components/root/portfolio-project-actions";
|
||||||
import { AppCard } from "@/components/ui/app-card";
|
import { AppCard } from "@/components/ui/app-card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { CardContent } from "@/components/ui/card";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { getLocalizedPath } from "@/lib/locale";
|
import { getLocalizedPath } from "@/lib/locale";
|
||||||
import { getLocalizedValue, type PortfolioCategoryView, type PortfolioProjectView } from "@/lib/portfolio";
|
import { getLocalizedValue, type PortfolioCategoryView, type PortfolioProjectView } from "@/lib/portfolio";
|
||||||
|
|
||||||
@@ -26,16 +19,10 @@ type PortfolioProjectsOverviewProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const copy = {
|
const copy = {
|
||||||
category: "Kategorie",
|
|
||||||
all: "Alle",
|
all: "Alle",
|
||||||
status: "Status",
|
|
||||||
draft: "Entwurf",
|
|
||||||
published: "Veroeffentlicht",
|
|
||||||
filter: "Filtern",
|
|
||||||
newProject: "Neues Projekt",
|
newProject: "Neues Projekt",
|
||||||
newCategory: "Neues Kategorie",
|
newCategory: "Neues Kategorie",
|
||||||
openProject: "Ansehen",
|
openProject: "Ansehen",
|
||||||
editProject: "Bearbeiten",
|
|
||||||
untitled: "Unbenanntes Projekt",
|
untitled: "Unbenanntes Projekt",
|
||||||
empty: "Noch keine Projekte vorhanden.",
|
empty: "Noch keine Projekte vorhanden.",
|
||||||
};
|
};
|
||||||
@@ -44,14 +31,11 @@ export function PortfolioProjectsOverview({
|
|||||||
categories,
|
categories,
|
||||||
projects,
|
projects,
|
||||||
selectedCategory,
|
selectedCategory,
|
||||||
selectedStatus,
|
|
||||||
}: PortfolioProjectsOverviewProps) {
|
}: PortfolioProjectsOverviewProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<MotionFade delay={0.05}>
|
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||||
<AppCard level={3}>
|
<div className="grid gap-4 md:grid-cols-3 xl:min-w-[520px]">
|
||||||
<CardContent className="flex flex-col gap-6 p-6 lg:flex-row lg:items-end lg:justify-between lg:p-8">
|
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
|
||||||
<StatsCard title="Projects" value={String(projects.length)} />
|
<StatsCard title="Projects" value={String(projects.length)} />
|
||||||
<StatsCard title="Categories" value={String(categories.length)} />
|
<StatsCard title="Categories" value={String(categories.length)} />
|
||||||
<StatsCard
|
<StatsCard
|
||||||
@@ -60,7 +44,7 @@ export function PortfolioProjectsOverview({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link href="/root/portfolio/projects/new">
|
<Link href="/root/portfolio/projects/new">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
@@ -74,82 +58,44 @@ export function PortfolioProjectsOverview({
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Label htmlFor="portfolio-filter-status" className="sr-only">
|
<Button asChild variant={selectedCategory === "" ? "default" : "outline"}>
|
||||||
{copy.status}
|
<Link href="/root/portfolio">{copy.all}</Link>
|
||||||
</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Filter className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<Select name="status" defaultValue={selectedStatus}>
|
|
||||||
<SelectTrigger id="portfolio-filter-status" className="pl-9">
|
|
||||||
<SelectValue placeholder={copy.status} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">{copy.all}</SelectItem>
|
|
||||||
<SelectItem value="draft">{copy.draft}</SelectItem>
|
|
||||||
<SelectItem value="published">{copy.published}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-end">
|
|
||||||
<Button type="submit" variant="outline">
|
|
||||||
{copy.filter}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<Button
|
||||||
|
key={category.id}
|
||||||
|
asChild
|
||||||
|
variant={selectedCategory === category.id ? "default" : "outline"}
|
||||||
|
>
|
||||||
|
<Link href={`/root/portfolio?category=${category.id}`}>
|
||||||
|
{category.name.de || category.name.en || category.name.ar}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</AppCard>
|
|
||||||
</MotionFade>
|
|
||||||
|
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{projects.map((project, index) => (
|
{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>
|
<AppCard interactive>
|
||||||
<CardHeader className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
<CardContent className="flex flex-col gap-4 p-5 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-2">
|
||||||
<div className="flex flex-wrap items-center gap-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}
|
{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>
|
</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>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
@@ -163,14 +109,9 @@ export function PortfolioProjectsOverview({
|
|||||||
{copy.openProject}
|
{copy.openProject}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild>
|
<PortfolioProjectActions projectId={project.id} />
|
||||||
<Link href={`/root/portfolio/projects/${project.id}`}>
|
|
||||||
<FolderKanban className="h-4 w-4" />
|
|
||||||
{copy.editProject}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardContent>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
</MotionFade>
|
</MotionFade>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import Link from "next/link";
|
|||||||
|
|
||||||
import { DashboardLayout } from "@/components/dashboard/dashboard-layout";
|
import { DashboardLayout } from "@/components/dashboard/dashboard-layout";
|
||||||
import { MotionFade } from "@/components/motion-fade";
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
import { FormSaveButton } from "@/components/root/form-save-button";
|
|
||||||
import { SidebarMaintenanceControl } from "@/components/root/sidebar-maintenance-control";
|
import { SidebarMaintenanceControl } from "@/components/root/sidebar-maintenance-control";
|
||||||
import { SoundToggle } from "@/components/sound-toggle";
|
import { SoundToggle } from "@/components/sound-toggle";
|
||||||
import { ThemeToggle } from "@/components/theme-toggle";
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
@@ -50,10 +49,6 @@ type RootDashboardShellProps = {
|
|||||||
logoutAction: () => Promise<void>;
|
logoutAction: () => Promise<void>;
|
||||||
headerTitle: string;
|
headerTitle: string;
|
||||||
headerDescription: string;
|
headerDescription: string;
|
||||||
saveFormId?: string;
|
|
||||||
saveFormSelector?: string;
|
|
||||||
saveButtonLabel?: string;
|
|
||||||
reloadDocumentOnSave?: boolean;
|
|
||||||
headerActions?: ReactNode;
|
headerActions?: ReactNode;
|
||||||
sidebarTopContent?: ReactNode;
|
sidebarTopContent?: ReactNode;
|
||||||
toolbar?: ReactNode;
|
toolbar?: ReactNode;
|
||||||
@@ -68,10 +63,6 @@ export async function RootDashboardShell({
|
|||||||
logoutAction,
|
logoutAction,
|
||||||
headerTitle,
|
headerTitle,
|
||||||
headerDescription,
|
headerDescription,
|
||||||
saveFormId,
|
|
||||||
saveFormSelector,
|
|
||||||
saveButtonLabel,
|
|
||||||
reloadDocumentOnSave = false,
|
|
||||||
headerActions,
|
headerActions,
|
||||||
sidebarTopContent,
|
sidebarTopContent,
|
||||||
toolbar,
|
toolbar,
|
||||||
@@ -112,28 +103,6 @@ export async function RootDashboardShell({
|
|||||||
: portfolioChild === "new-project"
|
: portfolioChild === "new-project"
|
||||||
? PlusSquare
|
? PlusSquare
|
||||||
: FolderKanban;
|
: 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 (
|
return (
|
||||||
<DashboardLayout
|
<DashboardLayout
|
||||||
title={headerTitle}
|
title={headerTitle}
|
||||||
@@ -175,7 +144,17 @@ export async function RootDashboardShell({
|
|||||||
headerActions={
|
headerActions={
|
||||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
{headerActions}
|
{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>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -728,6 +728,9 @@ export function SiteSettingsForm({
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit">Save Settings</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { KeyRound, Mail, Send, Server } from "lucide-react";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { AppCard } from "@/components/ui/app-card";
|
import { AppCard } from "@/components/ui/app-card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -171,6 +172,9 @@ export function SMTPSettingsForm({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit">Save SMTP</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</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 { z } from "zod";
|
||||||
|
|
||||||
import { mediaFieldInputSchema } from "./media-validation";
|
import { mediaFieldInputSchema } from "./media-validation";
|
||||||
@@ -10,6 +10,7 @@ const requiredText = (label: string) =>
|
|||||||
.min(1, `${label} is required.`);
|
.min(1, `${label} is required.`);
|
||||||
|
|
||||||
const optionalTrimmedText = z.string().trim().optional().transform((value) => value ?? "");
|
const optionalTrimmedText = z.string().trim().optional().transform((value) => value ?? "");
|
||||||
|
const portfolioProjectViewModes = ["GRID", "STORY", "CASE_STUDY"] as const;
|
||||||
|
|
||||||
export const categoryInputSchema = z.object({
|
export const categoryInputSchema = z.object({
|
||||||
id: z.string().trim().optional(),
|
id: z.string().trim().optional(),
|
||||||
@@ -83,7 +84,7 @@ export const sectionInputSchema = z
|
|||||||
|
|
||||||
export const assetInputSchema = z.object({
|
export const assetInputSchema = z.object({
|
||||||
id: z.string().trim().optional(),
|
id: z.string().trim().optional(),
|
||||||
kind: z.nativeEnum(PortfolioAssetKind),
|
kind: z.literal("IMAGE"),
|
||||||
filePath: optionalTrimmedText,
|
filePath: optionalTrimmedText,
|
||||||
fileFieldName: optionalTrimmedText,
|
fileFieldName: optionalTrimmedText,
|
||||||
media: mediaFieldInputSchema.optional(),
|
media: mediaFieldInputSchema.optional(),
|
||||||
@@ -98,6 +99,7 @@ export const projectInputSchema = z.object({
|
|||||||
categoryId: requiredText("Project categoryId"),
|
categoryId: requiredText("Project categoryId"),
|
||||||
slug: requiredText("Project slug")
|
slug: requiredText("Project slug")
|
||||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Project slug must be lowercase and hyphenated."),
|
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Project slug must be lowercase and hyphenated."),
|
||||||
|
viewMode: z.enum(portfolioProjectViewModes).default("GRID"),
|
||||||
titleAr: requiredText("Project titleAr"),
|
titleAr: requiredText("Project titleAr"),
|
||||||
titleEn: requiredText("Project titleEn"),
|
titleEn: requiredText("Project titleEn"),
|
||||||
titleDe: requiredText("Project titleDe"),
|
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 { getPortfolioMediaBindings } from "@/lib/media";
|
||||||
import type { AppLocale } from "@/lib/locale";
|
import type { AppLocale } from "@/lib/locale";
|
||||||
@@ -42,6 +48,7 @@ type ProjectRecord = Pick<
|
|||||||
PortfolioProject,
|
PortfolioProject,
|
||||||
| "id"
|
| "id"
|
||||||
| "slug"
|
| "slug"
|
||||||
|
| "viewMode"
|
||||||
| "titleAr"
|
| "titleAr"
|
||||||
| "titleEn"
|
| "titleEn"
|
||||||
| "titleDe"
|
| "titleDe"
|
||||||
@@ -99,6 +106,7 @@ export type PortfolioAssetView = {
|
|||||||
export type PortfolioProjectView = {
|
export type PortfolioProjectView = {
|
||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
viewMode: PortfolioProjectViewMode;
|
||||||
title: LocalizedContent;
|
title: LocalizedContent;
|
||||||
summary: LocalizedContent;
|
summary: LocalizedContent;
|
||||||
clientName: string;
|
clientName: string;
|
||||||
@@ -116,6 +124,20 @@ export type PortfolioProjectView = {
|
|||||||
assets: PortfolioAssetView[];
|
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 {
|
function mapLocalizedContent(record: Record<string, unknown>, prefix: string): LocalizedContent {
|
||||||
return {
|
return {
|
||||||
ar: String(record[`${prefix}Ar`] ?? ""),
|
ar: String(record[`${prefix}Ar`] ?? ""),
|
||||||
@@ -174,6 +196,7 @@ function mapProject(
|
|||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
slug: record.slug,
|
slug: record.slug,
|
||||||
|
viewMode: resolvePortfolioProjectViewMode(record.viewMode),
|
||||||
title: mapLocalizedContent(record, "title"),
|
title: mapLocalizedContent(record, "title"),
|
||||||
summary: mapLocalizedContent(record, "summary"),
|
summary: mapLocalizedContent(record, "summary"),
|
||||||
clientName: record.clientName,
|
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
|
DOCUMENT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum PortfolioProjectViewMode {
|
||||||
|
GRID
|
||||||
|
STORY
|
||||||
|
CASE_STUDY
|
||||||
|
}
|
||||||
|
|
||||||
enum MediaSource {
|
enum MediaSource {
|
||||||
UPLOAD
|
UPLOAD
|
||||||
EXTERNAL
|
EXTERNAL
|
||||||
@@ -64,6 +70,7 @@ model PortfolioProject {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
categoryId String
|
categoryId String
|
||||||
slug String @unique
|
slug String @unique
|
||||||
|
viewMode PortfolioProjectViewMode @default(GRID)
|
||||||
titleAr String
|
titleAr String
|
||||||
titleEn String
|
titleEn String
|
||||||
titleDe String
|
titleDe String
|
||||||
|
|||||||
+429
-108
@@ -9,7 +9,121 @@ const connectionString =
|
|||||||
const pool = new Pool({ connectionString });
|
const pool = new Pool({ connectionString });
|
||||||
const prisma = new PrismaClient({ adapter: new PrismaPg(pool) });
|
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() {
|
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({
|
await prisma.appConfig.upsert({
|
||||||
where: { key: "siteName" },
|
where: { key: "siteName" },
|
||||||
update: { value: "moh-sass" },
|
update: { value: "moh-sass" },
|
||||||
@@ -121,63 +235,226 @@ async function main() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const project = await prisma.portfolioProject.upsert({
|
const commerceCategory = await prisma.category.upsert({
|
||||||
where: { slug: "brand-redesign" },
|
where: { slug: "commerce" },
|
||||||
update: {
|
update: {
|
||||||
categoryId: brandCategory.id,
|
nameAr: "التجارة الرقمية",
|
||||||
titleAr: "إعادة تصميم الهوية",
|
nameEn: "Commerce",
|
||||||
titleEn: "Brand Redesign",
|
nameDe: "Commerce",
|
||||||
titleDe: "Brand Redesign",
|
descriptionAr: "متاجر وتجارب شراء رقمية مع تركيز على الوضوح والتحويل.",
|
||||||
summaryAr: "إعادة بناء لهوية رقمية مع نظام مرئي أوضح ومسارات استخدام أسرع.",
|
descriptionEn: "Commerce experiences with a focus on clarity and conversion.",
|
||||||
summaryEn: "A digital brand refresh with a clearer visual system and faster journeys.",
|
descriptionDe: "Commerce-Projekte mit Fokus auf Klarheit und Conversion.",
|
||||||
summaryDe: "Ein digitales Redesign mit klarerem visuellen System und schnelleren Journeys.",
|
sortOrder: 3,
|
||||||
clientName: "Studio Client",
|
isActive: true,
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
create: {
|
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",
|
slug: "brand-redesign",
|
||||||
|
categoryId: brandCategory.id,
|
||||||
|
viewMode: "CASE_STUDY",
|
||||||
titleAr: "إعادة تصميم الهوية",
|
titleAr: "إعادة تصميم الهوية",
|
||||||
titleEn: "Brand Redesign",
|
titleEn: "Brand Redesign",
|
||||||
titleDe: "Brand Redesign",
|
titleDe: "Brand Redesign",
|
||||||
summaryAr: "إعادة بناء لهوية رقمية مع نظام مرئي أوضح ومسارات استخدام أسرع.",
|
summaryAr: "مثال عرض دراسة حالة يركز على التحدي والحل والنتيجة.",
|
||||||
summaryEn: "A digital brand refresh with a clearer visual system and faster journeys.",
|
summaryEn: "Case study example focused on challenge, solution, and outcome.",
|
||||||
summaryDe: "Ein digitales Redesign mit klarerem visuellen System und schnelleren Journeys.",
|
summaryDe: "Case-Study-Ansicht mit Fokus auf Herausforderung, Loesung und Ergebnis.",
|
||||||
clientName: "Studio Client",
|
clientName: "Studio Client",
|
||||||
projectYear: 2025,
|
projectYear: 2025,
|
||||||
serviceLabelAr: "هوية بصرية",
|
serviceLabelAr: "هوية بصرية",
|
||||||
serviceLabelEn: "Brand Identity",
|
serviceLabelEn: "Brand Identity",
|
||||||
serviceLabelDe: "Brand Identity",
|
serviceLabelDe: "Brand Identity",
|
||||||
previewUrl: "https://example.com/preview/brand-redesign",
|
previewUrl: "https://example.com/preview/brand-redesign",
|
||||||
coverImagePath: "/uploads/portfolio/demo-cover.svg",
|
coverImagePath: caseStudyCover.url,
|
||||||
isFeatured: true,
|
isFeatured: true,
|
||||||
isPublished: true,
|
isPublished: true,
|
||||||
publishedAt: new Date("2025-01-10T09:00:00.000Z"),
|
publishedAt: new Date("2025-01-10T09:00:00.000Z"),
|
||||||
sortOrder: 1,
|
sortOrder: 3,
|
||||||
},
|
coverAssetId: caseStudyCover.id,
|
||||||
});
|
sections: [
|
||||||
|
|
||||||
await prisma.portfolioSection.deleteMany({
|
|
||||||
where: { projectId: project.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.portfolioAsset.deleteMany({
|
|
||||||
where: { projectId: project.id },
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.portfolioSection.createMany({
|
|
||||||
data: [
|
|
||||||
{
|
{
|
||||||
projectId: project.id,
|
|
||||||
type: "RICH_TEXT",
|
type: "RICH_TEXT",
|
||||||
titleAr: "التحدي",
|
titleAr: "التحدي",
|
||||||
titleEn: "Challenge",
|
titleEn: "Challenge",
|
||||||
@@ -185,10 +462,12 @@ async function main() {
|
|||||||
bodyAr: "كان المطلوب تحديث الهوية بدون خسارة التعرف البصري الحالي.",
|
bodyAr: "كان المطلوب تحديث الهوية بدون خسارة التعرف البصري الحالي.",
|
||||||
bodyEn: "The brief required a refreshed identity without losing recognition.",
|
bodyEn: "The brief required a refreshed identity without losing recognition.",
|
||||||
bodyDe: "Die Marke sollte modernisiert werden, ohne die Wiedererkennbarkeit zu verlieren.",
|
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",
|
type: "RICH_TEXT",
|
||||||
titleAr: "الحل",
|
titleAr: "الحل",
|
||||||
titleEn: "Solution",
|
titleEn: "Solution",
|
||||||
@@ -196,81 +475,123 @@ async function main() {
|
|||||||
bodyAr: "تم بناء نظام مرئي أوضح مع قواعد استخدام قابلة للتوسع.",
|
bodyAr: "تم بناء نظام مرئي أوضح مع قواعد استخدام قابلة للتوسع.",
|
||||||
bodyEn: "A clearer visual system with scalable usage rules was created.",
|
bodyEn: "A clearer visual system with scalable usage rules was created.",
|
||||||
bodyDe: "Es wurde ein klareres visuelles System mit skalierbaren Regeln aufgebaut.",
|
bodyDe: "Es wurde ein klareres visuelles System mit skalierbaren Regeln aufgebaut.",
|
||||||
sortOrder: 2,
|
imagePath: null,
|
||||||
},
|
linkUrl: null,
|
||||||
{
|
|
||||||
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",
|
|
||||||
sortOrder: 1,
|
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({
|
for (const projectConfig of projects) {
|
||||||
where: { slug: "campaign-site" },
|
const project = await prisma.portfolioProject.upsert({
|
||||||
|
where: { slug: projectConfig.slug },
|
||||||
update: {
|
update: {
|
||||||
categoryId: webCategory.id,
|
categoryId: projectConfig.categoryId,
|
||||||
titleAr: "موقع حملة",
|
viewMode: projectConfig.viewMode,
|
||||||
titleEn: "Campaign Site",
|
titleAr: projectConfig.titleAr,
|
||||||
titleDe: "Campaign Site",
|
titleEn: projectConfig.titleEn,
|
||||||
summaryAr: "صفحة إطلاق مرنة لحملة رقمية مع تركيز على السرعة والتحويل.",
|
titleDe: projectConfig.titleDe,
|
||||||
summaryEn: "A launch site built for speed, iteration, and conversion.",
|
summaryAr: projectConfig.summaryAr,
|
||||||
summaryDe: "Eine Kampagnenseite mit Fokus auf Tempo, Iteration und Conversion.",
|
summaryEn: projectConfig.summaryEn,
|
||||||
clientName: "Launch Client",
|
summaryDe: projectConfig.summaryDe,
|
||||||
projectYear: 2024,
|
clientName: projectConfig.clientName,
|
||||||
serviceLabelAr: "موقع تسويقي",
|
projectYear: projectConfig.projectYear,
|
||||||
serviceLabelEn: "Marketing Website",
|
serviceLabelAr: projectConfig.serviceLabelAr,
|
||||||
serviceLabelDe: "Marketing Website",
|
serviceLabelEn: projectConfig.serviceLabelEn,
|
||||||
previewUrl: "https://example.com/preview/campaign-site",
|
serviceLabelDe: projectConfig.serviceLabelDe,
|
||||||
coverImagePath: "/uploads/portfolio/demo-cover.svg",
|
previewUrl: projectConfig.previewUrl,
|
||||||
isFeatured: false,
|
coverImagePath: projectConfig.coverImagePath,
|
||||||
isPublished: true,
|
isFeatured: projectConfig.isFeatured,
|
||||||
publishedAt: new Date("2024-09-05T09:00:00.000Z"),
|
isPublished: projectConfig.isPublished,
|
||||||
sortOrder: 2,
|
publishedAt: projectConfig.publishedAt,
|
||||||
|
sortOrder: projectConfig.sortOrder,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
categoryId: webCategory.id,
|
slug: projectConfig.slug,
|
||||||
slug: "campaign-site",
|
categoryId: projectConfig.categoryId,
|
||||||
titleAr: "موقع حملة",
|
viewMode: projectConfig.viewMode,
|
||||||
titleEn: "Campaign Site",
|
titleAr: projectConfig.titleAr,
|
||||||
titleDe: "Campaign Site",
|
titleEn: projectConfig.titleEn,
|
||||||
summaryAr: "صفحة إطلاق مرنة لحملة رقمية مع تركيز على السرعة والتحويل.",
|
titleDe: projectConfig.titleDe,
|
||||||
summaryEn: "A launch site built for speed, iteration, and conversion.",
|
summaryAr: projectConfig.summaryAr,
|
||||||
summaryDe: "Eine Kampagnenseite mit Fokus auf Tempo, Iteration und Conversion.",
|
summaryEn: projectConfig.summaryEn,
|
||||||
clientName: "Launch Client",
|
summaryDe: projectConfig.summaryDe,
|
||||||
projectYear: 2024,
|
clientName: projectConfig.clientName,
|
||||||
serviceLabelAr: "موقع تسويقي",
|
projectYear: projectConfig.projectYear,
|
||||||
serviceLabelEn: "Marketing Website",
|
serviceLabelAr: projectConfig.serviceLabelAr,
|
||||||
serviceLabelDe: "Marketing Website",
|
serviceLabelEn: projectConfig.serviceLabelEn,
|
||||||
previewUrl: "https://example.com/preview/campaign-site",
|
serviceLabelDe: projectConfig.serviceLabelDe,
|
||||||
coverImagePath: "/uploads/portfolio/demo-cover.svg",
|
previewUrl: projectConfig.previewUrl,
|
||||||
isFeatured: false,
|
coverImagePath: projectConfig.coverImagePath,
|
||||||
isPublished: true,
|
isFeatured: projectConfig.isFeatured,
|
||||||
publishedAt: new Date("2024-09-05T09:00:00.000Z"),
|
isPublished: projectConfig.isPublished,
|
||||||
sortOrder: 2,
|
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()
|
main()
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ describe("portfolio validation", () => {
|
|||||||
projectInputSchema.parse({
|
projectInputSchema.parse({
|
||||||
categoryId: "cat_1",
|
categoryId: "cat_1",
|
||||||
slug: "Invalid Slug",
|
slug: "Invalid Slug",
|
||||||
|
viewMode: "GRID",
|
||||||
titleAr: "عنوان",
|
titleAr: "عنوان",
|
||||||
titleEn: "Title",
|
titleEn: "Title",
|
||||||
titleDe: "Titel",
|
titleDe: "Titel",
|
||||||
@@ -123,6 +124,41 @@ describe("portfolio validation", () => {
|
|||||||
).toBe("IMAGE");
|
).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", () => {
|
it("rejects invalid media payloads", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
assetInputSchema.parse({
|
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