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