mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 19:51:17 +00:00
216 lines
13 KiB
TypeScript
216 lines
13 KiB
TypeScript
import type { AiSettings, Block, BlockDefinition, ColorOverrides, FieldDef, ListFieldDef, ListItem, SectionData, SectionDef, TemplateContent, TemplateData, TemplateDefinition, TemplateStyle } from "./types"
|
||
|
||
const MAX_TEXT = 4000
|
||
const MAX_ITEMS = 40
|
||
const MAX_BLOCKS = 40
|
||
const HEX_COLOR = /^#[0-9a-f]{6}$/i
|
||
const LIBRARY_KEY = /^[a-z0-9]+:[a-z0-9]+$/
|
||
const BLOCK_ID = /^[a-z0-9-]{1,40}$/i
|
||
export const IMAGE_STYLES: TemplateStyle["images"][] = ["original", "mono", "warm", "soft", "contrast"]
|
||
|
||
function libraryKey(value: unknown, own: { key: string }[]) {
|
||
if (typeof value !== "string") return null
|
||
return own.some((item) => item.key === value) || LIBRARY_KEY.test(value) ? value : null
|
||
}
|
||
|
||
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",
|
||
km: "Kilometerstand", power: "Leistung", gearbox: "Getriebe", payload: "Nutzlast / zGG", condition: "Zustand", rate: "Zinssatz (% p. a.)",
|
||
color: "Farbe (#rrggbb)", x: "Position waagerecht (%)", y: "Position senkrecht (%)", grade: "Pflegegrad", amount: "Betrag (€)",
|
||
emergency: "Hinweis in der Notruf-Leiste", areas: "Einzugsgebiet (PLZ-Anfänge, mit Komma)", strip: "Bilder", variants: "Farbvarianten",
|
||
sticker: "Sticker", stickerSecondary: "Zweiter Sticker", status: "Status (z. B. fast weg, ausverkauft)", handle: "Social-Handle",
|
||
offer: "Hinweisleiste", oldPrice: "Alter Preis", breakdown: "Verteilung 5 bis 1 Sterne in % (mit Komma)", box: "Lieferumfang (eine pro Zeile)",
|
||
city: "Stadt", distance: "Entfernung", score: "Bewertungsnote", guests: "Gäste", size: "Größe", subtitle: "Unterzeile", avatar: "Profilbild",
|
||
bio: "Kurzbeschreibung", url: "Link", tag: "Kennzeichnung", tags: "Merkmale (z. B. vegan, scharf, glutenfrei)", allergens: "Allergene",
|
||
table: "Tischnummer", wifi: "WLAN-Hinweis", short: "Kürzel (Wappen)", founded: "Gründungsjahr", away: "Gastmannschaft", awayShort: "Kürzel Gast",
|
||
venue: "Spielort", league: "Liga / Spieltag", home: "Heim", team: "Verein", games: "Spiele", points: "Punkte", group: "Gruppe",
|
||
training: "Training",
|
||
logo: "Logo (Bild-Link; leer = Name als Text)", link: "Externer Link (z. B. mobile.de)", linkLabel: "Text für den Link",
|
||
values: "Werte (mit Komma trennen)", steps: "Schritte (mit Komma trennen)",
|
||
menu: "Menüpunkt (leer = nicht im Menü)", href: "Ziel des Buttons (#abschnitt, mailto:… oder https://…)",
|
||
hrefSecondary: "Ziel des zweiten Buttons", keywords: "Schlagworte (mit Komma trennen)", word: "Schriftzug (leer = Name)",
|
||
edition: "Kopfzeile", topics: "Themen zur Auswahl (mit Komma trennen)", secondary: "Zweite Zeile (leer = keine)",
|
||
ribbon: "Hervorhebung (z. B. Beliebt; leer = keine)",
|
||
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|avatar|logo)/
|
||
|
||
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> }
|
||
|
||
// Felder einer Sektion aus ihren Standardinhalten; `items` benennt die Einträge von Listen
|
||
function describeFields(section: SectionData, items?: Record<string, string>): (FieldDef | ListFieldDef)[] {
|
||
return 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: items?.[fieldKey] ?? "Eintrag",
|
||
fields: Object.entries(shape).map(([itemKey, sample]) => describeField(itemKey, sample)),
|
||
}
|
||
})
|
||
}
|
||
|
||
// 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>)
|
||
return { key, label: info.label, optional: info.optional, fields: describeFields(section, info.items) }
|
||
})
|
||
}
|
||
|
||
// Baustein für den Baukasten; Felder werden wie bei describeSections aus den Standardinhalten abgeleitet.
|
||
// Listen brauchen mindestens einen Eintrag, er gibt die Felder neuer Einträge vor.
|
||
export function defineBlock<D extends SectionData>(block: Omit<BlockDefinition<D>, "fields"> & { items?: Record<string, string> }) {
|
||
const { items, ...rest } = block
|
||
return { ...rest, fields: describeFields(block.defaults, items) } satisfies BlockDefinition<D>
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// Bausteine gegen die Bibliothek auflösen: unbekannte Typen fallen weg, Inhalte wie bei Sektionen.
|
||
// Ohne gespeicherte Liste (neue Variante) gilt die Startaufstellung der Vorlage.
|
||
function resolveBlocks(builder: NonNullable<TemplateDefinition["builder"]>, raw: unknown): Block[] {
|
||
const library = new Map(builder.library.map((block) => [block.type, block]))
|
||
const source: unknown[] = Array.isArray(raw) ? raw : builder.seed.map((seed, i) => ({ id: `b${i + 1}`, ...seed }))
|
||
const used = new Set<string>()
|
||
const blocks: Block[] = []
|
||
|
||
for (const item of source.slice(0, MAX_BLOCKS)) {
|
||
if (!isRecord(item)) continue
|
||
const block = typeof item.type === "string" ? library.get(item.type) : undefined
|
||
if (!block) continue
|
||
|
||
let id = typeof item.id === "string" && BLOCK_ID.test(item.id) && !used.has(item.id) ? item.id : ""
|
||
for (let n = blocks.length + 1; !id; n++) {
|
||
if (!used.has(`b${n}`)) id = `b${n}`
|
||
}
|
||
used.add(id)
|
||
blocks.push({ id, type: block.type, hidden: item.hidden === true, data: mergeSection(block.defaults, item.data) })
|
||
}
|
||
|
||
return blocks
|
||
}
|
||
|
||
// 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 : {}
|
||
const colors = isRecord(source.colors) ? source.colors : {}
|
||
const style = isRecord(source.style) ? source.style : {}
|
||
const optionalKeys = def.sections.filter((section) => section.optional).map((section) => section.key)
|
||
const savedOrder = Array.isArray(source.order) ? source.order.filter((key): key is string => typeof key === "string" && optional.has(key)) : []
|
||
const hex = (value: unknown) => (typeof value === "string" && HEX_COLOR.test(value) ? value : "")
|
||
|
||
return {
|
||
// Eigene Schlüssel der Vorlage oder "vorlage:schlüssel" aus der Bibliothek (aufgelöst in lib/theme.ts)
|
||
palette: libraryKey(source.palette, def.palettes) ?? def.palettes[0].key,
|
||
fonts: libraryKey(source.fonts, def.fonts) ?? def.fonts[0].key,
|
||
colors: {
|
||
bg: hex(colors.bg),
|
||
fg: hex(colors.fg),
|
||
muted: hex(colors.muted),
|
||
surface: hex(colors.surface),
|
||
// Früher gab es nur `accent` auf oberster Ebene
|
||
accent: hex(colors.accent) || hex(source.accent),
|
||
} satisfies ColorOverrides,
|
||
hidden: [...new Set(hidden)],
|
||
order: [...new Set([...savedOrder, ...optionalKeys])],
|
||
style: {
|
||
images: IMAGE_STYLES.includes(style.images as TemplateStyle["images"]) ? (style.images as TemplateStyle["images"]) : "original",
|
||
motion: style.motion === "subtle" ? "subtle" : "full",
|
||
} satisfies TemplateStyle,
|
||
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,
|
||
blocks: def.builder ? resolveBlocks(def.builder, source.blocks) : [],
|
||
}
|
||
}
|
||
|
||
export function brandName(content: TemplateContent) {
|
||
const name = content.data.brand?.name
|
||
return typeof name === "string" && name.trim() ? name.trim() : null
|
||
}
|