since3-portal/modules/templates/ui/components/editor/design-panel.tsx
2026-10-11 18:22:32 +02:00

225 lines
12 KiB
TypeScript

"use client"
import type { ReactNode } from "react"
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { cn } from "@/lib/utils"
import { fontLibrary, paletteLibrary, themeColors } from "../../../lib/theme"
import type { ColorOverrides, Palette, TemplateContent, TemplateDefinition, TemplateStyle } from "../../../lib/types"
import { Toggle } from "./toggle"
type ThemePatch = Partial<Pick<TemplateContent, "palette" | "fonts" | "colors" | "hidden" | "order" | "style">>
const colorFields: { key: keyof ColorOverrides; label: string }[] = [
{ key: "bg", label: "Hintergrund" },
{ key: "fg", label: "Text" },
{ key: "muted", label: "Nebentext" },
{ key: "surface", label: "Flächen" },
{ key: "accent", label: "Akzent" },
]
const imageOptions: { key: TemplateStyle["images"]; label: string }[] = [
{ key: "original", label: "Original" },
{ key: "mono", label: "Schwarzweiß" },
{ key: "warm", label: "Warm" },
{ key: "soft", label: "Sanft" },
{ key: "contrast", label: "Kontrast" },
]
const choice = (active: boolean) =>
cn("cursor-pointer rounded-lg border text-left text-sm transition-shadow", active ? "ring-2 ring-ring" : "hover:border-foreground/30")
function Heading({ title, text }: { title: string; text?: string }) {
return (
<div className="mb-3">
<h3 className="text-sm font-medium">{title}</h3>
{text && <p className="mt-0.5 text-xs text-muted-foreground">{text}</p>}
</div>
)
}
function Swatch({ palette, active, label, onClick, small }: { palette: Palette; active: boolean; label: string; onClick: () => void; small?: boolean }) {
return (
<button type="button" title={label} aria-pressed={active} onClick={onClick} className={cn(choice(active), "overflow-hidden")}>
<div className={cn("flex items-end gap-1 p-2", small ? "h-9" : "h-14")} style={{ background: palette.bg }}>
<span className={cn("rounded-full", small ? "size-3" : "size-4")} style={{ background: palette.fg }} />
<span className={cn("rounded-full", small ? "size-3" : "size-4")} style={{ background: palette.accent }} />
</div>
{!small && <p className="px-2 py-1.5 text-xs">{palette.label}</p>}
</button>
)
}
function More({ label, children }: { label: string; children: ReactNode }) {
return (
<Collapsible className="mt-2">
<CollapsibleTrigger className="flex cursor-pointer items-center gap-1 text-xs text-muted-foreground hover:text-foreground [&[data-panel-open]>svg]:rotate-180">
{label} <ChevronDownIcon className="size-3.5 transition-transform" />
</CollapsibleTrigger>
<CollapsibleContent>
<div className="pt-3">{children}</div>
</CollapsibleContent>
</Collapsible>
)
}
export function DesignPanel({ def, content, onChange }: { def: TemplateDefinition; content: TemplateContent; onChange: (patch: ThemePatch) => void }) {
const theme = themeColors(def, content)
const ownPalettes = new Set(def.palettes.map((p) => `${def.key}:${p.key}`))
const ownFonts = new Set(def.fonts.map((f) => `${def.key}:${f.key}`))
const customized = Object.values(content.colors).some(Boolean)
const optional = content.order
.map((key) => def.sections.find((section) => section.key === key))
.filter((section) => section !== undefined)
function setPalette(key: string) {
// Neue Palette: eigene Farben zurücksetzen, sonst sieht man den Wechsel nicht
onChange({ palette: key, colors: { bg: "", fg: "", muted: "", surface: "", accent: "" } })
}
function move(index: number, offset: number) {
const keys = optional.map((section) => section.key)
const [key] = keys.splice(index, 1)
keys.splice(index + offset, 0, key)
onChange({ order: keys })
}
return (
<div className="flex flex-col gap-8">
<section>
<Heading title="Farbpalette" />
<div className="grid grid-cols-3 gap-2">
{def.palettes.map((p) => (
<Swatch key={p.key} palette={p} label={p.label} active={content.palette === p.key} onClick={() => setPalette(p.key)} />
))}
</div>
<More label={`Paletten aus anderen Vorlagen (${paletteLibrary.length - def.palettes.length})`}>
<div className="grid grid-cols-6 gap-1.5">
{paletteLibrary
.filter((entry) => !ownPalettes.has(entry.key))
.map((entry) => (
<Swatch
key={entry.key}
small
palette={entry.palette}
label={`${entry.palette.label} · ${entry.template}`}
active={content.palette === entry.key}
onClick={() => setPalette(entry.key)}
/>
))}
</div>
</More>
</section>
<section>
<Heading title="Eigene Farben" text="Zum Beispiel die Markenfarben des Kunden. Textfarbe auf dem Akzent wird automatisch gewählt." />
<div className="grid grid-cols-5 gap-2">
{colorFields.map((field) => (
<label key={field.key} className="flex flex-col items-center gap-1.5 text-center text-[11px] text-muted-foreground">
<input
type="color"
value={content.colors[field.key] || theme[field.key]}
onChange={(event) => onChange({ colors: { ...content.colors, [field.key]: event.target.value } })}
className={cn("h-10 w-full cursor-pointer rounded-md border bg-transparent p-1", content.colors[field.key] && "ring-2 ring-ring")}
/>
{field.label}
</label>
))}
</div>
{customized && (
<Button variant="ghost" size="sm" className="mt-2" onClick={() => onChange({ colors: { bg: "", fg: "", muted: "", surface: "", accent: "" } })}>
Farben der Palette verwenden
</Button>
)}
</section>
<section>
<Heading title="Schriften" />
<div className="flex flex-col gap-2">
{def.fonts.map((pair) => (
<button key={pair.key} type="button" aria-pressed={pair.key === content.fonts} onClick={() => onChange({ fonts: pair.key })} className={cn(choice(pair.key === content.fonts), "px-3 py-2.5")}>
{pair.label}
</button>
))}
</div>
<More label={`Schriften aus anderen Vorlagen (${fontLibrary.length - def.fonts.length})`}>
<div className="flex max-h-72 flex-col gap-1 overflow-y-auto pr-1">
{fontLibrary
.filter((entry) => !ownFonts.has(entry.key))
.map((entry) => (
<button key={entry.key} type="button" aria-pressed={content.fonts === entry.key} onClick={() => onChange({ fonts: entry.key })} className={cn(choice(content.fonts === entry.key), "px-3 py-2")}>
{entry.label} <span className="text-xs text-muted-foreground">· {entry.template}</span>
</button>
))}
</div>
</More>
</section>
<section>
<Heading title="Bildstil" text="Gilt für alle Bilder der Seite." />
<div className="flex flex-wrap gap-1.5">
{imageOptions.map((option) => (
<button
key={option.key}
type="button"
aria-pressed={content.style.images === option.key}
onClick={() => onChange({ style: { ...content.style, images: option.key } })}
className={cn(choice(content.style.images === option.key), "px-3 py-1.5")}
>
{option.label}
</button>
))}
</div>
</section>
<section>
<Heading title="Bewegung" text="Dezent: nur sanftes Einblenden, keine Bewegungseffekte." />
<div className="grid grid-cols-2 gap-1.5">
{(["full", "subtle"] as const).map((motion) => (
<button
key={motion}
type="button"
aria-pressed={content.style.motion === motion}
onClick={() => onChange({ style: { ...content.style, motion } })}
className={cn(choice(content.style.motion === motion), "px-3 py-2")}
>
{motion === "full" ? "Voll" : "Dezent"}
</button>
))}
</div>
</section>
{/* Baukasten-Vorlagen haben keine optionalen Sektionen; Reihenfolge und Sichtbarkeit steuern dort die Bausteine (Tab „Inhalte“) */}
{optional.length > 0 && (
<section>
<Heading title="Sektionen" text="Reihenfolge per Pfeil ändern. Ausgeblendete Sektionen behalten ihre Inhalte." />
<div className="flex flex-col divide-y rounded-lg border">
{optional.map((section, index) => (
<div key={section.key} className="flex items-center gap-1 pl-1">
<div className="flex flex-col">
<button type="button" aria-label={`${section.label} nach oben`} disabled={index === 0} onClick={() => move(index, -1)} className="cursor-pointer rounded p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30">
<ChevronUpIcon className="size-3.5" />
</button>
<button type="button" aria-label={`${section.label} nach unten`} disabled={index === optional.length - 1} onClick={() => move(index, 1)} className="cursor-pointer rounded p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30">
<ChevronDownIcon className="size-3.5" />
</button>
</div>
<div className="min-w-0 flex-1">
<Toggle
checked={!content.hidden.includes(section.key)}
onChange={(visible) =>
onChange({ hidden: visible ? content.hidden.filter((key) => key !== section.key) : [...content.hidden, section.key] })
}
>
{section.label}
</Toggle>
</div>
</div>
))}
</div>
</section>
)}
</div>
)
}