since3-portal/modules/templates/lib/content.ts
2026-10-05 08:23:02 +02:00

168 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}