Refactor admin area and remove legacy root paths
CI / quality (push) Waiting to run

This commit is contained in:
MOH
2026-03-13 16:14:04 +01:00
parent e32ac4cacb
commit db897e22bf
65 changed files with 216 additions and 140 deletions
@@ -0,0 +1,449 @@
"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/_admin/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="/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="/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>
);
}