mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 19:51:17 +00:00
222 lines
11 KiB
TypeScript
222 lines
11 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>
|
|
|
|
<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>
|
|
)
|
|
}
|