This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user