STYLED - Turn the admin portfolio overview into a professional table

Replace the card grid with a compact data table (cover, project, category,
year, status, layout, actions) and a proper empty state that adapts to an
active category filter. Actions expose a public-view link plus the existing
edit/delete menu. No data-layer or save changes.
This commit is contained in:
moh
2026-09-20 17:08:20 +02:00
parent d1245204f1
commit 40387c19d4
+142 -45
View File
@@ -1,13 +1,22 @@
import { ExternalLink, FolderKanban, Plus, Tags, CheckCircle2 } from "lucide-react";
/* eslint-disable @next/next/no-img-element */
import { CheckCircle2, ExternalLink, FolderKanban, ImageOff, Plus, Star, Tags } from "lucide-react";
import Link from "next/link";
import { StatsCard } from "@/components/dashboard/stats-card";
import { MotionFade } from "@/components/motion-fade";
import { PortfolioProjectActions } from "@/components/admin/portfolio-project-actions";
import { AppCard } from "@/components/ui/app-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CardContent } from "@/components/ui/card";
import { Card } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { getAdminAppPath } from "@/lib/admin-routing";
import { getSiteSettings } from "@/lib/app-config";
import { getLocalizedPath } from "@/lib/locale";
@@ -24,17 +33,34 @@ const copy = {
all: "Alle",
newProject: "Neues Projekt",
newCategory: "Neues Kategorie",
openProject: "Ansehen",
view: "Auf der Website ansehen",
untitled: "Unbenanntes Projekt",
empty: "Noch keine Projekte vorhanden.",
emptyFiltered: "Keine Projekte in dieser Auswahl.",
emptyHint: "Lege dein erstes Projekt an, um es hier zu verwalten.",
colProject: "Projekt",
colCategory: "Kategorie",
colYear: "Jahr",
colStatus: "Status",
colMode: "Layout",
colActions: "Aktionen",
published: "Published",
draft: "Draft",
featured: "Featured",
};
function categoryLabel(name: PortfolioCategoryView["name"]) {
return name.de || name.en || name.ar;
}
export async function PortfolioProjectsOverview({
categories,
projects,
selectedCategory,
}: PortfolioProjectsOverviewProps) {
const siteSettings = await getSiteSettings();
const publishedCount = projects.filter((project) => project.isPublished).length;
const isFiltered = selectedCategory !== "";
return (
<div className="space-y-6">
@@ -42,11 +68,7 @@ export async function PortfolioProjectsOverview({
<div className="grid gap-4 md:grid-cols-3 xl:min-w-[620px]">
<StatsCard title="Projects" value={String(projects.length)} icon={FolderKanban} />
<StatsCard title="Categories" value={String(categories.length)} icon={Tags} />
<StatsCard
title="Published"
value={String(projects.filter((project) => project.isPublished).length)}
icon={CheckCircle2}
/>
<StatsCard title="Published" value={String(publishedCount)} icon={CheckCircle2} />
</div>
<div className="flex flex-wrap gap-2">
@@ -76,59 +98,134 @@ export async function PortfolioProjectsOverview({
variant={selectedCategory === category.id ? "default" : "outline"}
>
<Link href={`${getAdminAppPath("/portfolio")}?category=${category.id}`}>
{category.name.de || category.name.en || category.name.ar}
{categoryLabel(category.name)}
</Link>
</Button>
))}
</div>
<div className="grid gap-4 xl:grid-cols-2">
{projects.map((project, index) => (
<MotionFade key={project.id} delay={0.06 + index * 0.03}>
<AppCard interactive className="h-full">
<CardContent className="flex flex-col gap-4 p-5 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<p className="text-xl font-semibold text-foreground">
{getLocalizedValue(project.title, "de") || copy.untitled}
{projects.length === 0 ? (
<AppCard level={3} padding="lg">
<div className="flex flex-col items-center gap-4 py-12 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-pill border border-border/70 bg-surface-2 text-muted-foreground">
<FolderKanban className="h-6 w-6" />
</div>
<div className="space-y-1">
<p className="text-base font-semibold text-foreground">
{isFiltered ? copy.emptyFiltered : copy.empty}
</p>
<Badge variant={project.isPublished ? "success" : "warning"}>
{project.isPublished ? "Published" : "Draft"}
</Badge>
<Badge variant="outline">{project.viewMode}</Badge>
<p className="text-sm text-muted-foreground">{copy.emptyHint}</p>
</div>
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
<span>{project.category.name.de || project.category.name.en || project.category.name.ar}</span>
<span>{project.projectYear}</span>
</div>
</div>
<div className="flex flex-wrap gap-3">
{isFiltered ? (
<Button asChild variant="outline">
<Link href={getAdminAppPath("/portfolio")}>{copy.all}</Link>
</Button>
) : (
<Button asChild>
<Link href={getAdminAppPath("/portfolio/projects/new")}>
<Plus className="h-4 w-4" />
{copy.newProject}
</Link>
</Button>
)}
</div>
</AppCard>
) : (
<Card className="overflow-hidden">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[72px]">
<span className="sr-only">Cover</span>
</TableHead>
<TableHead>{copy.colProject}</TableHead>
<TableHead className="hidden md:table-cell">{copy.colCategory}</TableHead>
<TableHead className="hidden w-[80px] sm:table-cell">{copy.colYear}</TableHead>
<TableHead className="w-[130px]">{copy.colStatus}</TableHead>
<TableHead className="hidden w-[120px] lg:table-cell">{copy.colMode}</TableHead>
<TableHead className="w-[96px] text-right">{copy.colActions}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{projects.map((project) => {
const title = getLocalizedValue(project.title, "de") || copy.untitled;
return (
<TableRow key={project.id}>
<TableCell>
<div className="flex h-11 w-14 items-center justify-center overflow-hidden rounded-nested border border-border/70 bg-surface-2 text-muted-foreground">
{project.coverImagePath ? (
<img
src={project.coverImagePath}
alt={title}
className="h-full w-full object-cover"
/>
) : (
<ImageOff className="h-4 w-4" />
)}
</div>
</TableCell>
<TableCell>
<div className="flex min-w-0 flex-col">
<span className="inline-flex items-center gap-1.5 font-medium text-foreground">
<span className="truncate">{title}</span>
{project.isFeatured ? (
<Star
className="h-3.5 w-3.5 shrink-0 text-brand-primary"
fill="currentColor"
aria-label={copy.featured}
/>
) : null}
</span>
<span className="truncate text-xs text-muted-foreground">/{project.slug}</span>
</div>
</TableCell>
<TableCell className="hidden text-sm text-muted-foreground md:table-cell">
{categoryLabel(project.category.name)}
</TableCell>
<TableCell className="hidden text-sm text-muted-foreground sm:table-cell">
{project.projectYear}
</TableCell>
<TableCell>
<Badge variant={project.isPublished ? "success" : "warning"}>
{project.isPublished ? copy.published : copy.draft}
</Badge>
</TableCell>
<TableCell className="hidden lg:table-cell">
<Badge variant="outline">{project.viewMode}</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1.5">
<Button asChild variant="ghost" size="icon" title={copy.view}>
<Link
href={getLocalizedPath("de", `/portfolio/${project.slug}`, siteSettings.defaultLocale)}
href={getLocalizedPath(
"de",
`/portfolio/${project.slug}`,
siteSettings.defaultLocale,
)}
target="_blank"
rel="noreferrer"
>
<ExternalLink className="h-4 w-4" />
{copy.openProject}
<span className="sr-only">{copy.view}</span>
</Link>
</Button>
<PortfolioProjectActions projectId={project.id} />
</div>
</CardContent>
</AppCard>
</MotionFade>
))}
{projects.length === 0 ? (
<AppCard className="xl:col-span-2">
<CardContent className="p-6 text-sm text-muted-foreground">
{copy.empty}
</CardContent>
</AppCard>
) : null}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Card>
)}
</div>
);
}