mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 19:51:17 +00:00
168 lines
8.7 KiB
TypeScript
168 lines
8.7 KiB
TypeScript
import type { CSSProperties } from "react"
|
||
import type { AiSettings, FieldDef, ListFieldDef, ListItem, SectionData, SectionDef, TemplateContent, TemplateData, TemplateDefinition } from "./types"
|
||
|
||
const MAX_TEXT = 4000
|
||
const MAX_ITEMS = 40
|
||
const HEX_COLOR = /^#[0-9a-f]{6}$/i
|
||
|
||
export const RICH_HINT = "Enter = neue Zeile, *Wort* = hervorgehoben"
|
||
|
||
// Leitet den Datentyp der Vorlage aus ihren Standardinhalten ab
|
||
export function defineTemplate<D extends TemplateData>(def: TemplateDefinition<D>) {
|
||
return def
|
||
}
|
||
|
||
export function unsplash(id: string, width = 2000) {
|
||
return `https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=${width}&q=80`
|
||
}
|
||
|
||
const FIELD_LABELS: Record<string, string> = {
|
||
name: "Name", tagline: "Untertitel", suffix: "Zusatz", role: "Rolle", eyebrow: "Überzeile", badge: "Hinweis",
|
||
title: "Überschrift", text: "Text", lead: "Einleitung", note: "Kleingedrucktes", cta: "Button", ctaSecondary: "Zweiter Button",
|
||
image: "Bild", imageSecondary: "Zweites Bild", imageThird: "Drittes Bild", src: "Bild", caption: "Bildunterschrift",
|
||
price: "Preis", period: "Zeitraum", duration: "Dauer", author: "Von", quote: "Zitat", label: "Beschriftung", value: "Wert",
|
||
time: "Uhrzeit", day: "Tag", location: "Ort", place: "Ort", year: "Jahr", phone: "Telefon", email: "E-Mail",
|
||
address: "Adresse", hours: "Öffnungszeiten", category: "Kategorie", description: "Beschreibung", meta: "Details",
|
||
features: "Merkmale (eine pro Zeile)", tasks: "Aufgaben (eine pro Zeile)", question: "Frage", answer: "Antwort",
|
||
options: "Antworten (mit | trennen)", level: "Intensität (1–3)", trainer: "Coach", company: "Firma", speaker: "Speaker",
|
||
room: "Raum", highlight: "Hervorheben (ja = hervorgehoben)", left: "Restplätze (leer = keine Anzeige)", area: "Bereich",
|
||
type: "Art", rooms: "Zimmer", rating: "Bewertung", reviews: "Bewertungen (Text)", count: "Anzahl", number: "Nummer",
|
||
open: "Öffnet (HH:MM, leer = geschlossen)", close: "Schließt (HH:MM)", deadline: "Aktionsende (JJJJ-MM-TT)",
|
||
deadlineLabel: "Text vor dem Countdown", success: "Text nach dem Absenden", slots: "Uhrzeiten (mit Komma trennen)",
|
||
roles: "Wechselnde Jobtitel (mit Komma trennen)", experience: "Erfahrungsstufen (mit Komma trennen)", bullets: "Vorteile (eine pro Zeile)",
|
||
discount: "Rabatt bei jährlicher Zahlung (%)", button: "Button", dateLabel: "Datum (Anzeige)", date: "Startzeit (JJJJ-MM-TTTHH:MM)",
|
||
before: "Beschriftung vorher", after: "Beschriftung nachher", closing: "Schlusssatz", signature: "Unterschrift", book: "Buchtitel",
|
||
factor: "Ersparnis-Faktor (0 bis 1)", min: "Minimum (€)", max: "Maximum (€)", sqm: "Wohnfläche (m²)", instagram: "Instagram",
|
||
items: "Einträge", plans: "Tarife", members: "Personen", questions: "Fragen", milestones: "Meilensteine", facts: "Kennzahlen",
|
||
}
|
||
|
||
const TEXTAREA_KEYS = new Set(["title", "text", "lead", "quote", "description", "answer", "features", "tasks", "bullets"])
|
||
const IMAGE_KEY = /^(image|src|photo|portrait)/
|
||
|
||
function humanize(key: string) {
|
||
const spaced = key.replace(/([A-Z])/g, " $1").toLowerCase()
|
||
return spaced.charAt(0).toUpperCase() + spaced.slice(1)
|
||
}
|
||
|
||
function describeField(key: string, sample: string): FieldDef {
|
||
const label = FIELD_LABELS[key] ?? humanize(key)
|
||
if (IMAGE_KEY.test(key)) return { key, label, type: "image" }
|
||
if (key === "title") return { key, label, type: "textarea", hint: RICH_HINT }
|
||
if (TEXTAREA_KEYS.has(key) || sample.includes("\n") || sample.length > 90) return { key, label, type: "textarea" }
|
||
return { key, label }
|
||
}
|
||
|
||
type SectionMeta = string | { label: string; optional?: boolean; items?: Record<string, string> }
|
||
|
||
// Editor-Beschreibung aus den Standardinhalten ableiten; nur Sektionsnamen und Listen-Bezeichnungen sind nötig
|
||
export function describeSections<D extends TemplateData>(defaults: D, meta: Record<keyof D & string, SectionMeta>): SectionDef[] {
|
||
return Object.entries(defaults).map(([key, section]) => {
|
||
const info = typeof meta[key] === "string" ? { label: meta[key] as string } : (meta[key] as Exclude<SectionMeta, string>)
|
||
const fields = Object.entries(section).map(([fieldKey, value]): FieldDef | ListFieldDef => {
|
||
if (!Array.isArray(value)) return describeField(fieldKey, value)
|
||
const shape = value[0] ?? {}
|
||
return {
|
||
key: fieldKey,
|
||
label: FIELD_LABELS[fieldKey] ?? humanize(fieldKey),
|
||
type: "list",
|
||
itemLabel: info.items?.[fieldKey] ?? "Eintrag",
|
||
fields: Object.entries(shape).map(([itemKey, sample]) => describeField(itemKey, sample)),
|
||
}
|
||
})
|
||
return { key, label: info.label, optional: info.optional, fields }
|
||
})
|
||
}
|
||
|
||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||
}
|
||
|
||
function text(value: unknown, fallback: string) {
|
||
return typeof value === "string" ? value.slice(0, MAX_TEXT) : fallback
|
||
}
|
||
|
||
function mergeItem(shape: ListItem, input: unknown): ListItem {
|
||
const source = isRecord(input) ? input : {}
|
||
return Object.fromEntries(Object.keys(shape).map((key) => [key, text(source[key], "")]))
|
||
}
|
||
|
||
function mergeSection(defaults: SectionData, input: unknown): SectionData {
|
||
const source = isRecord(input) ? input : {}
|
||
const result: SectionData = {}
|
||
|
||
for (const [key, fallback] of Object.entries(defaults)) {
|
||
const value = source[key]
|
||
if (Array.isArray(fallback)) {
|
||
const shape = fallback[0] ?? {}
|
||
result[key] = Array.isArray(value) ? value.slice(0, MAX_ITEMS).map((item) => mergeItem(shape, item)) : fallback
|
||
} else {
|
||
result[key] = text(value, fallback)
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
// Gespeicherte Inhalte immer gegen die aktuelle Vorlage auflösen: neue Felder bekommen ihren
|
||
// Standardwert, entfernte oder fremde Felder fallen weg. So überstehen Varianten Änderungen an der Vorlage.
|
||
export function resolveContent<D extends TemplateData>(def: TemplateDefinition<D>, raw: unknown): TemplateContent<D> {
|
||
const source = isRecord(raw) ? raw : {}
|
||
const data = isRecord(source.data) ? source.data : {}
|
||
const optional = new Set(def.sections.filter((section) => section.optional).map((section) => section.key))
|
||
const hidden = Array.isArray(source.hidden) ? source.hidden.filter((key): key is string => typeof key === "string" && optional.has(key)) : []
|
||
const ai = isRecord(source.ai) ? source.ai : {}
|
||
|
||
return {
|
||
palette: def.palettes.find((palette) => palette.key === source.palette)?.key ?? def.palettes[0].key,
|
||
fonts: def.fonts.find((pair) => pair.key === source.fonts)?.key ?? def.fonts[0].key,
|
||
accent: typeof source.accent === "string" && HEX_COLOR.test(source.accent) ? source.accent : "",
|
||
hidden: [...new Set(hidden)],
|
||
ai: {
|
||
chat: ai.chat === true,
|
||
phone: ai.phone === true,
|
||
assistant: ai.assistant === true,
|
||
content: ai.content === true,
|
||
name: text(ai.name, "").slice(0, 60),
|
||
greeting: text(ai.greeting, "").slice(0, 400),
|
||
knowledge: typeof ai.knowledge === "string" ? ai.knowledge.slice(0, 8000) : "",
|
||
} satisfies AiSettings,
|
||
data: Object.fromEntries(
|
||
Object.entries(def.defaults).map(([key, section]) => [key, mergeSection(section, data[key])])
|
||
) as D,
|
||
}
|
||
}
|
||
|
||
// Schwarz oder Weiß, je nachdem was auf der Akzentfarbe besser lesbar ist (WCAG-Luminanz)
|
||
function readableOn(hex: string) {
|
||
const [r, g, b] = [1, 3, 5].map((i) => {
|
||
const channel = parseInt(hex.slice(i, i + 2), 16) / 255
|
||
return channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
|
||
})
|
||
return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.179 ? "#111111" : "#ffffff"
|
||
}
|
||
|
||
export function accentColors(def: TemplateDefinition, content: TemplateContent) {
|
||
const palette = def.palettes.find((p) => p.key === content.palette) ?? def.palettes[0]
|
||
const accent = content.accent || palette.accent
|
||
return { accent, accentFg: readableOn(accent) }
|
||
}
|
||
|
||
export function themeStyle(def: TemplateDefinition, content: TemplateContent): CSSProperties {
|
||
const palette = def.palettes.find((p) => p.key === content.palette) ?? def.palettes[0]
|
||
const accent = content.accent || palette.accent
|
||
|
||
return {
|
||
"--t-bg": palette.bg,
|
||
"--t-fg": palette.fg,
|
||
"--t-muted": palette.muted,
|
||
"--t-surface": palette.surface,
|
||
"--t-accent": accent,
|
||
"--t-accent-fg": readableOn(accent),
|
||
colorScheme: palette.dark ? "dark" : "light",
|
||
} as CSSProperties
|
||
}
|
||
|
||
export function brandName(content: TemplateContent) {
|
||
const name = content.data.brand?.name
|
||
return typeof name === "string" && name.trim() ? name.trim() : null
|
||
}
|