Refactor site header, heroes, and design system
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { FileText, FolderPlus, Hash, Layers3, Pencil, Text, Trash2 } from "lucide-react";
|
||||
|
||||
import type { deleteCategoryAction, upsertCategoryAction } from "@/app/root/portfolio/actions";
|
||||
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 { 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" },
|
||||
] 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",
|
||||
};
|
||||
|
||||
type CategoryAction = typeof upsertCategoryAction;
|
||||
type CategoryDeleteAction = typeof deleteCategoryAction;
|
||||
|
||||
type PortfolioCategoriesManagerProps = {
|
||||
categories: Array<PortfolioCategoryView & { projectCount: number }>;
|
||||
activeCount: number;
|
||||
assignedProjects: number;
|
||||
saveCategoryAction: CategoryAction;
|
||||
removeCategoryAction: CategoryDeleteAction;
|
||||
};
|
||||
|
||||
function CategoryLocaleFields({
|
||||
idPrefix,
|
||||
values,
|
||||
}: {
|
||||
idPrefix: string;
|
||||
values?: {
|
||||
nameAr?: string;
|
||||
nameEn?: string;
|
||||
nameDe?: string;
|
||||
descriptionAr?: string;
|
||||
descriptionEn?: string;
|
||||
descriptionDe?: string;
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<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 (
|
||||
<div key={`${idPrefix}-${locale.key}`} className="space-y-4 rounded-lg border border-input bg-background p-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">{locale.label}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${idPrefix}-${nameKey}`} className="sr-only">{`Name ${locale.label}`}</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}`} className="sr-only">{`${copy.description} ${locale.label}`}</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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditCategoryDialog({
|
||||
category,
|
||||
open,
|
||||
onOpenChange,
|
||||
saveCategoryAction,
|
||||
removeCategoryAction,
|
||||
}: {
|
||||
category: PortfolioCategoriesManagerProps["categories"][number];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
saveCategoryAction: CategoryAction;
|
||||
removeCategoryAction: CategoryDeleteAction;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copy.editCategory}</DialogTitle>
|
||||
<DialogDescription>{copy.editDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form id={`portfolio-category-form-${category.id}`} action={saveCategoryAction} className="space-y-6">
|
||||
<input type="hidden" name="id" value={category.id} />
|
||||
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`slug-${category.id}`}>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={`slug-${category.id}`} name="slug" defaultValue={category.slug} required className="pl-9" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`sortOrder-${category.id}`}>{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={`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-md 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={{
|
||||
nameAr: category.name.ar,
|
||||
nameEn: category.name.en,
|
||||
nameDe: category.name.de,
|
||||
descriptionAr: category.description.ar,
|
||||
descriptionEn: category.description.en,
|
||||
descriptionDe: category.description.de,
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<DialogFooter className="items-center justify-between sm:flex-row">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{category.projectCount > 0 ? copy.deleteBlocked : "Kategorie kann geloescht werden."}
|
||||
</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={`portfolio-category-form-${category.id}`}>
|
||||
{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} className="bg-secondary">
|
||||
<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">
|
||||
<div className="rounded-lg border border-input bg-background px-4 py-4">
|
||||
<p className="text-xs uppercase tracking-[0.12em] text-muted-foreground">Total</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-foreground">{categories.length}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-input bg-background px-4 py-4">
|
||||
<p className="text-xs uppercase tracking-[0.12em] text-muted-foreground">Active</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-foreground">{activeCount}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-input bg-background px-4 py-4">
|
||||
<p className="text-xs uppercase tracking-[0.12em] text-muted-foreground">Assigned</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-foreground">{assignedProjects}</p>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<form id="portfolio-category-create-form" action={saveCategoryAction} className="space-y-6">
|
||||
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-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="create-slug" name="slug" required placeholder="Slug" className="pl-9" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-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="create-sortOrder" name="sortOrder" type="number" min="0" defaultValue="0" required className="pl-9" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 rounded-md border border-input bg-card px-4 py-3 text-sm">
|
||||
<Checkbox name="isActive" defaultChecked />
|
||||
{copy.active}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<CategoryLocaleFields idPrefix="create" />
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit">{copy.saveCategory}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
|
||||
<AppCard className="bg-secondary">
|
||||
<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-secondary 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="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 ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<span>{category.slug}</span>
|
||||
<span>•</span>
|
||||
<span>{copy.sortOrder} {category.sortOrder}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{category.projectCount} {copy.projects}</Badge>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setEditingCategoryId(category.id);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
{copy.editCategory}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="bg-background">
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{locales.map((locale) => (
|
||||
<div key={`${category.id}-${locale.key}`} className="rounded-lg border border-input bg-card p-4">
|
||||
<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="mt-2 text-sm text-muted-foreground">{category.description[locale.lowerKey]}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
|
||||
<EditCategoryDialog
|
||||
category={category}
|
||||
open={editingCategoryId === category.id}
|
||||
onOpenChange={(open) => setEditingCategoryId(open ? category.id : null)}
|
||||
saveCategoryAction={saveCategoryAction}
|
||||
removeCategoryAction={removeCategoryAction}
|
||||
/>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
)}
|
||||
</CardContent>
|
||||
</AppCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user