From 7956b073e9022f7010d77c19a919414d6ac1461a Mon Sep 17 00:00:00 2001 From: MOH Date: Wed, 11 Mar 2026 17:46:35 +0100 Subject: [PATCH] Refactor portfolio admin and views --- app/[locale]/(site)/portfolio/[slug]/page.tsx | 227 +--- app/root/marquee/page.tsx | 1 - app/root/portfolio/actions.ts | 3 + app/root/portfolio/projects/[id]/page.tsx | 44 +- app/root/portfolio/projects/new/page.tsx | 1 - app/root/site-settings/page.tsx | 1 - app/root/smtp/contact-protection/page.tsx | 1 - app/root/smtp/page.tsx | 1 - components/root/contact-protection-form.tsx | 4 + components/root/form-save-button.tsx | 304 ------ components/root/marquee-settings-form.tsx | 4 + components/root/media-field-picker.tsx | 282 ++--- .../root/portfolio-categories-manager.tsx | 437 ++++---- components/root/portfolio-project-actions.tsx | 80 ++ components/root/portfolio-project-form.tsx | 997 +++++++----------- .../root/portfolio-projects-overview.tsx | 171 +-- components/root/root-dashboard-shell.tsx | 43 +- components/root/site-settings-form.tsx | 3 + components/root/smtp-settings-form.tsx | 4 + components/site/portfolio-project-detail.tsx | 457 ++++++++ lib/portfolio-validation.ts | 6 +- lib/portfolio.ts | 25 +- .../migration.sql | 4 + prisma/schema.prisma | 23 +- prisma/seed.js | 577 +++++++--- tests/portfolio-validation.test.ts | 36 + tests/portfolio.test.ts | 15 + 27 files changed, 1972 insertions(+), 1779 deletions(-) delete mode 100644 components/root/form-save-button.tsx create mode 100644 components/root/portfolio-project-actions.tsx create mode 100644 components/site/portfolio-project-detail.tsx create mode 100644 prisma/migrations/20260311120000_add_portfolio_project_view_mode/migration.sql create mode 100644 tests/portfolio.test.ts diff --git a/app/[locale]/(site)/portfolio/[slug]/page.tsx b/app/[locale]/(site)/portfolio/[slug]/page.tsx index 7ba1ef1..9d97719 100644 --- a/app/[locale]/(site)/portfolio/[slug]/page.tsx +++ b/app/[locale]/(site)/portfolio/[slug]/page.tsx @@ -1,22 +1,16 @@ import type { Metadata } from "next"; -import { ArrowLeft, ArrowUpRight, CalendarDays, FolderKanban, Tag, UserRound } from "lucide-react"; -import Link from "next/link"; -import Image from "next/image"; import { getTranslations } from "next-intl/server"; import { notFound } from "next/navigation"; import { Container } from "@/components/layout/container"; import { PageHero } from "@/components/layout/page-hero"; -import { MotionFade } from "@/components/motion-fade"; +import { PortfolioProjectDetail } from "@/components/site/portfolio-project-detail"; import { buildLocalizedMetadata } from "@/lib/metadata"; -import { getLocalizedPath, resolveLocale } from "@/lib/locale"; +import { resolveLocale } from "@/lib/locale"; import { getLocalizedValue, getPublishedPortfolioProjectBySlug, } from "@/lib/portfolio"; -import { AppCard } from "@/components/ui/app-card"; -import { Button } from "@/components/ui/button"; -import { CardContent } from "@/components/ui/card"; type PortfolioItemPageProps = { params: { @@ -27,98 +21,6 @@ type PortfolioItemPageProps = { export const dynamic = "force-dynamic"; -function PortfolioImage({ - src, - alt, - className, - width, - height, -}: { - src: string; - alt: string; - className: string; - width: number; - height: number; -}) { - return ( - {alt} - ); -} - -function renderSectionContent( - section: NonNullable>>["sections"][number], - localeKey: ReturnType, - t: Awaited>, -) { - const title = getLocalizedValue(section.title, localeKey); - const body = getLocalizedValue(section.body, localeKey); - - if (section.type === "GALLERY") { - return ( -
-

{title}

- {section.imagePath ? ( - - ) : ( -
- No image configured. -
- )} -
- ); - } - - if (section.type === "LINK") { - return ( -
-

{title}

- {body ? ( -

{body}

- ) : null} - {section.linkUrl ? ( - - ) : null} -
- ); - } - - if (section.type === "STATS" || section.type === "DELIVERABLES") { - return ( -
-

{title}

-
-

{body}

-
-
- ); - } - - return ( -
-

{title}

-

{body}

-
- ); -} - export async function generateMetadata({ params: { locale, slug }, }: PortfolioItemPageProps): Promise { @@ -161,129 +63,8 @@ export default async function PortfolioItemPage({ description={getLocalizedValue(item.summary, localeKey)} /> - - - - - - -

- {getLocalizedValue(item.title, localeKey)} -

-

- {getLocalizedValue(item.summary, localeKey)} -

- - {item.coverImagePath ? ( -
- -
- ) : null} - -
- {[ - { - 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 ( - - - - {meta.label} - - - ); - })} -
- - {item.previewUrl ? ( -
- -
- ) : null} -
-
-
- -
- {item.sections.map((section, index) => ( - - - - {renderSectionContent(section, localeKey, t)} - - - - ))} -
- - {item.assets.length > 0 ? ( - - - -

{t("gallery")}

-
- {item.assets.map((asset) => ( -
- {asset.kind === "IMAGE" ? ( - - ) : ( -
-
-

{getLocalizedValue(asset.alt, localeKey)}

- -
-
- )} -
- ))} -
-
-
-
- ) : null} + + ); diff --git a/app/root/marquee/page.tsx b/app/root/marquee/page.tsx index 425a816..cca3c52 100644 --- a/app/root/marquee/page.tsx +++ b/app/root/marquee/page.tsx @@ -46,7 +46,6 @@ export default async function RootMarqueePage() { logoutAction={logoutAction} headerTitle={copy.title} headerDescription={copy.subtitle} - saveFormId="marquee-settings-form" >
diff --git a/app/root/portfolio/actions.ts b/app/root/portfolio/actions.ts index ca1f49a..f89241c 100644 --- a/app/root/portfolio/actions.ts +++ b/app/root/portfolio/actions.ts @@ -220,6 +220,7 @@ export async function saveProjectAction(formData: FormData) { id: String(formData.get("id") ?? "").trim() || undefined, categoryId: String(formData.get("categoryId") ?? ""), slug: String(formData.get("slug") ?? ""), + viewMode: String(formData.get("viewMode") ?? "GRID"), titleAr: String(formData.get("titleAr") ?? ""), titleEn: String(formData.get("titleEn") ?? ""), titleDe: String(formData.get("titleDe") ?? ""), @@ -369,6 +370,7 @@ export async function saveProjectAction(formData: FormData) { data: { categoryId: parsed.categoryId, slug: parsed.slug, + viewMode: parsed.viewMode, titleAr: parsed.titleAr, titleEn: parsed.titleEn, titleDe: parsed.titleDe, @@ -396,6 +398,7 @@ export async function saveProjectAction(formData: FormData) { data: { categoryId: parsed.categoryId, slug: parsed.slug, + viewMode: parsed.viewMode, titleAr: parsed.titleAr, titleEn: parsed.titleEn, titleDe: parsed.titleDe, diff --git a/app/root/portfolio/projects/[id]/page.tsx b/app/root/portfolio/projects/[id]/page.tsx index 530839d..8ebca64 100644 --- a/app/root/portfolio/projects/[id]/page.tsx +++ b/app/root/portfolio/projects/[id]/page.tsx @@ -6,6 +6,15 @@ import { RootDashboardShell } from "@/components/root/root-dashboard-shell"; import { AppCard } from "@/components/ui/app-card"; import { Button } from "@/components/ui/button"; import { CardContent } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; import { getMediaOptions } from "@/lib/media"; import { @@ -72,8 +81,6 @@ export default async function RootPortfolioProjectPage({ logoutAction={logoutAction} headerTitle={copy.title} headerDescription={copy.subtitle} - saveFormId="portfolio-project-form" - saveButtonLabel={copy.saveProject} >
@@ -88,20 +95,37 @@ export default async function RootPortfolioProjectPage({ - - + +

{copy.dangerZone}

{copy.dangerText}

-
- - -
+ + + + + + + {copy.deleteProject} + + This action permanently removes the project data from the database. + + + +
+ + +
+
+
+
diff --git a/app/root/portfolio/projects/new/page.tsx b/app/root/portfolio/projects/new/page.tsx index 79e0742..0b7c46b 100644 --- a/app/root/portfolio/projects/new/page.tsx +++ b/app/root/portfolio/projects/new/page.tsx @@ -49,7 +49,6 @@ export default async function RootNewPortfolioProjectPage() { logoutAction={logoutAction} headerTitle={copy.title} headerDescription={copy.subtitle} - saveFormId="portfolio-project-form" >
diff --git a/app/root/site-settings/page.tsx b/app/root/site-settings/page.tsx index 53aea1e..8d9cd59 100644 --- a/app/root/site-settings/page.tsx +++ b/app/root/site-settings/page.tsx @@ -54,7 +54,6 @@ export default async function RootSiteSettingsPage() { logoutAction={logoutAction} headerTitle={copy.title} headerDescription={copy.subtitle} - saveFormId="site-settings-form" >
diff --git a/app/root/smtp/contact-protection/page.tsx b/app/root/smtp/contact-protection/page.tsx index 3776c15..8e383ed 100644 --- a/app/root/smtp/contact-protection/page.tsx +++ b/app/root/smtp/contact-protection/page.tsx @@ -47,7 +47,6 @@ export default async function RootSMTPProtectionPage() { logoutAction={logoutAction} headerTitle={copy.title} headerDescription={copy.subtitle} - saveFormId="contact-protection-form" >
diff --git a/app/root/smtp/page.tsx b/app/root/smtp/page.tsx index 297512a..b35ed8b 100644 --- a/app/root/smtp/page.tsx +++ b/app/root/smtp/page.tsx @@ -48,7 +48,6 @@ export default async function RootSMTPPage() { logoutAction={logoutAction} headerTitle={copy.title} headerDescription={copy.subtitle} - saveFormId="smtp-settings-form" headerActions={(
+
+ +
); } diff --git a/components/root/form-save-button.tsx b/components/root/form-save-button.tsx deleted file mode 100644 index 4add7cf..0000000 --- a/components/root/form-save-button.tsx +++ /dev/null @@ -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(formId ?? null); - const [isDirty, setIsDirty] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const baselineRef = useRef>(new Map()); - const dirtyFormsRef = useRef>(new Set()); - const activeFormIdRef = useRef(formId ?? null); - const frameRef = useRef(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 ( - - ); -} diff --git a/components/root/marquee-settings-form.tsx b/components/root/marquee-settings-form.tsx index 170828c..e1bdd90 100644 --- a/components/root/marquee-settings-form.tsx +++ b/components/root/marquee-settings-form.tsx @@ -1,4 +1,5 @@ import { AppCard } from "@/components/ui/app-card"; +import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import type { MarqueeSettings } from "@/lib/marquee-settings"; @@ -39,6 +40,9 @@ export function MarqueeSettingsForm({ ))}
+
+ +
); } diff --git a/components/root/media-field-picker.tsx b/components/root/media-field-picker.tsx index 9ea0918..6df0e78 100644 --- a/components/root/media-field-picker.tsx +++ b/components/root/media-field-picker.tsx @@ -3,14 +3,23 @@ /* eslint-disable @next/next/no-img-element */ import type { MediaKind } from "@prisma/client"; -import { Check, Link2, Search, Type, Upload } from "lucide-react"; -import { useEffect, useId, useMemo, useRef, useState } from "react"; +import { Check, ImageIcon, Search, Trash2 } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; -import type { MediaOption } from "@/lib/media"; -import { cn } from "@/lib/utils"; import { AppCard } from "@/components/ui/app-card"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import type { MediaOption } from "@/lib/media"; +import { cn } from "@/lib/utils"; export type MediaFieldState = { mode: "upload" | "external" | "library"; @@ -29,11 +38,6 @@ type MediaFieldPickerProps = { hasInitialValue?: boolean; inputName: string; fileFieldName: string; - fileLabel?: string; - externalLabel?: string; - libraryLabel?: string; - accept?: string; - allowExternal?: boolean; allowClear?: boolean; clearLabel?: string; emptyValue?: Partial; @@ -46,58 +50,32 @@ export function MediaFieldPicker({ options, hasInitialValue = false, inputName, - fileFieldName, - fileLabel, - externalLabel, - libraryLabel, - accept, - allowExternal = true, allowClear = false, clearLabel = "Remove", emptyValue, }: MediaFieldPickerProps) { const hiddenInputRef = useRef(null); - const searchId = useId(); - const [libraryQuery, setLibraryQuery] = useState(""); - const modeOptions: Array = allowExternal - ? ["upload", "external", "library"] - : ["upload", "library"]; + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); const filteredOptions = options.filter((option) => option.kind === value.kind); const selectedOption = filteredOptions.find((option) => option.id === value.assetId) ?? null; - const visibleLibraryOptions = useMemo(() => { - const normalizedQuery = libraryQuery.trim().toLowerCase(); + const visibleOptions = useMemo(() => { + const normalizedQuery = query.trim().toLowerCase(); if (!normalizedQuery) { return filteredOptions; } return filteredOptions.filter((option) => option.label.toLowerCase().includes(normalizedQuery)); - }, [filteredOptions, libraryQuery]); - const resolvedLabel = - value.mode === "library" - ? selectedOption?.label ?? value.label - : value.label; + }, [filteredOptions, query]); const serializedValue = JSON.stringify({ mode: value.mode, assetId: value.assetId, url: value.url, - label: resolvedLabel, + label: value.label, kind: value.kind, }); - const previewUrl = - value.isCleared - ? "" - : value.mode === "library" - ? selectedOption?.url ?? "" - : value.mode === "external" - ? value.url - : "" - ; - const hasCurrentValue = - !value.isCleared && - ((value.mode === "library" && value.assetId.trim() !== "") || - (value.mode === "external" && value.url.trim() !== "")); - const canClear = allowClear && (hasCurrentValue || (hasInitialValue && !value.isCleared)); + const canClear = allowClear && (Boolean(value.assetId) || (hasInitialValue && !value.isCleared)); useEffect(() => { const hiddenInput = hiddenInputRef.current; @@ -111,174 +89,132 @@ export function MediaFieldPicker({ }, [serializedValue]); return ( - -
- -
- {modeOptions.map((mode) => ( - - ))} + + + +
+
+ +

+ Media must be selected from the + {" "} + Media Library + . +

+
+
+ {canClear ? ( - + ) : null}
- - - {value.mode !== "library" ? ( -
- -
- - onChange({ ...value, label: event.target.value, isCleared: false })} - placeholder="Label" - className="pl-9" + + {selectedOption ? ( +
+ {selectedOption.label} +
+

{selectedOption.label}

+

{selectedOption.source}

+
-
- ) : null} - - {value.mode === "upload" ? ( -
- -
- - + ) : ( +
+ No media selected.
-
- ) : null} + )} + - {value.mode === "external" ? ( -
- -
- - onChange({ ...value, url: event.target.value, isCleared: false })} - placeholder={externalLabel ?? "External URL"} - className="pl-9" - /> -
-
- ) : null} + + + + {title} + Select an existing item from the media library. + - {value.mode === "library" ? ( -
- -
+
setLibraryQuery(event.target.value)} - className="pl-9" + value={query} + onChange={(event) => setQuery(event.target.value)} placeholder="Search media" + className="pl-9" />
-
- {visibleLibraryOptions.length > 0 ? ( - visibleLibraryOptions.map((option) => { - const isActive = option.id === value.assetId; +
+ {visibleOptions.map((option) => { + const isActive = option.id === value.assetId; - return ( - - ); - }) - ) : ( -
- No media found. -
- )} +
+ + ); + })}
-
- ) : null} - {previewUrl ? ( - value.kind === "IMAGE" ? ( - - {value.label - - ) : ( - - {previewUrl} - - ) - ) : null} + + + + +
); } diff --git a/components/root/portfolio-categories-manager.tsx b/components/root/portfolio-categories-manager.tsx index 574c4fd..630174d 100644 --- a/components/root/portfolio-categories-manager.tsx +++ b/components/root/portfolio-categories-manager.tsx @@ -2,7 +2,16 @@ import { useEffect, useState } from "react"; import { useSearchParams } from "next/navigation"; -import { FileText, FolderPlus, Hash, Layers3, Pencil, Text, Trash2 } from "lucide-react"; +import { + FileText, + FolderPlus, + Hash, + Layers3, + Pencil, + Sparkles, + Text, + Trash2, +} from "lucide-react"; import type { deleteCategoryAction, upsertCategoryAction } from "@/app/root/portfolio/actions"; import { StatsCard } from "@/components/dashboard/stats-card"; @@ -23,35 +32,48 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; import { Textarea } from "@/components/ui/textarea"; import type { PortfolioCategoryView } from "@/lib/portfolio"; const locales = [ - { key: "Ar", lowerKey: "ar" as const, label: "Arabic" }, - { key: "En", lowerKey: "en" as const, label: "English" }, - { key: "De", lowerKey: "de" as const, label: "German" }, + { key: "Ar", label: "Arabic" }, + { key: "En", label: "English" }, + { key: "De", label: "German" }, ] as const; const copy = { - addCategory: "Kategorie hinzufuegen", - saveCategory: "Kategorie speichern", - save: "Speichern", - delete: "Loeschen", - active: "Aktiv", - sortOrder: "Sortierung", - projects: "Projekte", - description: "Beschreibung", - currentCategories: "Aktuelle Kategorien", - modalDescription: "Neue Kategorie direkt im Popup anlegen.", - editDescription: "Kategorie im Popup aendern oder loeschen.", - empty: "Noch keine Kategorien vorhanden.", - deleteBlocked: "Loeschen erst moeglich, wenn keine Projekte mehr zugeordnet sind.", - editCategory: "Kategorie bearbeiten", + addCategory: "Add Category", + saveCategory: "Save Category", + save: "Save", + delete: "Delete", + active: "Active", + sortOrder: "Sort Order", + projects: "Projects", + description: "Description", + currentCategories: "Current Categories", + modalDescription: "Create a new category with a faster flow for basics, localization, and status.", + editDescription: "Update category content, change status, or remove the category if it has no assigned projects.", + empty: "No categories yet.", + deleteBlocked: "Delete becomes available only when no projects are assigned.", + editCategory: "Edit Category", }; type CategoryAction = typeof upsertCategoryAction; type CategoryDeleteAction = typeof deleteCategoryAction; +type CategoryFormValues = { + slug?: string; + sortOrder?: number; + isActive?: boolean; + nameAr?: string; + nameEn?: string; + nameDe?: string; + descriptionAr?: string; + descriptionEn?: string; + descriptionDe?: string; +}; + type PortfolioCategoriesManagerProps = { categories: Array; activeCount: number; @@ -65,64 +87,134 @@ function CategoryLocaleFields({ values, }: { idPrefix: string; - values?: { - nameAr?: string; - nameEn?: string; - nameDe?: string; - descriptionAr?: string; - descriptionEn?: string; - descriptionDe?: string; - }; + values?: CategoryFormValues; }) { return ( -
- {locales.map((locale) => { - const nameKey = `name${locale.key}` as const; - const descriptionKey = `description${locale.key}` as const; +
+
+

Localized Content

+

+ Keep names and descriptions ready in all supported locales. +

+
- return ( - -
+
+ {locales.map((locale) => { + const nameKey = `name${locale.key}` as const; + const descriptionKey = `description${locale.key}` as const; + + return ( +

{locale.label}

-
-
- -
- - +
+ +
+ + +
-
-
- -
- -