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

184 lines
8.3 KiB
TypeScript

"use client"
import Link from "next/link"
import { useEffect, useEffectEvent, useState, useTransition } from "react"
import { ArrowLeftIcon, ExternalLinkIcon } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { saveVariant } from "../../actions/variants"
import type { ShareState } from "../../lib/preview"
import { getTemplate } from "../../lib/registry"
import type { ListItem, TemplateContent } from "../../lib/types"
import { AiPanel } from "../components/editor/ai-panel"
import { BlocksPanel } from "../components/editor/blocks-panel"
import { ContentPanel } from "../components/editor/content-panel"
import { DesignPanel } from "../components/editor/design-panel"
import { PreviewFrame } from "../components/editor/preview-frame"
import { SharePanel } from "../components/editor/share-panel"
export type EditorVariant = {
id: string
name: string
templateKey: string
clientId: string | null
content: TemplateContent
shareToken: string | null
shareExpiresAt: string | null
shareState: ShareState
}
const tabs = [
{ key: "content", label: "Inhalte" },
{ key: "design", label: "Design" },
{ key: "ai", label: "KI" },
{ key: "share", label: "Teilen" },
] as const
type Tab = (typeof tabs)[number]["key"]
function snapshot(name: string, clientId: string | null, content: TemplateContent) {
return JSON.stringify({ name, clientId, content })
}
export function VariantEditor({ variant, clients }: { variant: EditorVariant; clients: { id: string; name: string }[] }) {
const def = getTemplate(variant.templateKey)
const [name, setName] = useState(variant.name)
const [clientId, setClientId] = useState(variant.clientId)
const [content, setContent] = useState(variant.content)
const [saved, setSaved] = useState(() => snapshot(variant.name, variant.clientId, variant.content))
const [tab, setTab] = useState<Tab>("content")
// Baukasten: Baustein, zu dem die Vorschau springt (n zählt hoch, damit erneutes Aufklappen wieder springt)
const [focus, setFocus] = useState<{ id: string; n: number } | null>(null)
const [error, setError] = useState<string | null>(null)
const [pending, startTransition] = useTransition()
const current = snapshot(name, clientId, content)
const dirty = current !== saved
function save() {
startTransition(async () => {
const result = await saveVariant(variant.id, { name, clientId, content })
if ("error" in result) {
setError(result.error)
return
}
setError(null)
setSaved(current)
})
}
const onShortcut = useEffectEvent((event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") {
event.preventDefault()
if (dirty && !pending) save()
}
})
useEffect(() => {
window.addEventListener("keydown", onShortcut)
return () => window.removeEventListener("keydown", onShortcut)
}, [])
useEffect(() => {
if (!dirty) return
const warn = (event: BeforeUnloadEvent) => event.preventDefault()
window.addEventListener("beforeunload", warn)
return () => window.removeEventListener("beforeunload", warn)
}, [dirty])
if (!def) return null
function updateField(section: string, field: string, value: string | ListItem[]) {
setContent((c) => ({ ...c, data: { ...c.data, [section]: { ...c.data[section], [field]: value } } }))
}
return (
<div className="flex flex-col gap-4 lg:h-[calc(100dvh-4rem)]">
<div className="flex flex-wrap items-center gap-3">
<Link
href="/admin/templates"
aria-label="Zurück zu den Vorlagen"
className={buttonVariants({ variant: "ghost", size: "icon-sm" })}
onClick={(event) => {
if (dirty && !window.confirm("Ungespeicherte Änderungen verwerfen?")) event.preventDefault()
}}
>
<ArrowLeftIcon />
</Link>
<div className="min-w-0 flex-1">
<input
value={name}
onChange={(event) => setName(event.target.value)}
aria-label="Name der Variante"
className="w-full truncate rounded-md bg-transparent text-xl font-semibold outline-none focus-visible:ring-2 focus-visible:ring-ring/50 md:text-2xl"
/>
<p className="text-xs text-muted-foreground">Vorlage „{def.name}“ · {def.industry}</p>
</div>
<span className={cn("text-xs", dirty ? "text-signal-warning" : "text-muted-foreground")}>
{pending ? "Speichert …" : dirty ? "Ungespeicherte Änderungen" : "Gespeichert"}
</span>
<a
href={`/preview/${def.key}?variant=${variant.id}`}
target="_blank"
rel="noreferrer"
title="Zeigt den gespeicherten Stand"
className={buttonVariants({ variant: "outline", size: "sm" })}
>
<ExternalLinkIcon /> Vollbild
</a>
<Button size="sm" onClick={save} disabled={!dirty || pending}>
Speichern
</Button>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="grid min-h-0 flex-1 gap-4 lg:grid-cols-[minmax(320px,400px)_minmax(0,1fr)]">
<aside className="flex min-h-0 flex-col overflow-hidden rounded-xl border bg-card">
<div role="tablist" className="flex gap-1 border-b p-1.5">
{tabs.map((item) => (
<button
key={item.key}
type="button"
role="tab"
aria-selected={tab === item.key}
onClick={() => setTab(item.key)}
className={cn(
"flex-1 cursor-pointer rounded-md px-3 py-1.5 text-sm transition-colors",
tab === item.key ? "bg-surface font-medium" : "text-muted-foreground hover:text-foreground"
)}
>
{item.label}
</button>
))}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
{tab === "content" && (
<>
<ContentPanel def={def} data={content.data} hidden={content.hidden} onChange={updateField} />
{def.builder && (
<BlocksPanel
library={def.builder.library}
blocks={content.blocks}
onChange={(blocks) => setContent((c) => ({ ...c, blocks }))}
onFocus={(id) => setFocus((current) => ({ id, n: (current?.n ?? 0) + 1 }))}
/>
)}
</>
)}
{tab === "design" && (
<DesignPanel def={def} content={content} onChange={(patch) => setContent((c) => ({ ...c, ...patch }))} />
)}
{tab === "ai" && (
<AiPanel variantId={variant.id} ai={content.ai} onChange={(patch) => setContent((c) => ({ ...c, ai: { ...c.ai, ...patch } }))} />
)}
{tab === "share" && (
<SharePanel variant={variant} clients={clients} clientId={clientId} onClientChange={setClientId} dirty={dirty} />
)}
</div>
</aside>
<PreviewFrame templateKey={def.key} variantId={variant.id} content={content} focus={focus} />
</div>
</div>
)
}