Refactor root admin dashboard UI

This commit is contained in:
MOH
2026-03-07 17:36:45 +01:00
parent ead75769ef
commit 8606f6e315
25 changed files with 2345 additions and 685 deletions
+15 -15
View File
@@ -52,12 +52,12 @@ function parseJsonArray(rawValue: FormDataEntryValue | null, key: string) {
const parsed = JSON.parse(rawValue);
if (!Array.isArray(parsed)) {
throw new Error(`${key} must be an array.`);
throw new Error(`${key} muss ein Array sein.`);
}
return parsed;
} catch {
throw new Error(`Invalid ${key} payload.`);
throw new Error(`Ungueltige ${key} Nutzdaten.`);
}
}
@@ -70,17 +70,17 @@ function parseJsonObject(rawValue: FormDataEntryValue | null, key: string) {
const parsed = JSON.parse(rawValue);
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
throw new Error(`${key} must be an object.`);
throw new Error(`${key} muss ein Objekt sein.`);
}
return parsed;
} catch {
throw new Error(`Invalid ${key} payload.`);
throw new Error(`Ungueltige ${key} Nutzdaten.`);
}
}
function parseZodError(error: ZodError) {
return error.issues[0]?.message ?? "Validation failed.";
return error.issues[0]?.message ?? "Validierung fehlgeschlagen.";
}
async function revalidatePortfolioPages() {
@@ -135,7 +135,7 @@ export async function upsertCategoryAction(formData: FormData) {
}
await revalidatePortfolioPages();
redirect(withMessage(redirectPath, "success", "Category saved."));
redirect(withMessage(redirectPath, "success", "Kategorie gespeichert."));
} catch (error) {
if (isRedirectError(error)) {
throw error;
@@ -145,8 +145,8 @@ export async function upsertCategoryAction(formData: FormData) {
error instanceof ZodError
? parseZodError(error)
: error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002"
? "Category slug must be unique."
: "Unable to save category.";
? "Kategorie Slug muss eindeutig sein."
: "Kategorie konnte nicht gespeichert werden.";
redirect(withMessage(redirectPath, "error", message));
}
@@ -166,7 +166,7 @@ export async function deleteCategoryAction(formData: FormData) {
});
if (projectCount > 0) {
redirect(withMessage(redirectPath, "error", "Cannot delete a category with projects."));
redirect(withMessage(redirectPath, "error", "Kategorie mit Projekten kann nicht geloescht werden."));
}
await prisma.category.delete({
@@ -176,13 +176,13 @@ export async function deleteCategoryAction(formData: FormData) {
});
await revalidatePortfolioPages();
redirect(withMessage(redirectPath, "success", "Category deleted."));
redirect(withMessage(redirectPath, "success", "Kategorie geloescht."));
} catch (error) {
if (isRedirectError(error)) {
throw error;
}
redirect(withMessage(redirectPath, "error", "Unable to delete category."));
redirect(withMessage(redirectPath, "error", "Kategorie konnte nicht geloescht werden."));
}
}
@@ -337,7 +337,7 @@ export async function saveProjectAction(formData: FormData) {
});
if (!assetSelection.url) {
throw new Error("Each asset row needs either an existing file or a new upload.");
throw new Error("Jede Datei Zeile braucht eine vorhandene Datei oder einen neuen Upload.");
}
if (assetSelection.createdAssetId) {
@@ -521,7 +521,7 @@ export async function saveProjectAction(formData: FormData) {
}
redirect(
withMessage(`/root/portfolio/projects/${projectResult.project.id}`, "success", "Project saved."),
withMessage(`/root/portfolio/projects/${projectResult.project.id}`, "success", "Projekt gespeichert."),
);
} catch (error) {
if (isRedirectError(error)) {
@@ -532,10 +532,10 @@ export async function saveProjectAction(formData: FormData) {
error instanceof ZodError
? parseZodError(error)
: error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002"
? "Project slug must be unique."
? "Projekt Slug muss eindeutig sein."
: error instanceof Error
? error.message
: "Unable to save project.";
: "Projekt konnte nicht gespeichert werden.";
await removeManagedPaths(uploadedPaths);
if (createdMediaAssetIds.length > 0) {
+67 -52
View File
@@ -1,5 +1,6 @@
import { redirect } from "next/navigation";
import { MotionFade } from "@/components/motion-fade";
import { PortfolioSubnav } from "@/components/root/portfolio-subnav";
import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
import { AppCard } from "@/components/ui/app-card";
@@ -17,9 +18,9 @@ import { deleteCategoryAction, upsertCategoryAction } from "../actions";
export const dynamic = "force-dynamic";
const locales = [
{ key: "Ar", label: "Arabic", hint: "الواجهة العربية" },
{ key: "En", label: "English", hint: "English website" },
{ key: "De", label: "German", hint: "Deutsche Website" },
{ key: "Ar", label: "Arabisch", hint: "الواجهة العربية" },
{ key: "En", label: "Englisch", hint: "English website" },
{ key: "De", label: "Deutsch", hint: "Deutsche Website" },
] as const;
const copy = {
@@ -32,6 +33,13 @@ const copy = {
portfolio: "Portfolio",
logout: "Ausloggen",
backToSite: "Zur Website",
sortOrder: "Sortierung",
active: "Aktiv",
saveCategory: "Kategorie speichern",
save: "Speichern",
delete: "Loeschen",
projects: "Projekte",
description: "Beschreibung",
};
type RootPortfolioCategoriesPageProps = {
@@ -65,38 +73,42 @@ export default async function RootPortfolioCategoriesPage({
logoutAction={logoutAction}
headerTitle={copy.title}
headerDescription={copy.subtitle}
toolbar={<PortfolioSubnav active="categories" />}
>
<div className="space-y-6">
<PortfolioSubnav active="categories" />
{searchParams?.success ? (
<p className="rounded-nested border border-status-success/30 bg-status-success/10 px-4 py-3 text-sm text-status-success">
{searchParams.success}
</p>
<MotionFade delay={0.1}>
<p className="rounded-nested border border-status-success/30 bg-status-success/10 px-4 py-3 text-sm text-status-success">
{searchParams.success}
</p>
</MotionFade>
) : null}
{searchParams?.error ? (
<p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{searchParams.error}
</p>
<MotionFade delay={0.12}>
<p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{searchParams.error}
</p>
</MotionFade>
) : null}
<AppCard>
<CardHeader>
<CardTitle>Neue Kategorie</CardTitle>
<CardDescription>Eine Kategorie wird genau einem oder mehreren Projekten zugeordnet.</CardDescription>
</CardHeader>
<CardContent>
<form action={upsertCategoryAction} className="grid gap-4 md:grid-cols-2">
<MotionFade delay={0.15}>
<AppCard>
<CardHeader>
<CardTitle>Neue Kategorie</CardTitle>
<CardDescription>Eine Kategorie wird genau einem oder mehreren Projekten zugeordnet.</CardDescription>
</CardHeader>
<CardContent>
<form action={upsertCategoryAction} className="grid gap-4 md:grid-cols-2">
<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 />
</div>
<div className="space-y-2">
<Label htmlFor="create-sortOrder">Sort Order</Label>
<Input id="create-sortOrder" name="sortOrder" type="number" min="0" defaultValue="0" required />
</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 />
</div>
<div className="md:col-span-2">
<Tabs defaultValue="Ar">
@@ -117,7 +129,7 @@ export default async function RootPortfolioCategoriesPage({
<Input id={`create-name-${locale.key}`} name={`name${locale.key}`} required />
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`create-description-${locale.key}`}>{`Description ${locale.label}`}</Label>
<Label htmlFor={`create-description-${locale.key}`}>{`${copy.description} ${locale.label}`}</Label>
<Textarea
id={`create-description-${locale.key}`}
name={`description${locale.key}`}
@@ -131,23 +143,25 @@ 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 />
Active
</label>
<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 />
{copy.active}
</label>
<div className="md:col-span-2">
<Button type="submit">Save Category</Button>
</div>
</form>
</CardContent>
</AppCard>
<div className="md:col-span-2">
<Button type="submit">{copy.saveCategory}</Button>
</div>
</form>
</CardContent>
</AppCard>
</MotionFade>
<div className="grid gap-4">
{categories.map((category) => (
<AppCard key={category.id}>
<CardContent className="p-6">
<form action={upsertCategoryAction} className="grid gap-4 md:grid-cols-2">
{categories.map((category, index) => (
<MotionFade key={category.id} delay={0.18 + index * 0.03}>
<AppCard>
<CardContent className="p-6">
<form action={upsertCategoryAction} className="grid gap-4 md:grid-cols-2">
<input type="hidden" name="id" value={category.id} />
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
@@ -157,7 +171,7 @@ export default async function RootPortfolioCategoriesPage({
</div>
<div className="space-y-2">
<Label htmlFor={`sortOrder-${category.id}`}>Sort Order</Label>
<Label htmlFor={`sortOrder-${category.id}`}>{copy.sortOrder}</Label>
<Input
id={`sortOrder-${category.id}`}
name="sortOrder"
@@ -197,7 +211,7 @@ export default async function RootPortfolioCategoriesPage({
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`${descriptionKey}-${category.id}`}>{`Description ${locale.label}`}</Label>
<Label htmlFor={`${descriptionKey}-${category.id}`}>{`${copy.description} ${locale.label}`}</Label>
<Textarea
id={`${descriptionKey}-${category.id}`}
name={descriptionKey}
@@ -215,26 +229,27 @@ export default async function RootPortfolioCategoriesPage({
<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} />
Active
{copy.active}
</label>
<div className="flex items-end justify-between gap-3">
<p className="text-sm text-muted-foreground">{category.projectCount} projects</p>
<p className="text-sm text-muted-foreground">{category.projectCount} {copy.projects}</p>
<div className="flex gap-3">
<Button type="submit">Save</Button>
<Button type="submit">{copy.save}</Button>
</div>
</div>
</form>
</form>
<form action={deleteCategoryAction} className="mt-4">
<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}>
Delete
</Button>
</form>
</CardContent>
</AppCard>
<form action={deleteCategoryAction} className="mt-4">
<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}>
{copy.delete}
</Button>
</form>
</CardContent>
</AppCard>
</MotionFade>
))}
</div>
</div>
+46 -41
View File
@@ -61,6 +61,7 @@ export default async function RootPortfolioPage() {
logoutAction={logoutAction}
headerTitle={copy.title}
headerDescription={copy.subtitle}
toolbar={<PortfolioSubnav active="overview" />}
headerActions={
<div className="flex flex-wrap gap-3">
<Button asChild>
@@ -79,8 +80,6 @@ export default async function RootPortfolioPage() {
}
>
<div className="space-y-6">
<PortfolioSubnav active="overview" />
<section className="grid gap-4 md:grid-cols-3">
{[
{
@@ -120,47 +119,53 @@ export default async function RootPortfolioPage() {
</section>
<section className="grid gap-4 lg:grid-cols-3">
<AppCard>
<CardHeader>
<CardTitle>{copy.totalCategories}</CardTitle>
<CardDescription>Sortieren, aktivieren und neue Portfolio Gruppen anlegen.</CardDescription>
</CardHeader>
<CardContent>
<Button asChild variant="outline">
<Link href="/root/portfolio/categories">{copy.categoriesAction}</Link>
</Button>
</CardContent>
</AppCard>
<MotionFade delay={0.18}>
<AppCard>
<CardHeader>
<CardTitle>{copy.totalCategories}</CardTitle>
<CardDescription>Sortieren, aktivieren und neue Portfolio Gruppen anlegen.</CardDescription>
</CardHeader>
<CardContent>
<Button asChild variant="outline">
<Link href="/root/portfolio/categories">{copy.categoriesAction}</Link>
</Button>
</CardContent>
</AppCard>
</MotionFade>
<AppCard>
<CardHeader>
<CardTitle>{copy.totalProjects}</CardTitle>
<CardDescription>Drafts, veroeffentlichte Projekte und flexible Sections pflegen.</CardDescription>
</CardHeader>
<CardContent className="flex gap-3">
<Button asChild variant="outline">
<Link href="/root/portfolio/projects">{copy.projectsAction}</Link>
</Button>
<Button asChild>
<Link href="/root/portfolio/projects/new">{copy.newProject}</Link>
</Button>
</CardContent>
</AppCard>
<MotionFade delay={0.22}>
<AppCard>
<CardHeader>
<CardTitle>{copy.totalProjects}</CardTitle>
<CardDescription>Entwuerfe, veroeffentlichte Projekte und flexible Abschnitte pflegen.</CardDescription>
</CardHeader>
<CardContent className="flex gap-3">
<Button asChild variant="outline">
<Link href="/root/portfolio/projects">{copy.projectsAction}</Link>
</Button>
<Button asChild>
<Link href="/root/portfolio/projects/new">{copy.newProject}</Link>
</Button>
</CardContent>
</AppCard>
</MotionFade>
<AppCard>
<CardHeader>
<CardTitle>{copy.media}</CardTitle>
<CardDescription>Alle Cover und Projektdateien an einem Ort pruefen.</CardDescription>
</CardHeader>
<CardContent>
<Button asChild variant="outline">
<Link href="/root/media">
<ImageIcon className="h-4 w-4" />
{copy.media}
</Link>
</Button>
</CardContent>
</AppCard>
<MotionFade delay={0.26}>
<AppCard>
<CardHeader>
<CardTitle>{copy.media}</CardTitle>
<CardDescription>Alle Cover und Projektdateien an einem Ort pruefen.</CardDescription>
</CardHeader>
<CardContent>
<Button asChild variant="outline">
<Link href="/root/media">
<ImageIcon className="h-4 w-4" />
{copy.media}
</Link>
</Button>
</CardContent>
</AppCard>
</MotionFade>
</section>
</div>
</RootDashboardShell>
+44 -32
View File
@@ -1,5 +1,6 @@
import { redirect } from "next/navigation";
import { MotionFade } from "@/components/motion-fade";
import { PortfolioProjectForm } from "@/components/root/portfolio-project-form";
import { PortfolioSubnav } from "@/components/root/portfolio-subnav";
import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
@@ -27,6 +28,10 @@ const copy = {
portfolio: "Portfolio",
logout: "Ausloggen",
backToSite: "Zur Website",
saveProject: "Projekt speichern",
dangerZone: "Gefahrenbereich",
dangerText: "Projektdaten werden aus der Datenbank entfernt. Hochgeladene Dateien bleiben auf dem Speicher erhalten.",
deleteProject: "Projekt loeschen",
};
type RootPortfolioProjectPageProps = {
@@ -72,47 +77,54 @@ export default async function RootPortfolioProjectPage({
logoutAction={logoutAction}
headerTitle={copy.title}
headerDescription={copy.subtitle}
toolbar={<PortfolioSubnav active="projects" />}
>
<div className="space-y-6">
<PortfolioSubnav active="projects" />
{searchParams?.success ? (
<p className="rounded-nested border border-status-success/30 bg-status-success/10 px-4 py-3 text-sm text-status-success">
{searchParams.success}
</p>
<MotionFade delay={0.1}>
<p className="rounded-nested border border-status-success/30 bg-status-success/10 px-4 py-3 text-sm text-status-success">
{searchParams.success}
</p>
</MotionFade>
) : null}
{searchParams?.error ? (
<p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{searchParams.error}
</p>
<MotionFade delay={0.12}>
<p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{searchParams.error}
</p>
</MotionFade>
) : null}
<PortfolioProjectForm
action={saveProjectAction}
categories={categories}
mediaOptions={mediaOptions}
project={project}
redirectPath={`/root/portfolio/projects/${project.id}`}
submitLabel="Save Project"
/>
<MotionFade delay={0.15}>
<PortfolioProjectForm
action={saveProjectAction}
categories={categories}
mediaOptions={mediaOptions}
project={project}
redirectPath={`/root/portfolio/projects/${project.id}`}
submitLabel={copy.saveProject}
/>
</MotionFade>
<AppCard>
<CardContent className="flex items-center justify-between gap-4 p-6">
<div>
<p className="text-sm font-medium text-foreground">Danger Zone</p>
<p className="mt-1 text-sm text-muted-foreground">
Project records are deleted from the database. Uploaded files stay on disk.
</p>
</div>
<form action={deleteProjectAction}>
<input type="hidden" name="id" value={project.id} />
<Button type="submit" variant="destructive">
Delete Project
</Button>
</form>
</CardContent>
</AppCard>
<MotionFade delay={0.2}>
<AppCard>
<CardContent className="flex items-center justify-between gap-4 p-6">
<div>
<p className="text-sm font-medium text-foreground">{copy.dangerZone}</p>
<p className="mt-1 text-sm text-muted-foreground">
{copy.dangerText}
</p>
</div>
<form action={deleteProjectAction}>
<input type="hidden" name="id" value={project.id} />
<Button type="submit" variant="destructive">
{copy.deleteProject}
</Button>
</form>
</CardContent>
</AppCard>
</MotionFade>
</div>
</RootDashboardShell>
);
+17 -13
View File
@@ -1,5 +1,6 @@
import { redirect } from "next/navigation";
import { MotionFade } from "@/components/motion-fade";
import { PortfolioProjectForm } from "@/components/root/portfolio-project-form";
import { PortfolioSubnav } from "@/components/root/portfolio-subnav";
import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
@@ -13,7 +14,7 @@ export const dynamic = "force-dynamic";
const copy = {
title: "Neues Portfolio Projekt",
subtitle: "Projekt mit Kategorie, Sections und Assets anlegen.",
subtitle: "Projekt mit Kategorie, Abschnitten und Dateien anlegen.",
overview: "Uebersicht",
maintenance: "Wartungsmodus",
uiKit: "UI Kit",
@@ -56,23 +57,26 @@ export default async function RootNewPortfolioProjectPage({
logoutAction={logoutAction}
headerTitle={copy.title}
headerDescription={copy.subtitle}
toolbar={<PortfolioSubnav active="projects" />}
>
<div className="space-y-6">
<PortfolioSubnav active="projects" />
{searchParams?.error ? (
<p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{searchParams.error}
</p>
<MotionFade delay={0.1}>
<p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{searchParams.error}
</p>
</MotionFade>
) : null}
<PortfolioProjectForm
action={saveProjectAction}
categories={categories}
mediaOptions={mediaOptions}
redirectPath="/root/portfolio/projects/new"
submitLabel="Create Project"
/>
<MotionFade delay={0.15}>
<PortfolioProjectForm
action={saveProjectAction}
categories={categories}
mediaOptions={mediaOptions}
redirectPath="/root/portfolio/projects/new"
submitLabel="Projekt anlegen"
/>
</MotionFade>
</div>
</RootDashboardShell>
);
+66 -43
View File
@@ -2,6 +2,7 @@ import { Plus } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { MotionFade } from "@/components/motion-fade";
import { PortfolioSubnav } from "@/components/root/portfolio-subnav";
import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
import { AppCard } from "@/components/ui/app-card";
@@ -27,6 +28,17 @@ const copy = {
logout: "Ausloggen",
backToSite: "Zur Website",
newProject: "Neues Projekt",
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",
empty: "Keine Projekte fuer die aktuellen Filter gefunden.",
};
type RootPortfolioProjectsPageProps = {
@@ -71,36 +83,42 @@ export default async function RootPortfolioProjectsPage({
logoutAction={logoutAction}
headerTitle={copy.title}
headerDescription={copy.subtitle}
toolbar={<PortfolioSubnav active="projects" />}
headerActions={
<Button asChild>
<Link href="/root/portfolio/projects/new">
<Plus className="h-4 w-4" />
{copy.newProject}
</Link>
</Button>
<MotionFade delay={0.04}>
<Button asChild>
<Link href="/root/portfolio/projects/new">
<Plus className="h-4 w-4" />
{copy.newProject}
</Link>
</Button>
</MotionFade>
}
>
<div className="space-y-6">
<PortfolioSubnav active="projects" />
{searchParams?.success ? (
<p className="rounded-nested border border-status-success/30 bg-status-success/10 px-4 py-3 text-sm text-status-success">
{searchParams.success}
</p>
<MotionFade delay={0.1}>
<p className="rounded-nested border border-status-success/30 bg-status-success/10 px-4 py-3 text-sm text-status-success">
{searchParams.success}
</p>
</MotionFade>
) : null}
{searchParams?.error ? (
<p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{searchParams.error}
</p>
<MotionFade delay={0.12}>
<p className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{searchParams.error}
</p>
</MotionFade>
) : null}
<AppCard>
<CardContent className="p-6">
<form className="grid gap-4 md:grid-cols-3">
<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">
Category
{copy.category}
</label>
<select
id="category"
@@ -108,7 +126,7 @@ export default async function RootPortfolioProjectsPage({
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="">All</option>
<option value="">{copy.all}</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name.de}
@@ -119,7 +137,7 @@ export default async function RootPortfolioProjectsPage({
<div className="space-y-2">
<label htmlFor="status" className="text-sm font-medium text-foreground">
Status
{copy.status}
</label>
<select
id="status"
@@ -127,25 +145,27 @@ export default async function RootPortfolioProjectsPage({
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">All</option>
<option value="draft">Draft</option>
<option value="published">Published</option>
<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">
Filter
{copy.filter}
</Button>
</div>
</form>
</CardContent>
</AppCard>
</form>
</CardContent>
</AppCard>
</MotionFade>
<div className="grid gap-4">
{projects.map((project) => (
<AppCard key={project.id} interactive>
<CardHeader className="flex flex-row items-center justify-between 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">
@@ -154,34 +174,37 @@ export default async function RootPortfolioProjectsPage({
</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 ? "Published" : "Draft"}
{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">
Sort {project.sortOrder}
{copy.sort} {project.sortOrder}
</span>
</div>
</CardHeader>
<CardContent className="flex flex-wrap items-center justify-between gap-4">
</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 ? "Preview link set" : "No preview link"}</p>
<p>{project.previewUrl ? copy.previewSet : copy.previewMissing}</p>
</div>
<Button asChild>
<Link href={`/root/portfolio/projects/${project.id}`}>Edit Project</Link>
<Link href={`/root/portfolio/projects/${project.id}`}>{copy.editProject}</Link>
</Button>
</CardContent>
</AppCard>
</CardContent>
</AppCard>
</MotionFade>
))}
{projects.length === 0 ? (
<AppCard>
<CardContent className="p-6 text-sm text-muted-foreground">
No projects match the selected filters.
</CardContent>
</AppCard>
<MotionFade delay={0.18}>
<AppCard>
<CardContent className="p-6 text-sm text-muted-foreground">
{copy.empty}
</CardContent>
</AppCard>
</MotionFade>
) : null}
</div>
</div>