Refactor site header, heroes, and design system

This commit is contained in:
MOH
2026-03-08 00:29:51 +01:00
parent a18370d003
commit 0c2b67e378
56 changed files with 2714 additions and 1318 deletions
+4 -4
View File
@@ -110,7 +110,7 @@ export function MediaFieldPicker({
}, [serializedValue]);
return (
<div className="space-y-3 rounded-lg border bg-card p-4 shadow-sm">
<div className="space-y-3 rounded-surface border border-border/80 bg-card p-4 shadow-card">
<div className="space-y-2">
<Label className="sr-only">{title}</Label>
<div className="flex flex-wrap gap-2">
@@ -128,7 +128,7 @@ export function MediaFieldPicker({
})
}
className={cn(
"rounded-md border px-4 py-2 text-sm transition-colors",
"rounded-nested border px-4 py-2 text-sm transition-colors",
value.mode === mode
? "border-input bg-primary text-primary-foreground"
: "border-input bg-background text-foreground/75 hover:bg-accent hover:text-accent-foreground",
@@ -150,7 +150,7 @@ export function MediaFieldPicker({
isCleared: true,
})
}
className="rounded-md border border-destructive/25 px-4 py-2 text-sm text-destructive transition-colors hover:bg-destructive/5"
className="rounded-nested border border-destructive/25 px-4 py-2 text-sm text-destructive transition-colors hover:bg-destructive/5"
>
{clearLabel}
</button>
@@ -208,7 +208,7 @@ export function MediaFieldPicker({
{value.mode === "library" ? (
<div className="space-y-3">
<Label className="sr-only">{libraryLabel ?? "Media Library"}</Label>
<div className="space-y-2 rounded-md border bg-card p-3">
<div className="space-y-2 rounded-nested border border-border/80 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
@@ -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>
);
}
File diff suppressed because it is too large Load Diff
+57 -178
View File
@@ -1,8 +1,7 @@
import { Boxes, ExternalLink, Filter, FolderKanban, Layers3, Plus, Tags } from "lucide-react";
import { ExternalLink, Filter, FolderKanban, 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";
@@ -26,76 +25,45 @@ type PortfolioProjectsOverviewProps = {
};
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",
filter: "Filtern",
newProject: "Neues Projekt",
newCategory: "Neues Kategorie",
openProject: "Ansehen",
editProject: "Bearbeiten",
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",
empty: "Noch keine Projekte vorhanden.",
};
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 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">Projects</p>
<p className="mt-2 text-3xl font-semibold text-foreground">{projects.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">Categories</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">Published</p>
<p className="mt-2 text-3xl font-semibold text-foreground">
{projects.filter((project) => project.isPublished).length}
</p>
</div>
</div>
@@ -117,45 +85,7 @@ export function PortfolioProjectsOverview({
</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}>
<MotionFade delay={0.1}>
<AppCard>
<CardContent className="p-6">
<form className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
@@ -211,104 +141,53 @@ export function PortfolioProjectsOverview({
</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}
{projects.map((project, index) => (
<MotionFade key={project.id} delay={0.14 + index * 0.03}>
<AppCard interactive>
<CardHeader className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1">
<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>
</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 className="text-sm text-muted-foreground">
{project.category.name.de}
</p>
</div>
<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}
<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 variant="outline">
<Link href="/root/portfolio/categories">
<Tags className="h-4 w-4" />
{copy.newCategory}
<Button asChild>
<Link href={`/root/portfolio/projects/${project.id}`}>
<FolderKanban className="h-4 w-4" />
{copy.editProject}
</Link>
</Button>
</div>
</CardContent>
</CardHeader>
</AppCard>
</MotionFade>
))}
{projects.length === 0 ? (
<AppCard>
<CardContent className="p-6 text-sm text-muted-foreground">
{copy.empty}
</CardContent>
</AppCard>
) : null}
</div>
</div>
@@ -34,7 +34,7 @@ export function SidebarMaintenanceControl({
<label
htmlFor="sidebar-maintenance-enabled"
className={cn(
"flex cursor-pointer items-center justify-between gap-3 rounded-md border px-3 py-2 transition-colors",
"flex cursor-pointer items-center justify-between gap-3 rounded-nested border px-3 py-2 transition-colors",
enabled
? "border-status-warning/40 bg-status-warning-soft/80"
: "border-input bg-card hover:bg-accent/20",
@@ -43,7 +43,7 @@ export function SidebarMaintenanceControl({
<span className="flex min-w-0 items-center gap-3">
<span
className={cn(
"flex h-9 w-9 items-center justify-center rounded-full border",
"flex h-9 w-9 items-center justify-center rounded-pill border",
enabled
? "border-status-warning/40 bg-status-warning-soft text-status-warning"
: "border-border bg-background text-muted-foreground",
+107 -2
View File
@@ -117,6 +117,20 @@ export function SiteSettingsForm({
mediaOptions,
}: SiteSettingsFormProps) {
const [settings, setSettings] = useState(initialSettings);
const [siteLogoLight, setSiteLogoLight] = useState<MediaFieldState>(
createImageFieldState(
initialBindings.siteLogoLight?.assetId,
initialBindings.siteLogoLight?.url,
"Site Logo Light",
),
);
const [siteLogoDark, setSiteLogoDark] = useState<MediaFieldState>(
createImageFieldState(
initialBindings.siteLogoDark?.assetId,
initialBindings.siteLogoDark?.url,
"Site Logo Dark",
),
);
const [favicon, setFavicon] = useState<MediaFieldState>(
createImageFieldState(
initialBindings.favicon?.assetId,
@@ -131,6 +145,16 @@ export function SiteSettingsForm({
"Default OG Image",
),
);
const siteLogoLightPreviewUrl = getMediaPreviewUrl(
siteLogoLight,
mediaOptions,
initialBindings.siteLogoLight?.url,
);
const siteLogoDarkPreviewUrl = getMediaPreviewUrl(
siteLogoDark,
mediaOptions,
initialBindings.siteLogoDark?.url,
);
const faviconPreviewUrl = getMediaPreviewUrl(favicon, mediaOptions, initialBindings.favicon?.url);
const defaultOgImagePreviewUrl = getMediaPreviewUrl(
defaultOgImage,
@@ -295,12 +319,54 @@ export function SiteSettingsForm({
<AppCard>
<CardHeader>
<CardTitle>Preview Images</CardTitle>
<CardTitle>Brand And Preview Images</CardTitle>
<CardDescription>
Favicon erscheint im Browser. Das Default OG Bild wird fuer Social Sharing genutzt, wenn eine Seite kein eigenes Bild liefert.
Logo erscheint im Header. Favicon erscheint im Browser. Das Default OG Bild wird fuer Social Sharing genutzt, wenn eine Seite kein eigenes Bild liefert.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-6 xl:grid-cols-2">
<MediaFieldPicker
title="Site Logo Light"
value={siteLogoLight}
onChange={setSiteLogoLight}
options={mediaOptions}
hasInitialValue={Boolean(initialBindings.siteLogoLight?.assetId || initialBindings.siteLogoLight?.url)}
inputName="siteLogoLightMedia"
fileFieldName="siteLogoLightFile"
fileLabel="Upload Light Logo"
libraryLabel="Light Logo Library"
accept="image/*,.svg"
allowExternal={false}
allowClear
clearLabel="Remove Light Logo"
emptyValue={{
mode: "upload",
assetId: "",
url: "",
}}
/>
<MediaFieldPicker
title="Site Logo Dark"
value={siteLogoDark}
onChange={setSiteLogoDark}
options={mediaOptions}
hasInitialValue={Boolean(initialBindings.siteLogoDark?.assetId || initialBindings.siteLogoDark?.url)}
inputName="siteLogoDarkMedia"
fileFieldName="siteLogoDarkFile"
fileLabel="Upload Dark Logo"
libraryLabel="Dark Logo Library"
accept="image/*,.svg"
allowExternal={false}
allowClear
clearLabel="Remove Dark Logo"
emptyValue={{
mode: "upload",
assetId: "",
url: "",
}}
/>
<MediaFieldPicker
title="Favicon"
value={favicon}
@@ -380,6 +446,45 @@ export function SiteSettingsForm({
})}
</div>
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<ImageIcon className="h-4 w-4 text-brand-primary" />
Header Logo Preview
</div>
<div className="grid gap-3">
<div className="rounded-md border bg-card p-4">
<p className="mb-3 text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
Light
</p>
{siteLogoLightPreviewUrl ? (
<img
src={siteLogoLightPreviewUrl}
alt="Site Logo Light"
className="h-10 w-auto max-w-full object-contain"
/>
) : (
<div className="flex h-10 items-center text-sm text-muted-foreground">
Default light logo will be used
</div>
)}
</div>
<div className="rounded-md border border-border/70 bg-slate-950 p-4">
<p className="mb-3 text-xs font-medium uppercase tracking-[0.2em] text-white/55">
Dark
</p>
{siteLogoDarkPreviewUrl ? (
<img
src={siteLogoDarkPreviewUrl}
alt="Site Logo Dark"
className="h-10 w-auto max-w-full object-contain"
/>
) : (
<div className="flex h-10 items-center text-sm text-white/60">
Default dark logo will be used
</div>
)}
</div>
</div>
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<Search className="h-4 w-4 text-brand-primary" />
Search Preview
+123
View File
@@ -0,0 +1,123 @@
import type { ReactNode } from "react";
import { AppCard } from "@/components/ui/app-card";
import { Badge } from "@/components/ui/badge";
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
export function WorkspaceHero({
eyebrow,
title,
description,
aside,
}: {
eyebrow: string;
title: string;
description: string;
aside?: ReactNode;
}) {
return (
<AppCard level={3}>
<CardContent className="flex flex-col gap-4 p-6 lg:flex-row lg:items-end lg:justify-between lg:p-8">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
{eyebrow}
</p>
<h2 className="text-2xl font-semibold text-foreground">{title}</h2>
<p className="max-w-3xl text-sm text-muted-foreground">{description}</p>
</div>
{aside ? <div className="flex flex-wrap gap-2">{aside}</div> : null}
</CardContent>
</AppCard>
);
}
export function WorkspaceSidebarPanel({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
return (
<AppCard level={2}>
<CardHeader className="pb-4">
<CardTitle className="text-base">{title}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">{children}</CardContent>
</AppCard>
);
}
export function WorkspaceStepButton({
active,
completed,
index,
label,
eyebrow,
onClick,
completeIcon,
}: {
active: boolean;
completed: boolean;
index: number;
label: string;
eyebrow: string;
onClick: () => void;
completeIcon?: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex w-full items-start gap-3 rounded-surface border px-4 py-3 text-left transition-colors",
active
? "border-input bg-accent/40"
: "border-input bg-background hover:bg-accent/20",
)}
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-pill border border-input bg-background text-sm font-semibold text-foreground">
{completed ? completeIcon ?? "OK" : index + 1}
</div>
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{eyebrow}
</p>
<p className="mt-1 text-sm font-medium text-foreground">{label}</p>
</div>
</button>
);
}
export function WorkspaceLocaleCard({
title,
hint,
children,
}: {
title: string;
hint: string;
children: ReactNode;
}) {
return (
<AppCard level={2}>
<CardHeader className="pb-4">
<CardTitle className="text-lg">{title}</CardTitle>
<CardDescription>{hint}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">{children}</CardContent>
</AppCard>
);
}
export function WorkspaceStatusBadge({
done,
doneLabel = "Ready",
openLabel = "Open",
}: {
done: boolean;
doneLabel?: string;
openLabel?: string;
}) {
return <Badge variant={done ? "success" : "warning"}>{done ? doneLabel : openLabel}</Badge>;
}