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
+1 -1
View File
@@ -55,7 +55,7 @@ export function FlashMessage({
return (
<p
className={cn(
"rounded-nested border px-4 py-3 text-sm",
"rounded-md border px-4 py-3 text-sm",
type === "success"
? "border-status-success/30 bg-status-success/10 text-status-success"
: "border-destructive/30 bg-destructive/10 text-destructive",
+40 -29
View File
@@ -3,7 +3,7 @@
/* eslint-disable @next/next/no-img-element */
import type { MediaKind } from "@prisma/client";
import { Check, Search } from "lucide-react";
import { Check, Link2, Search, Type, Upload } from "lucide-react";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import type { MediaOption } from "@/lib/media";
@@ -110,9 +110,9 @@ export function MediaFieldPicker({
}, [serializedValue]);
return (
<div className="space-y-3 rounded-surface border border-border p-4">
<div className="space-y-3 rounded-lg border bg-card p-4 shadow-sm">
<div className="space-y-2">
<Label>{title}</Label>
<Label className="sr-only">{title}</Label>
<div className="flex flex-wrap gap-2">
{modeOptions.map((mode) => (
<button
@@ -128,10 +128,10 @@ export function MediaFieldPicker({
})
}
className={cn(
"rounded-pill border px-4 py-2 text-sm transition-colors",
"rounded-md border px-4 py-2 text-sm transition-colors",
value.mode === mode
? "border-border-strong bg-foreground text-background"
: "border-border bg-background text-foreground/75 hover:border-border-strong hover:text-foreground",
? "border-input bg-primary text-primary-foreground"
: "border-input bg-background text-foreground/75 hover:bg-accent hover:text-accent-foreground",
)}
>
{mode}
@@ -150,7 +150,7 @@ export function MediaFieldPicker({
isCleared: true,
})
}
className="rounded-pill border border-destructive/25 px-4 py-2 text-sm text-destructive transition-colors hover:border-destructive/50 hover:bg-destructive/5"
className="rounded-md border border-destructive/25 px-4 py-2 text-sm text-destructive transition-colors hover:bg-destructive/5"
>
{clearLabel}
</button>
@@ -167,37 +167,48 @@ export function MediaFieldPicker({
{value.mode !== "library" ? (
<div className="space-y-2">
<Label>Label</Label>
<Input
value={value.label}
onChange={(event) => onChange({ ...value, label: event.target.value, isCleared: false })}
placeholder="Homepage Hero"
/>
<Label className="sr-only">Label</Label>
<div className="relative">
<Type className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={value.label}
onChange={(event) => onChange({ ...value, label: event.target.value, isCleared: false })}
placeholder="Label"
className="pl-9"
/>
</div>
</div>
) : null}
{value.mode === "upload" ? (
<div className="space-y-2">
<Label>{fileLabel ?? fileFieldName}</Label>
<Input name={fileFieldName} type="file" accept={accept} />
<Label className="sr-only">{fileLabel ?? fileFieldName}</Label>
<div className="relative">
<Upload className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input name={fileFieldName} type="file" accept={accept} className="pl-9 file:mr-3" />
</div>
</div>
) : null}
{value.mode === "external" ? (
<div className="space-y-2">
<Label>{externalLabel ?? "External URL"}</Label>
<Input
value={value.url}
onChange={(event) => onChange({ ...value, url: event.target.value, isCleared: false })}
placeholder="https://example.com/image.jpg"
/>
<Label className="sr-only">{externalLabel ?? "External URL"}</Label>
<div className="relative">
<Link2 className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={value.url}
onChange={(event) => onChange({ ...value, url: event.target.value, isCleared: false })}
placeholder={externalLabel ?? "External URL"}
className="pl-9"
/>
</div>
</div>
) : null}
{value.mode === "library" ? (
<div className="space-y-3">
<Label>{libraryLabel ?? "Media Library"}</Label>
<div className="space-y-2 rounded-nested border border-border bg-surface-1 p-3">
<Label className="sr-only">{libraryLabel ?? "Media Library"}</Label>
<div className="space-y-2 rounded-md border bg-card p-3">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
@@ -227,10 +238,10 @@ export function MediaFieldPicker({
})
}
className={cn(
"flex w-full items-center gap-3 rounded-nested border px-3 py-2 text-left transition-colors",
"flex w-full items-center gap-3 rounded-md border px-3 py-2 text-left transition-colors",
isActive
? "border-border-strong bg-background"
: "border-border bg-background/70 hover:border-border-strong hover:bg-background",
? "border-input bg-accent/40"
: "border-input bg-background hover:bg-accent/20",
)}
>
{option.url ? (
@@ -247,7 +258,7 @@ export function MediaFieldPicker({
);
})
) : (
<div className="rounded-nested border border-dashed border-border px-3 py-4 text-sm text-muted-foreground">
<div className="rounded-md border border-dashed border-input px-3 py-4 text-sm text-muted-foreground">
No media found.
</div>
)}
@@ -258,11 +269,11 @@ export function MediaFieldPicker({
{previewUrl ? (
value.kind === "IMAGE" ? (
<div className="overflow-hidden rounded-nested border border-border bg-surface-1">
<div className="overflow-hidden rounded-md border bg-card">
<img src={previewUrl} alt={value.label || title} className="h-40 w-full object-cover" />
</div>
) : (
<div className="rounded-nested border border-border bg-surface-1 px-4 py-3 text-sm text-muted-foreground">
<div className="rounded-md border bg-card px-4 py-3 text-sm text-muted-foreground">
{previewUrl}
</div>
)
+166 -87
View File
@@ -1,15 +1,34 @@
"use client";
import type { MediaKind, PortfolioAssetKind, PortfolioSectionType } from "@prisma/client";
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
import {
ArrowDown,
ArrowUp,
BriefcaseBusiness,
CalendarRange,
FolderTree,
Hash,
Link2,
Plus,
Sparkles,
Trash2,
} from "lucide-react";
import { useState } from "react";
import { MediaFieldPicker, type MediaFieldState } from "@/components/root/media-field-picker";
import { AppCard } from "@/components/ui/app-card";
import { Button } from "@/components/ui/button";
import { CardContent, 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { moveArrayItem } from "@/lib/array";
@@ -176,6 +195,8 @@ export function PortfolioProjectForm({
}))
: [],
);
const [isFeatured, setIsFeatured] = useState(project?.isFeatured ?? false);
const [isPublished, setIsPublished] = useState(project?.isPublished ?? false);
const sectionsPayload = JSON.stringify(
sections.map((section, index) => ({
@@ -205,71 +226,106 @@ export function PortfolioProjectForm({
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="categoryId">Kategorie</Label>
<select
id="categoryId"
name="categoryId"
defaultValue={project?.category.id ?? categories[0]?.id ?? ""}
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
required
>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name.de} / {category.name.en}
</option>
))}
</select>
<Label htmlFor="categoryId" className="sr-only">Kategorie</Label>
<div className="relative">
<FolderTree className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Select
name="categoryId"
defaultValue={project?.category.id ?? categories[0]?.id ?? ""}
required
>
<SelectTrigger id="categoryId" className="pl-9">
<SelectValue placeholder="Kategorie" />
</SelectTrigger>
<SelectContent>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name.de} / {category.name.en}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="slug">Slug</Label>
<Input id="slug" name="slug" defaultValue={project?.slug ?? ""} required />
<Label htmlFor="slug" className="sr-only">Slug</Label>
<div className="relative">
<Hash className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="slug"
name="slug"
defaultValue={project?.slug ?? ""}
placeholder="Slug"
className="pl-9"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="clientName">Kundenname</Label>
<Input
id="clientName"
name="clientName"
defaultValue={project?.clientName ?? ""}
required
/>
<Label htmlFor="clientName" className="sr-only">Kundenname</Label>
<div className="relative">
<BriefcaseBusiness className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="clientName"
name="clientName"
defaultValue={project?.clientName ?? ""}
placeholder="Kundenname"
className="pl-9"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="projectYear">Projektjahr</Label>
<Input
id="projectYear"
name="projectYear"
type="number"
min="2000"
max="2100"
defaultValue={project?.projectYear ?? new Date().getFullYear()}
required
/>
<Label htmlFor="projectYear" className="sr-only">Projektjahr</Label>
<div className="relative">
<CalendarRange className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="projectYear"
name="projectYear"
type="number"
min="2000"
max="2100"
defaultValue={project?.projectYear ?? new Date().getFullYear()}
placeholder="Projektjahr"
className="pl-9"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="previewUrl">Preview URL</Label>
<Input
id="previewUrl"
name="previewUrl"
type="url"
defaultValue={project?.previewUrl ?? ""}
placeholder="https://example.com"
/>
<Label htmlFor="previewUrl" className="sr-only">Preview URL</Label>
<div className="relative">
<Link2 className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="previewUrl"
name="previewUrl"
type="url"
defaultValue={project?.previewUrl ?? ""}
placeholder="https://example.com"
className="pl-9"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="sortOrder">Sortierung</Label>
<Input
id="sortOrder"
name="sortOrder"
type="number"
min="0"
defaultValue={project?.sortOrder ?? 0}
required
/>
<Label htmlFor="sortOrder" className="sr-only">Sortierung</Label>
<div className="relative">
<Hash className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="sortOrder"
name="sortOrder"
type="number"
min="0"
defaultValue={project?.sortOrder ?? 0}
placeholder="Sortierung"
className="pl-9"
required
/>
</div>
</div>
<div className="space-y-2 md:col-span-2">
@@ -284,13 +340,23 @@ export function PortfolioProjectForm({
/>
</div>
<label className="flex items-center gap-3 rounded-nested border border-border px-4 py-3 text-sm">
<input type="checkbox" name="isFeatured" defaultChecked={project?.isFeatured ?? false} />
<label className="flex items-center gap-3 rounded-md border border-border px-4 py-3 text-sm">
<Checkbox
name="isFeatured"
checked={isFeatured}
onCheckedChange={(checked) => setIsFeatured(checked === true)}
/>
<Sparkles className="h-4 w-4 text-muted-foreground" />
Hervorgehoben
</label>
<label className="flex items-center gap-3 rounded-nested border border-border px-4 py-3 text-sm">
<input type="checkbox" name="isPublished" defaultChecked={project?.isPublished ?? false} />
<label className="flex items-center gap-3 rounded-md border border-border px-4 py-3 text-sm">
<Checkbox
name="isPublished"
checked={isPublished}
onCheckedChange={(checked) => setIsPublished(checked === true)}
/>
<Sparkles className="h-4 w-4 text-muted-foreground" />
Veroeffentlicht
</label>
</CardContent>
@@ -411,25 +477,29 @@ export function PortfolioProjectForm({
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Typ</Label>
<select
<Select
value={section.type}
onChange={(event) =>
onValueChange={(value) =>
setSections((current) =>
current.map((item, currentIndex) =>
currentIndex === index
? { ...item, type: event.target.value as PortfolioSectionType }
? { ...item, type: value as PortfolioSectionType }
: item,
),
)
}
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
>
{sectionTypeOptions.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
<SelectTrigger>
<SelectValue placeholder="Typ" />
</SelectTrigger>
<SelectContent>
{sectionTypeOptions.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2 md:col-span-2">
@@ -457,17 +527,22 @@ export function PortfolioProjectForm({
</div>
<div className="space-y-2 md:col-span-2">
<Label>Link URL</Label>
<Input
value={section.linkUrl}
onChange={(event) =>
setSections((current) =>
current.map((item, currentIndex) =>
currentIndex === index ? { ...item, linkUrl: event.target.value } : item,
),
)
}
/>
<Label className="sr-only">Link URL</Label>
<div className="relative">
<Link2 className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={section.linkUrl}
onChange={(event) =>
setSections((current) =>
current.map((item, currentIndex) =>
currentIndex === index ? { ...item, linkUrl: event.target.value } : item,
),
)
}
placeholder="Link URL"
className="pl-9"
/>
</div>
</div>
{localeFieldConfig.map((locale) => (
@@ -575,32 +650,36 @@ export function PortfolioProjectForm({
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Typ</Label>
<select
<Select
value={asset.kind}
onChange={(event) =>
onValueChange={(value) =>
setAssets((current) =>
current.map((item, currentIndex) =>
currentIndex === index
? {
...item,
kind: event.target.value as PortfolioAssetKind,
kind: value as PortfolioAssetKind,
media: {
...item.media,
kind: event.target.value as PortfolioAssetKind,
kind: value as PortfolioAssetKind,
},
}
: item,
),
)
}
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
>
{assetKindOptions.map((kind) => (
<option key={kind} value={kind}>
{kind}
</option>
))}
</select>
<SelectTrigger>
<SelectValue placeholder="Typ" />
</SelectTrigger>
<SelectContent>
{assetKindOptions.map((kind) => (
<SelectItem key={kind} value={kind}>
{kind}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2 md:col-span-2">
@@ -0,0 +1,316 @@
import { Boxes, ExternalLink, Filter, FolderKanban, Layers3, Plus, Tags } from "lucide-react";
import Link from "next/link";
import { MotionFade } from "@/components/motion-fade";
import { PortfolioSubnav } from "@/components/root/portfolio-subnav";
import { AppCard } from "@/components/ui/app-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { getLocalizedPath } from "@/lib/locale";
import { getLocalizedValue, type PortfolioCategoryView, type PortfolioProjectView } from "@/lib/portfolio";
type PortfolioProjectsOverviewProps = {
categories: PortfolioCategoryView[];
projects: PortfolioProjectView[];
selectedCategory: string;
selectedStatus: "all" | "draft" | "published";
};
const copy = {
summary: "Portfolio cockpit",
description: "Alle Projekte, Filter und Einstiegspunkte fuer die Bearbeitung an einem Ort.",
totalCategories: "Kategorien",
totalProjects: "Projekte",
publishedProjects: "Veroeffentlicht",
newProject: "Neues Projekt",
newCategory: "Neue Kategorie",
category: "Kategorie",
all: "Alle",
status: "Status",
draft: "Entwurf",
published: "Veroeffentlicht",
filter: "Filter anwenden",
empty: "Noch keine Projekte vorhanden. Lege das erste Projekt an und beginne direkt mit Inhalt, Abschnitten und Dateien.",
openProject: "Projekt ansehen",
editProject: "Projekt bearbeiten",
review: "Bearbeitungsstand",
reviewDone: "Bereit",
reviewMissing: "Fehlt etwas",
untitled: "Unbenanntes Projekt",
noCategory: "Ohne Kategorie",
noPreview: "Kein Preview Link",
hasPreview: "Preview Link vorhanden",
hasCover: "Cover gesetzt",
missingCover: "Cover fehlt",
hasSections: "Abschnitte vorhanden",
noSections: "Keine Abschnitte",
hasAssets: "Dateien vorhanden",
noAssets: "Keine Dateien",
};
function getProjectCompletion(project: PortfolioProjectView) {
const completedChecks = [
Boolean(project.slug.trim()),
Boolean(project.coverImagePath),
project.sections.length > 0,
project.assets.length > 0,
Boolean(project.title.ar.trim() && project.title.en.trim() && project.title.de.trim()),
Boolean(project.summary.ar.trim() && project.summary.en.trim() && project.summary.de.trim()),
].filter(Boolean).length;
return {
completedChecks,
totalChecks: 6,
ready: completedChecks === 6,
};
}
export function PortfolioProjectsOverview({
categories,
projects,
selectedCategory,
selectedStatus,
}: PortfolioProjectsOverviewProps) {
const publishedProjects = projects.filter((project) => project.isPublished).length;
return (
<div className="space-y-6">
<PortfolioSubnav active="projects" />
<MotionFade delay={0.05}>
<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="space-y-3">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
{copy.summary}
</p>
<div className="space-y-2">
<h2 className="text-2xl font-semibold text-foreground">Projektverwaltung</h2>
<p className="max-w-2xl text-sm text-muted-foreground">{copy.description}</p>
</div>
</div>
<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>
</CardContent>
</AppCard>
</MotionFade>
<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={0.08 + index * 0.04}>
<AppCard level={2}>
<CardContent className="flex items-center gap-4 p-6">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<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.14}>
<AppCard>
<CardContent className="p-6">
<form className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
<div className="space-y-2">
<Label htmlFor="portfolio-filter-category" className="sr-only">
{copy.category}
</Label>
<div className="relative">
<Tags className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Select name="category" defaultValue={selectedCategory || "__all__"}>
<SelectTrigger id="portfolio-filter-category" className="pl-9">
<SelectValue placeholder={copy.category} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">{copy.all}</SelectItem>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name.de}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="portfolio-filter-status" className="sr-only">
{copy.status}
</Label>
<div className="relative">
<Filter className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Select name="status" defaultValue={selectedStatus}>
<SelectTrigger id="portfolio-filter-status" className="pl-9">
<SelectValue placeholder={copy.status} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{copy.all}</SelectItem>
<SelectItem value="draft">{copy.draft}</SelectItem>
<SelectItem value="published">{copy.published}</SelectItem>
</SelectContent>
</Select>
</div>
</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) => {
const completion = getProjectCompletion(project);
return (
<MotionFade key={project.id} delay={0.18 + index * 0.03}>
<AppCard interactive>
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-xl">
{getLocalizedValue(project.title, "de") || copy.untitled}
</CardTitle>
<Badge variant={project.isPublished ? "success" : "warning"}>
{project.isPublished ? copy.published : copy.draft}
</Badge>
</div>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>{project.category.name.de || copy.noCategory}</span>
<span></span>
<span>{project.projectYear}</span>
<span></span>
<span>{project.slug}</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Badge variant={completion.ready ? "success" : "warning"}>
{copy.review}: {completion.completedChecks}/{completion.totalChecks}
</Badge>
<Badge variant="outline">
{completion.ready ? copy.reviewDone : copy.reviewMissing}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-5">
<p className="max-w-3xl text-sm text-muted-foreground">
{getLocalizedValue(project.summary, "de") || "Noch keine Kurzbeschreibung hinterlegt."}
</p>
<div className="flex flex-wrap gap-2">
<Badge variant={project.previewUrl ? "success" : "outline"}>
{project.previewUrl ? copy.hasPreview : copy.noPreview}
</Badge>
<Badge variant={project.coverImagePath ? "success" : "outline"}>
{project.coverImagePath ? copy.hasCover : copy.missingCover}
</Badge>
<Badge variant={project.sections.length > 0 ? "success" : "outline"}>
{project.sections.length > 0 ? copy.hasSections : copy.noSections}
</Badge>
<Badge variant={project.assets.length > 0 ? "success" : "outline"}>
{project.assets.length > 0 ? copy.hasAssets : copy.noAssets}
</Badge>
</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="space-y-4 p-6">
<p className="text-sm text-muted-foreground">{copy.empty}</p>
<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>
</CardContent>
</AppCard>
</MotionFade>
) : null}
</div>
</div>
);
}
+6 -6
View File
@@ -5,13 +5,13 @@ import { CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
type PortfolioSubnavProps = {
active: "overview" | "categories";
active: "projects" | "categories";
};
const items = [
{
key: "overview",
label: "Uebersicht",
key: "projects",
label: "Projekte",
href: "/root/portfolio",
},
{
@@ -30,10 +30,10 @@ export function PortfolioSubnav({ active }: PortfolioSubnavProps) {
key={item.key}
href={item.href}
className={cn(
"rounded-pill border px-4 py-2 text-sm transition-colors",
"rounded-md border px-4 py-2 text-sm transition-colors",
active === item.key
? "border-border-strong bg-foreground text-background"
: "border-border bg-background text-foreground/75 hover:border-border-strong hover:text-foreground",
? "border-input bg-primary text-primary-foreground"
: "border-input bg-background text-foreground/75 hover:bg-accent hover:text-accent-foreground",
)}
>
{item.label}
@@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
type SidebarMaintenanceControlProps = {
@@ -33,10 +34,10 @@ export function SidebarMaintenanceControl({
<label
htmlFor="sidebar-maintenance-enabled"
className={cn(
"flex cursor-pointer items-center justify-between gap-3 rounded-nested border px-3 py-2 transition-colors",
"flex cursor-pointer items-center justify-between gap-3 rounded-md border px-3 py-2 transition-colors",
enabled
? "border-status-warning/40 bg-status-warning-soft/80"
: "border-border bg-surface-1 hover:bg-surface-2",
: "border-input bg-card hover:bg-accent/20",
)}
>
<span className="flex min-w-0 items-center gap-3">
@@ -63,11 +64,10 @@ export function SidebarMaintenanceControl({
</Badge>
</label>
<input
<Checkbox
id="sidebar-maintenance-enabled"
type="checkbox"
checked={enabled}
onChange={(event) => setEnabled(event.target.checked)}
onCheckedChange={(checked) => setEnabled(checked === true)}
className="sr-only"
/>
</form>
+95 -76
View File
@@ -3,7 +3,7 @@
/* eslint-disable @next/next/no-img-element */
import { MediaKind } from "@prisma/client";
import { ImageIcon, Search, Type } from "lucide-react";
import { FileText, ImageIcon, Search, Type } from "lucide-react";
import { useState } from "react";
import { MediaFieldPicker, type MediaFieldState } from "@/components/root/media-field-picker";
@@ -179,95 +179,114 @@ export function SiteSettingsForm({
</CardHeader>
<CardContent className="grid gap-4 lg:grid-cols-3">
{localeFields.map((locale) => (
<div key={locale.key} className="space-y-4 rounded-surface border border-border bg-surface-1 p-4">
<div key={locale.key} className="space-y-4 rounded-lg border bg-card p-4 shadow-sm">
<div>
<p className="text-sm font-medium text-foreground">{locale.label}</p>
<p className="mt-1 text-xs text-muted-foreground">Globale Standardwerte fuer diese Sprache.</p>
</div>
<div className="space-y-2">
<Label htmlFor={`siteName${locale.suffix}`}>Site Name</Label>
<Input
id={`siteName${locale.suffix}`}
name={`siteName${locale.suffix}`}
value={settings.locales[locale.key].siteName}
onChange={(event) =>
setSettings((current) => ({
...current,
locales: {
...current.locales,
[locale.key]: {
...current.locales[locale.key],
siteName: event.target.value,
<Label htmlFor={`siteName${locale.suffix}`} className="sr-only">Site Name</Label>
<div className="relative">
<Type className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id={`siteName${locale.suffix}`}
name={`siteName${locale.suffix}`}
value={settings.locales[locale.key].siteName}
onChange={(event) =>
setSettings((current) => ({
...current,
locales: {
...current.locales,
[locale.key]: {
...current.locales[locale.key],
siteName: event.target.value,
},
},
},
}))
}
/>
}))
}
placeholder="Site Name"
className="pl-9"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`titleTemplate${locale.suffix}`}>Title Template</Label>
<Input
id={`titleTemplate${locale.suffix}`}
name={`titleTemplate${locale.suffix}`}
value={settings.locales[locale.key].titleTemplate}
onChange={(event) =>
setSettings((current) => ({
...current,
locales: {
...current.locales,
[locale.key]: {
...current.locales[locale.key],
titleTemplate: event.target.value,
<Label htmlFor={`titleTemplate${locale.suffix}`} className="sr-only">Title Template</Label>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id={`titleTemplate${locale.suffix}`}
name={`titleTemplate${locale.suffix}`}
value={settings.locales[locale.key].titleTemplate}
onChange={(event) =>
setSettings((current) => ({
...current,
locales: {
...current.locales,
[locale.key]: {
...current.locales[locale.key],
titleTemplate: event.target.value,
},
},
},
}))
}
placeholder="{pageTitle} | {siteName}"
/>
}))
}
placeholder="Title Template"
className="pl-9"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`siteDescription${locale.suffix}`}>Site Description</Label>
<Input
id={`siteDescription${locale.suffix}`}
name={`siteDescription${locale.suffix}`}
value={settings.locales[locale.key].siteDescription}
onChange={(event) =>
setSettings((current) => ({
...current,
locales: {
...current.locales,
[locale.key]: {
...current.locales[locale.key],
siteDescription: event.target.value,
<Label htmlFor={`siteDescription${locale.suffix}`} className="sr-only">Site Description</Label>
<div className="relative">
<FileText className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id={`siteDescription${locale.suffix}`}
name={`siteDescription${locale.suffix}`}
value={settings.locales[locale.key].siteDescription}
onChange={(event) =>
setSettings((current) => ({
...current,
locales: {
...current.locales,
[locale.key]: {
...current.locales[locale.key],
siteDescription: event.target.value,
},
},
},
}))
}
/>
}))
}
placeholder="Site Description"
className="pl-9"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`subhead${locale.suffix}`}>Subhead</Label>
<Input
id={`subhead${locale.suffix}`}
name={`subhead${locale.suffix}`}
value={settings.locales[locale.key].subhead}
onChange={(event) =>
setSettings((current) => ({
...current,
locales: {
...current.locales,
[locale.key]: {
...current.locales[locale.key],
subhead: event.target.value,
<Label htmlFor={`subhead${locale.suffix}`} className="sr-only">Subhead</Label>
<div className="relative">
<Type className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id={`subhead${locale.suffix}`}
name={`subhead${locale.suffix}`}
value={settings.locales[locale.key].subhead}
onChange={(event) =>
setSettings((current) => ({
...current,
locales: {
...current.locales,
[locale.key]: {
...current.locales[locale.key],
subhead: event.target.value,
},
},
},
}))
}
/>
}))
}
placeholder="Subhead"
className="pl-9"
/>
</div>
</div>
</div>
))}
@@ -344,7 +363,7 @@ export function SiteSettingsForm({
: `${locale.sampleTitle} | ${localeSettings.siteName}`;
return (
<div key={locale.key} className="rounded-nested border border-border bg-background p-3">
<div key={locale.key} className="rounded-md border bg-card p-3">
<p className="font-medium text-foreground">{locale.label}</p>
<p className="mt-2 text-xs text-muted-foreground">
Example page title: {locale.sampleTitle}
@@ -365,12 +384,12 @@ export function SiteSettingsForm({
<Search className="h-4 w-4 text-brand-primary" />
Search Preview
</div>
<div className="rounded-nested border border-border bg-background p-4">
<div className="rounded-md border bg-card p-4">
<div className="flex items-center gap-3">
{faviconPreviewUrl ? (
<img src={faviconPreviewUrl} alt="Favicon" className="h-5 w-5 rounded-sm" />
) : (
<div className="flex h-5 w-5 items-center justify-center rounded-sm border border-border bg-surface-1">
<div className="flex h-5 w-5 items-center justify-center rounded-sm border bg-muted/40">
<Type className="h-3.5 w-3.5 text-muted-foreground" />
</div>
)}
@@ -394,11 +413,11 @@ export function SiteSettingsForm({
<ImageIcon className="h-4 w-4 text-brand-secondary" />
Social Preview
</div>
<div className="overflow-hidden rounded-nested border border-border bg-background">
<div className="overflow-hidden rounded-md border bg-card">
{defaultOgImagePreviewUrl ? (
<img src={defaultOgImagePreviewUrl} alt="Default OG" className="h-32 w-full object-cover" />
) : (
<div className="flex h-32 items-center justify-center bg-surface-1 text-sm text-muted-foreground">
<div className="flex h-32 items-center justify-center bg-muted/40 text-sm text-muted-foreground">
No OG image selected
</div>
)}