Refine root UI around shadcn primitives
CI / quality (push) Waiting to run

This commit is contained in:
MOH
2026-03-07 22:28:52 +01:00
parent 985fbe1745
commit a18370d003
36 changed files with 1344 additions and 597 deletions
+79 -45
View File
@@ -1,4 +1,5 @@
import { redirect } from "next/navigation";
import { FileText, Hash, Text } from "lucide-react";
import { MotionFade } from "@/components/motion-fade";
import { FlashMessage } from "@/components/root/flash-message";
@@ -7,6 +8,7 @@ import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
import { AppCard } from "@/components/ui/app-card";
import { Button } from "@/components/ui/button";
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -106,12 +108,18 @@ export default async function RootPortfolioCategoriesPage({
>
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
<div className="space-y-2">
<Label htmlFor="create-slug">Slug</Label>
<Input id="create-slug" name="slug" required />
<Label htmlFor="create-slug" className="sr-only">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>
<Input id="create-sortOrder" name="sortOrder" type="number" min="0" defaultValue="0" required />
<Label htmlFor="create-sortOrder" className="sr-only">{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 placeholder={copy.sortOrder} className="pl-9" />
</div>
</div>
<div className="md:col-span-2">
@@ -126,20 +134,28 @@ export default async function RootPortfolioCategoriesPage({
{locales.map((locale) => (
<TabsContent key={locale.key} value={locale.key}>
<div className="grid gap-4 rounded-surface border border-border p-4 md:grid-cols-2">
<div className="grid gap-4 rounded-lg border bg-card p-4 shadow-sm md:grid-cols-2">
<div className="md:col-span-2 text-sm text-muted-foreground">{locale.hint}</div>
<div className="space-y-2">
<Label htmlFor={`create-name-${locale.key}`}>{`Name ${locale.label}`}</Label>
<Input id={`create-name-${locale.key}`} name={`name${locale.key}`} required />
<Label htmlFor={`create-name-${locale.key}`} 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={`create-name-${locale.key}`} name={`name${locale.key}`} required placeholder={`Name ${locale.label}`} className="pl-9" />
</div>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`create-description-${locale.key}`}>{`${copy.description} ${locale.label}`}</Label>
<Textarea
id={`create-description-${locale.key}`}
name={`description${locale.key}`}
rows={4}
required
/>
<Label htmlFor={`create-description-${locale.key}`} 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={`create-description-${locale.key}`}
name={`description${locale.key}`}
rows={4}
required
placeholder={`${copy.description} ${locale.label}`}
className="pl-9"
/>
</div>
</div>
</div>
</TabsContent>
@@ -147,8 +163,8 @@ export default async function RootPortfolioCategoriesPage({
</Tabs>
</div>
<label className="flex items-center gap-3 rounded-nested border border-border px-4 py-3 text-sm md:col-span-2">
<input type="checkbox" name="isActive" defaultChecked />
<label className="flex items-center gap-3 rounded-md border border-input bg-card px-4 py-3 text-sm md:col-span-2">
<Checkbox name="isActive" defaultChecked />
{copy.active}
</label>
@@ -175,20 +191,28 @@ export default async function RootPortfolioCategoriesPage({
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
<div className="space-y-2">
<Label htmlFor={`slug-${category.id}`}>Slug</Label>
<Input id={`slug-${category.id}`} name="slug" defaultValue={category.slug} required />
<Label htmlFor={`slug-${category.id}`} className="sr-only">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 placeholder="Slug" className="pl-9" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`sortOrder-${category.id}`}>{copy.sortOrder}</Label>
<Input
id={`sortOrder-${category.id}`}
name="sortOrder"
type="number"
min="0"
defaultValue={category.sortOrder}
required
/>
<Label htmlFor={`sortOrder-${category.id}`} className="sr-only">{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
placeholder={copy.sortOrder}
className="pl-9"
/>
</div>
</div>
<div className="md:col-span-2">
@@ -208,26 +232,36 @@ export default async function RootPortfolioCategoriesPage({
return (
<TabsContent key={`${category.id}-content-${locale.key}`} value={locale.key}>
<div className="grid gap-4 rounded-surface border border-border p-4 md:grid-cols-2">
<div className="grid gap-4 rounded-lg border bg-card p-4 shadow-sm md:grid-cols-2">
<div className="md:col-span-2 text-sm text-muted-foreground">{locale.hint}</div>
<div className="space-y-2">
<Label htmlFor={`${nameKey}-${category.id}`}>{`Name ${locale.label}`}</Label>
<Input
id={`${nameKey}-${category.id}`}
name={nameKey}
defaultValue={category.name[lowerLocale]}
required
/>
<Label htmlFor={`${nameKey}-${category.id}`} 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={`${nameKey}-${category.id}`}
name={nameKey}
defaultValue={category.name[lowerLocale]}
required
placeholder={`Name ${locale.label}`}
className="pl-9"
/>
</div>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`${descriptionKey}-${category.id}`}>{`${copy.description} ${locale.label}`}</Label>
<Textarea
id={`${descriptionKey}-${category.id}`}
name={descriptionKey}
rows={4}
defaultValue={category.description[lowerLocale]}
required
/>
<Label htmlFor={`${descriptionKey}-${category.id}`} 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={`${descriptionKey}-${category.id}`}
name={descriptionKey}
rows={4}
defaultValue={category.description[lowerLocale]}
required
placeholder={`${copy.description} ${locale.label}`}
className="pl-9"
/>
</div>
</div>
</div>
</TabsContent>
@@ -236,8 +270,8 @@ export default async function RootPortfolioCategoriesPage({
</Tabs>
</div>
<label className="flex items-center gap-3 rounded-nested border border-border px-4 py-3 text-sm">
<input type="checkbox" name="isActive" defaultChecked={category.isActive} />
<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>
+13 -192
View File
@@ -1,26 +1,17 @@
import { Boxes, ExternalLink, FolderKanban, Layers3, Plus, Tags } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
import { MotionFade } from "@/components/motion-fade";
import { FlashMessage } from "@/components/root/flash-message";
import { AppCard } from "@/components/ui/app-card";
import { Button } from "@/components/ui/button";
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PortfolioProjectsOverview } from "@/components/root/portfolio-projects-overview";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import {
getAdminPortfolioCategories,
getAdminPortfolioProjects,
getLocalizedValue,
} from "@/lib/portfolio";
import { getLocalizedPath } from "@/lib/locale";
import { getAdminPortfolioCategories, getAdminPortfolioProjects } from "@/lib/portfolio";
export const dynamic = "force-dynamic";
const copy = {
title: "Portfolio",
subtitle: "Verwaltung fuer Kategorien, Projekte und Inhalte.",
subtitle: "Zentrale Steuerung fuer Projekte, Inhalte und Medien.",
overview: "Uebersicht",
maintenance: "Wartungsmodus",
uiKit: "UI Kit",
@@ -29,23 +20,6 @@ const copy = {
portfolio: "Portfolio",
logout: "Ausloggen",
backToSite: "Zur Website",
totalCategories: "Kategorien",
totalProjects: "Projekte",
publishedProjects: "Veroeffentlicht",
newProject: "Neues Projekt",
newCategory: "Neue Kategorie",
category: "Kategorie",
all: "Alle",
status: "Status",
draft: "Entwurf",
published: "Veroeffentlicht",
filter: "Filtern",
sort: "Sortierung",
previewSet: "Preview Link gesetzt",
previewMissing: "Kein Preview Link",
editProject: "Projekt bearbeiten",
openProject: "Projekt ansehen",
empty: "Keine Projekte fuer die aktuellen Filter gefunden.",
};
type RootPortfolioPageProps = {
@@ -69,17 +43,19 @@ export default async function RootPortfolioPage({ searchParams }: RootPortfolioP
redirect("/root");
}
const selectedCategory = searchParams?.category && searchParams.category !== "__all__"
? searchParams.category
: "";
const selectedStatus = searchParams?.status === "draft" || searchParams?.status === "published"
? searchParams.status
: "all";
const [categories, projects] = await Promise.all([
getAdminPortfolioCategories(),
getAdminPortfolioProjects({
categoryId: searchParams?.category || undefined,
categoryId: selectedCategory || undefined,
status: selectedStatus,
}),
]);
const publishedProjects = projects.filter((project) => project.isPublished).length;
return (
<RootDashboardShell
@@ -89,22 +65,6 @@ export default async function RootPortfolioPage({ searchParams }: RootPortfolioP
logoutAction={logoutAction}
headerTitle={copy.title}
headerDescription={copy.subtitle}
headerActions={
<div className="flex flex-wrap gap-3">
<Button asChild>
<Link href="/root/portfolio/projects/new">
<Plus className="h-4 w-4" />
{copy.newProject}
</Link>
</Button>
<Button asChild variant="outline">
<Link href="/root/portfolio/categories">
<Tags className="h-4 w-4" />
{copy.newCategory}
</Link>
</Button>
</div>
}
>
<div className="space-y-6">
{searchParams?.success ? (
@@ -119,151 +79,12 @@ export default async function RootPortfolioPage({ searchParams }: RootPortfolioP
</MotionFade>
) : null}
<section className="grid gap-4 md:grid-cols-3">
{[
{
icon: Layers3,
label: copy.totalCategories,
value: categories.length,
},
{
icon: FolderKanban,
label: copy.totalProjects,
value: projects.length,
},
{
icon: Boxes,
label: copy.publishedProjects,
value: publishedProjects,
},
].map((item, index) => {
const Icon = item.icon;
return (
<MotionFade key={item.label} delay={index * 0.05}>
<AppCard level={2}>
<CardContent className="flex items-center gap-4 p-6">
<div className="flex h-12 w-12 items-center justify-center rounded-pill bg-surface-1">
<Icon className="h-5 w-5 text-brand-primary" />
</div>
<div>
<p className="text-sm text-muted-foreground">{item.label}</p>
<p className="text-3xl font-semibold text-foreground">{item.value}</p>
</div>
</CardContent>
</AppCard>
</MotionFade>
);
})}
</section>
<MotionFade delay={0.15}>
<AppCard>
<CardContent className="p-6">
<form className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<label htmlFor="category" className="text-sm font-medium text-foreground">
{copy.category}
</label>
<select
id="category"
name="category"
defaultValue={searchParams?.category ?? ""}
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
>
<option value="">{copy.all}</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name.de}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label htmlFor="status" className="text-sm font-medium text-foreground">
{copy.status}
</label>
<select
id="status"
name="status"
defaultValue={selectedStatus}
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
>
<option value="all">{copy.all}</option>
<option value="draft">{copy.draft}</option>
<option value="published">{copy.published}</option>
</select>
</div>
<div className="flex items-end">
<Button type="submit" variant="outline">
{copy.filter}
</Button>
</div>
</form>
</CardContent>
</AppCard>
</MotionFade>
<div className="grid gap-4">
{projects.map((project, index) => (
<MotionFade key={project.id} delay={0.18 + index * 0.03}>
<AppCard interactive>
<CardHeader className="flex flex-row items-center justify-between gap-4">
<div>
<CardTitle>{getLocalizedValue(project.title, "de")}</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">
{project.category.name.de}
</p>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="rounded-pill border border-border px-3 py-1">
{project.isPublished ? copy.published : copy.draft}
</span>
<span className="rounded-pill border border-border px-3 py-1">
{project.projectYear}
</span>
<span className="rounded-pill border border-border px-3 py-1">
{copy.sort} {project.sortOrder}
</span>
</div>
</CardHeader>
<CardContent className="flex flex-wrap items-center justify-between gap-4">
<div className="space-y-1 text-sm text-muted-foreground">
<p>{project.slug}</p>
<p>{project.previewUrl ? copy.previewSet : copy.previewMissing}</p>
</div>
<div className="flex flex-wrap gap-3">
<Button asChild variant="outline">
<Link
href={getLocalizedPath("de", `/portfolio/${project.slug}`)}
target="_blank"
rel="noreferrer"
>
<ExternalLink className="h-4 w-4" />
{copy.openProject}
</Link>
</Button>
<Button asChild>
<Link href={`/root/portfolio/projects/${project.id}`}>{copy.editProject}</Link>
</Button>
</div>
</CardContent>
</AppCard>
</MotionFade>
))}
{projects.length === 0 ? (
<MotionFade delay={0.18}>
<AppCard>
<CardContent className="p-6 text-sm text-muted-foreground">
{copy.empty}
</CardContent>
</AppCard>
</MotionFade>
) : null}
</div>
<PortfolioProjectsOverview
categories={categories}
projects={projects}
selectedCategory={selectedCategory}
selectedStatus={selectedStatus}
/>
</div>
</RootDashboardShell>
);
+70 -13
View File
@@ -1,7 +1,27 @@
import { redirect } from "next/navigation";
import { MotionFade } from "@/components/motion-fade";
import { FlashMessage } from "@/components/root/flash-message";
import { PortfolioProjectsOverview } from "@/components/root/portfolio-projects-overview";
import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import { getAdminPortfolioCategories, getAdminPortfolioProjects } from "@/lib/portfolio";
export const dynamic = "force-dynamic";
const copy = {
title: "Portfolio Projekte",
subtitle: "Projektliste und Einstieg in die komplette Bearbeitung.",
overview: "Uebersicht",
maintenance: "Wartungsmodus",
uiKit: "UI Kit",
media: "Media",
siteSettings: "SEO",
portfolio: "Portfolio",
logout: "Ausloggen",
backToSite: "Zur Website",
};
type RootPortfolioProjectsPageProps = {
searchParams?: {
category?: string;
@@ -14,23 +34,60 @@ type RootPortfolioProjectsPageProps = {
export default async function RootPortfolioProjectsPage({
searchParams,
}: RootPortfolioProjectsPageProps) {
const params = new URLSearchParams();
if (searchParams?.category) {
params.set("category", searchParams.category);
if (!isAdminAuthenticated()) {
redirect("/root");
}
if (searchParams?.status) {
params.set("status", searchParams.status);
async function logoutAction() {
"use server";
clearAdminSessionCookie();
redirect("/root");
}
if (searchParams?.success) {
params.set("success", searchParams.success);
}
const selectedCategory = searchParams?.category && searchParams.category !== "__all__"
? searchParams.category
: "";
const selectedStatus = searchParams?.status === "draft" || searchParams?.status === "published"
? searchParams.status
: "all";
const [categories, projects] = await Promise.all([
getAdminPortfolioCategories(),
getAdminPortfolioProjects({
categoryId: selectedCategory || undefined,
status: selectedStatus,
}),
]);
if (searchParams?.error) {
params.set("error", searchParams.error);
}
return (
<RootDashboardShell
copy={copy}
active="portfolio"
portfolioChild="projects"
logoutAction={logoutAction}
headerTitle={copy.title}
headerDescription={copy.subtitle}
>
<div className="space-y-6">
{searchParams?.success ? (
<MotionFade delay={0.08}>
<FlashMessage type="success" message={searchParams.success} />
</MotionFade>
) : null}
redirect(params.toString() ? `/root/portfolio?${params.toString()}` : "/root/portfolio");
{searchParams?.error ? (
<MotionFade delay={0.1}>
<FlashMessage type="error" message={searchParams.error} />
</MotionFade>
) : null}
<PortfolioProjectsOverview
categories={categories}
projects={projects}
selectedCategory={selectedCategory}
selectedStatus={selectedStatus}
/>
</div>
</RootDashboardShell>
);
}