450 lines
17 KiB
TypeScript
450 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useSearchParams } from "next/navigation";
|
|
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";
|
|
import { AppCard } from "@/components/ui/app-card";
|
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { CardContent } from "@/components/ui/card";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} 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", label: "Arabic" },
|
|
{ key: "En", label: "English" },
|
|
{ key: "De", label: "German" },
|
|
] as const;
|
|
|
|
const copy = {
|
|
addCategory: "Add Category",
|
|
saveCategory: "Save Category",
|
|
save: "Save",
|
|
delete: "Delete",
|
|
active: "Active",
|
|
sortOrder: "Sort Order",
|
|
projects: "Projects",
|
|
description: "Description",
|
|
currentCategories: "Current Categories",
|
|
modalDescription: "Create a new category with a faster flow for basics, localization, and status.",
|
|
editDescription: "Update category content, change status, or remove the category if it has no assigned projects.",
|
|
empty: "No categories yet.",
|
|
deleteBlocked: "Delete becomes available only when no projects are assigned.",
|
|
editCategory: "Edit Category",
|
|
};
|
|
|
|
type CategoryAction = typeof upsertCategoryAction;
|
|
type CategoryDeleteAction = typeof deleteCategoryAction;
|
|
|
|
type CategoryFormValues = {
|
|
slug?: string;
|
|
sortOrder?: number;
|
|
isActive?: boolean;
|
|
nameAr?: string;
|
|
nameEn?: string;
|
|
nameDe?: string;
|
|
descriptionAr?: string;
|
|
descriptionEn?: string;
|
|
descriptionDe?: string;
|
|
};
|
|
|
|
type PortfolioCategoriesManagerProps = {
|
|
categories: Array<PortfolioCategoryView & { projectCount: number }>;
|
|
activeCount: number;
|
|
assignedProjects: number;
|
|
saveCategoryAction: CategoryAction;
|
|
removeCategoryAction: CategoryDeleteAction;
|
|
};
|
|
|
|
function CategoryLocaleFields({
|
|
idPrefix,
|
|
values,
|
|
}: {
|
|
idPrefix: string;
|
|
values?: CategoryFormValues;
|
|
}) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-semibold text-foreground">Localized Content</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
Keep names and descriptions ready in all supported locales.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid gap-4 xl:grid-cols-3">
|
|
{locales.map((locale) => {
|
|
const nameKey = `name${locale.key}` as const;
|
|
const descriptionKey = `description${locale.key}` as const;
|
|
|
|
return (
|
|
<AppCard key={`${idPrefix}-${locale.key}`} level={2} padding="sm" className="space-y-4 rounded-nested">
|
|
<p className="text-sm font-medium text-foreground">{locale.label}</p>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor={`${idPrefix}-${nameKey}`}>Name</Label>
|
|
<div className="relative">
|
|
<Text className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
id={`${idPrefix}-${nameKey}`}
|
|
name={nameKey}
|
|
defaultValue={values?.[nameKey] ?? ""}
|
|
required
|
|
placeholder={`Name ${locale.label}`}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor={`${idPrefix}-${descriptionKey}`}>{copy.description}</Label>
|
|
<div className="relative">
|
|
<FileText className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
|
<Textarea
|
|
id={`${idPrefix}-${descriptionKey}`}
|
|
name={descriptionKey}
|
|
rows={5}
|
|
defaultValue={values?.[descriptionKey] ?? ""}
|
|
required
|
|
placeholder={`${copy.description} ${locale.label}`}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</AppCard>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CategoryForm({
|
|
formId,
|
|
action,
|
|
values,
|
|
categoryId,
|
|
}: {
|
|
formId: string;
|
|
action: CategoryAction;
|
|
values?: CategoryFormValues;
|
|
categoryId?: string;
|
|
}) {
|
|
return (
|
|
<form id={formId} action={action} className="space-y-6">
|
|
{categoryId ? <input type="hidden" name="id" value={categoryId} /> : null}
|
|
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
|
|
|
|
<div className="space-y-4">
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-semibold text-foreground">Basics</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
Set the stable identifier and display order first.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid gap-4 lg:grid-cols-3">
|
|
<div className="space-y-2">
|
|
<Label htmlFor={`${formId}-slug`}>Slug</Label>
|
|
<div className="relative">
|
|
<Text className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
id={`${formId}-slug`}
|
|
name="slug"
|
|
defaultValue={values?.slug ?? ""}
|
|
required
|
|
placeholder="Slug"
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor={`${formId}-sortOrder`}>{copy.sortOrder}</Label>
|
|
<div className="relative">
|
|
<Hash className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
id={`${formId}-sortOrder`}
|
|
name="sortOrder"
|
|
type="number"
|
|
min="0"
|
|
defaultValue={values?.sortOrder ?? 0}
|
|
required
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<label className="flex items-center gap-3 rounded-nested border border-input bg-card px-4 py-3 text-sm">
|
|
<Checkbox name="isActive" defaultChecked={values?.isActive ?? true} />
|
|
{copy.active}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<CategoryLocaleFields idPrefix={formId} values={values} />
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function EditCategoryDialog({
|
|
category,
|
|
open,
|
|
onOpenChange,
|
|
saveCategoryAction,
|
|
removeCategoryAction,
|
|
}: {
|
|
category: PortfolioCategoriesManagerProps["categories"][number];
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
saveCategoryAction: CategoryAction;
|
|
removeCategoryAction: CategoryDeleteAction;
|
|
}) {
|
|
const formId = `portfolio-category-form-${category.id}`;
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>{copy.editCategory}</DialogTitle>
|
|
<DialogDescription>{copy.editDescription}</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="grid gap-3 sm:grid-cols-3">
|
|
<AppCard level={2} padding="sm" className="rounded-nested">
|
|
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Projects</p>
|
|
<p className="mt-2 text-lg font-semibold text-foreground">{category.projectCount}</p>
|
|
</AppCard>
|
|
<AppCard level={2} padding="sm" className="rounded-nested">
|
|
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Status</p>
|
|
<div className="mt-2">
|
|
<Badge variant={category.isActive ? "success" : "warning"}>
|
|
{category.isActive ? "Active" : "Inactive"}
|
|
</Badge>
|
|
</div>
|
|
</AppCard>
|
|
<AppCard level={2} padding="sm" className="rounded-nested">
|
|
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">Order</p>
|
|
<p className="mt-2 text-lg font-semibold text-foreground">{category.sortOrder}</p>
|
|
</AppCard>
|
|
</div>
|
|
|
|
<CategoryForm
|
|
formId={formId}
|
|
action={saveCategoryAction}
|
|
categoryId={category.id}
|
|
values={{
|
|
slug: category.slug,
|
|
sortOrder: category.sortOrder,
|
|
isActive: category.isActive,
|
|
nameAr: category.name.ar,
|
|
nameEn: category.name.en,
|
|
nameDe: category.name.de,
|
|
descriptionAr: category.description.ar,
|
|
descriptionEn: category.description.en,
|
|
descriptionDe: category.description.de,
|
|
}}
|
|
/>
|
|
|
|
<DialogFooter className="items-center justify-between sm:flex-row">
|
|
<p className="text-sm text-muted-foreground">
|
|
{category.projectCount > 0 ? copy.deleteBlocked : "Category can be deleted."}
|
|
</p>
|
|
<div className="flex w-full flex-col-reverse gap-2 sm:w-auto sm:flex-row">
|
|
<form action={removeCategoryAction}>
|
|
<input type="hidden" name="id" value={category.id} />
|
|
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
|
|
<Button type="submit" variant="destructive" disabled={category.projectCount > 0}>
|
|
<Trash2 className="h-4 w-4" />
|
|
{copy.delete}
|
|
</Button>
|
|
</form>
|
|
<Button type="submit" form={formId}>
|
|
{copy.save}
|
|
</Button>
|
|
</div>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
export function PortfolioCategoriesManager({
|
|
categories,
|
|
activeCount,
|
|
assignedProjects,
|
|
saveCategoryAction,
|
|
removeCategoryAction,
|
|
}: PortfolioCategoriesManagerProps) {
|
|
const searchParams = useSearchParams();
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [editingCategoryId, setEditingCategoryId] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (searchParams.has("success")) {
|
|
setCreateOpen(false);
|
|
setEditingCategoryId(null);
|
|
}
|
|
}, [searchParams]);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<AppCard level={3}>
|
|
<CardContent className="flex flex-col gap-6 p-6 lg:flex-row lg:items-end lg:justify-between lg:p-8">
|
|
<div className="grid gap-4 sm:grid-cols-3">
|
|
<StatsCard title="Total" value={String(categories.length)} />
|
|
<StatsCard title="Active" value={String(activeCount)} />
|
|
<StatsCard title="Assigned" value={String(assignedProjects)} />
|
|
</div>
|
|
|
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button>
|
|
<FolderPlus className="h-4 w-4" />
|
|
{copy.addCategory}
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>{copy.addCategory}</DialogTitle>
|
|
<DialogDescription>{copy.modalDescription}</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="grid gap-3 sm:grid-cols-3">
|
|
<AppCard level={2} padding="sm" className="rounded-nested">
|
|
<Sparkles className="h-4 w-4 text-brand-primary" />
|
|
<p className="mt-3 text-sm font-medium text-foreground">Start with basics</p>
|
|
<p className="mt-1 text-sm text-muted-foreground">Slug and sort order first.</p>
|
|
</AppCard>
|
|
<AppCard level={2} padding="sm" className="rounded-nested">
|
|
<Layers3 className="h-4 w-4 text-brand-primary" />
|
|
<p className="mt-3 text-sm font-medium text-foreground">Fill all locales</p>
|
|
<p className="mt-1 text-sm text-muted-foreground">Keep names and descriptions complete.</p>
|
|
</AppCard>
|
|
<AppCard level={2} padding="sm" className="rounded-nested">
|
|
<Pencil className="h-4 w-4 text-brand-primary" />
|
|
<p className="mt-3 text-sm font-medium text-foreground">Publish when ready</p>
|
|
<p className="mt-1 text-sm text-muted-foreground">Categories stay manageable from day one.</p>
|
|
</AppCard>
|
|
</div>
|
|
|
|
<CategoryForm
|
|
formId="portfolio-category-create-form"
|
|
action={saveCategoryAction}
|
|
/>
|
|
|
|
<DialogFooter>
|
|
<Button type="submit" form="portfolio-category-create-form">
|
|
{copy.saveCategory}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</CardContent>
|
|
</AppCard>
|
|
|
|
<AppCard level={3}>
|
|
<CardContent className="space-y-4 p-6">
|
|
<div className="flex items-center gap-3">
|
|
<Layers3 className="h-5 w-5 text-brand-primary" />
|
|
<h2 className="text-lg font-semibold text-foreground">{copy.currentCategories}</h2>
|
|
</div>
|
|
|
|
{categories.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">{copy.empty}</p>
|
|
) : (
|
|
<Accordion type="single" collapsible className="space-y-3">
|
|
{categories.map((category) => (
|
|
<AccordionItem key={category.id} value={category.id}>
|
|
<AccordionTrigger className="bg-surface-2 hover:no-underline">
|
|
<div className="flex min-w-0 flex-1 flex-col gap-3 text-left lg:flex-row lg:items-center lg:justify-between">
|
|
<div className="space-y-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="text-base font-semibold text-foreground">
|
|
{category.name.de || category.name.en || category.name.ar}
|
|
</span>
|
|
<Badge variant={category.isActive ? "success" : "warning"}>
|
|
{category.isActive ? "Active" : "Inactive"}
|
|
</Badge>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
|
<span>{category.slug}</span>
|
|
<span>{copy.projects}: {category.projectCount}</span>
|
|
<span>{copy.sortOrder}: {category.sortOrder}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
setEditingCategoryId(category.id);
|
|
}}
|
|
>
|
|
<Pencil className="h-4 w-4" />
|
|
Edit
|
|
</Button>
|
|
</div>
|
|
</AccordionTrigger>
|
|
<AccordionContent className="space-y-4">
|
|
<div className="grid gap-4 xl:grid-cols-3">
|
|
{locales.map((locale) => (
|
|
<AppCard key={`${category.id}-${locale.key}`} level={2} padding="sm" className="space-y-3 rounded-nested">
|
|
<p className="text-sm font-medium text-foreground">{locale.label}</p>
|
|
<p className="text-sm font-semibold text-foreground">
|
|
{category.name[locale.key.toLowerCase() as "ar" | "en" | "de"]}
|
|
</p>
|
|
<p className="text-sm leading-6 text-muted-foreground">
|
|
{category.description[locale.key.toLowerCase() as "ar" | "en" | "de"]}
|
|
</p>
|
|
</AppCard>
|
|
))}
|
|
</div>
|
|
|
|
<EditCategoryDialog
|
|
category={category}
|
|
open={editingCategoryId === category.id}
|
|
onOpenChange={(open) => setEditingCategoryId(open ? category.id : null)}
|
|
saveCategoryAction={saveCategoryAction}
|
|
removeCategoryAction={removeCategoryAction}
|
|
/>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
))}
|
|
</Accordion>
|
|
)}
|
|
</CardContent>
|
|
</AppCard>
|
|
</div>
|
|
);
|
|
}
|