@@ -8,15 +8,14 @@ import {
|
||||
Hash,
|
||||
Layers3,
|
||||
Pencil,
|
||||
Sparkles,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
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";
|
||||
@@ -57,6 +56,10 @@ const copy = {
|
||||
empty: "No categories yet.",
|
||||
deleteBlocked: "Delete becomes available only when no projects are assigned.",
|
||||
editCategory: "Edit Category",
|
||||
visibilityActive: "Visible on public portfolio filters.",
|
||||
visibilityInactive: "Hidden from public portfolio filters.",
|
||||
statusSection: "Visibility & Status",
|
||||
statusHint: "Control whether the category can be assigned and shown publicly.",
|
||||
};
|
||||
|
||||
type CategoryAction = typeof upsertCategoryAction;
|
||||
@@ -145,6 +148,56 @@ function CategoryLocaleFields({
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryStatusFields({
|
||||
formId,
|
||||
values,
|
||||
}: {
|
||||
formId: string;
|
||||
values?: CategoryFormValues;
|
||||
}) {
|
||||
const isActive = values?.isActive ?? true;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-foreground">{copy.statusSection}</p>
|
||||
<p className="text-sm text-muted-foreground">{copy.statusHint}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
|
||||
<AppCard level={2} padding="sm" className="rounded-nested">
|
||||
<div className="flex items-start gap-3">
|
||||
{isActive ? (
|
||||
<ShieldCheck className="mt-0.5 h-4 w-4 text-status-success" />
|
||||
) : (
|
||||
<ShieldOff className="mt-0.5 h-4 w-4 text-status-warning" />
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-medium text-foreground">{copy.active}</p>
|
||||
<Badge variant={isActive ? "success" : "warning"}>
|
||||
{isActive ? "Public" : "Hidden"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isActive ? copy.visibilityActive : copy.visibilityInactive}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<label
|
||||
htmlFor={`${formId}-isActive`}
|
||||
className="flex items-center gap-3 rounded-nested border border-input bg-card px-4 py-3 text-sm"
|
||||
>
|
||||
<Checkbox id={`${formId}-isActive`} name="isActive" defaultChecked={isActive} />
|
||||
{copy.active}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryForm({
|
||||
formId,
|
||||
action,
|
||||
@@ -200,17 +253,16 @@ function CategoryForm({
|
||||
/>
|
||||
</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} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<CategoryStatusFields formId={formId} values={values} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -232,7 +284,7 @@ function EditCategoryDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="max-h-[90vh] max-w-5xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copy.editCategory}</DialogTitle>
|
||||
<DialogDescription>{copy.editDescription}</DialogDescription>
|
||||
@@ -247,7 +299,7 @@ function EditCategoryDialog({
|
||||
<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"}
|
||||
{category.isActive ? "Public" : "Hidden"}
|
||||
</Badge>
|
||||
</div>
|
||||
</AppCard>
|
||||
@@ -276,7 +328,9 @@ function EditCategoryDialog({
|
||||
|
||||
<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."}
|
||||
{category.projectCount > 0
|
||||
? `Delete blocked. ${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}>
|
||||
@@ -316,60 +370,69 @@ export function PortfolioCategoriesManager({
|
||||
}, [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>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
Total:
|
||||
{" "}
|
||||
<span className="font-semibold text-foreground">{categories.length}</span>
|
||||
</span>
|
||||
<span>
|
||||
Active:
|
||||
{" "}
|
||||
<span className="font-semibold text-foreground">{activeCount}</span>
|
||||
</span>
|
||||
<span>
|
||||
Assigned:
|
||||
{" "}
|
||||
<span className="font-semibold text-foreground">{assignedProjects}</span>
|
||||
</span>
|
||||
</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>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<FolderPlus className="h-4 w-4" />
|
||||
{copy.addCategory}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[90vh] max-w-5xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copy.addCategory}</DialogTitle>
|
||||
<DialogDescription>{copy.modalDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<AppCard level={2} padding="sm" className="rounded-nested">
|
||||
<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 className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">1. Basics</p>
|
||||
<p className="text-sm text-muted-foreground">Slug and sort order first.</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">2. Localization</p>
|
||||
<p className="text-sm text-muted-foreground">Names and descriptions in all locales.</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">3. Visibility</p>
|
||||
<p className="text-sm text-muted-foreground">Decide if the category is public or hidden.</p>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<CategoryForm
|
||||
formId="portfolio-category-create-form"
|
||||
action={saveCategoryAction}
|
||||
/>
|
||||
<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>
|
||||
<DialogFooter>
|
||||
<Button type="submit" form="portfolio-category-create-form">
|
||||
{copy.saveCategory}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<AppCard level={3}>
|
||||
<CardContent className="space-y-4 p-6">
|
||||
@@ -381,66 +444,51 @@ export function PortfolioCategoriesManager({
|
||||
{categories.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{copy.empty}</p>
|
||||
) : (
|
||||
<Accordion type="single" collapsible className="space-y-3">
|
||||
<div className="grid gap-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>
|
||||
<AppCard
|
||||
key={category.id}
|
||||
level={2}
|
||||
padding="sm"
|
||||
className="rounded-nested"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate 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-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{category.slug}</span>
|
||||
<span>{copy.projects}: {category.projectCount}</span>
|
||||
<span>{copy.sortOrder}: {category.sortOrder}</span>
|
||||
</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>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setEditingCategoryId(category.id)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<EditCategoryDialog
|
||||
category={category}
|
||||
open={editingCategoryId === category.id}
|
||||
onOpenChange={(open) => setEditingCategoryId(open ? category.id : null)}
|
||||
saveCategoryAction={saveCategoryAction}
|
||||
removeCategoryAction={removeCategoryAction}
|
||||
/>
|
||||
</AppCard>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { getAdminAppPath } from "@/lib/admin-routing";
|
||||
|
||||
const copy = {
|
||||
editProject: "Bearbeiten",
|
||||
@@ -40,7 +41,7 @@ export function PortfolioProjectActions({ projectId }: { projectId: string }) {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/portfolio/projects/${projectId}`}>
|
||||
<Link href={getAdminAppPath(`/portfolio/projects/${projectId}`)}>
|
||||
<FolderKanban className="mr-2 h-4 w-4" />
|
||||
{copy.editProject}
|
||||
</Link>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+18
-4
@@ -22,16 +22,30 @@ function useTabsContext() {
|
||||
}
|
||||
|
||||
type TabsProps = {
|
||||
defaultValue: string;
|
||||
defaultValue?: string;
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function Tabs({ defaultValue, children, className }: TabsProps) {
|
||||
const [value, setValue] = React.useState(defaultValue);
|
||||
function Tabs({ defaultValue, value, onValueChange, children, className }: TabsProps) {
|
||||
const [internalValue, setInternalValue] = React.useState(defaultValue ?? "");
|
||||
const activeValue = value ?? internalValue;
|
||||
|
||||
const setValue = React.useCallback(
|
||||
(nextValue: string) => {
|
||||
if (value === undefined) {
|
||||
setInternalValue(nextValue);
|
||||
}
|
||||
|
||||
onValueChange?.(nextValue);
|
||||
},
|
||||
[onValueChange, value],
|
||||
);
|
||||
|
||||
return (
|
||||
<TabsContext.Provider value={{ value, setValue }}>
|
||||
<TabsContext.Provider value={{ value: activeValue, setValue }}>
|
||||
<div className={cn("w-full", className)}>{children}</div>
|
||||
</TabsContext.Provider>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user