since3-portal/modules/templates/ui/components/editor/preview-frame.tsx
2026-10-05 08:23:02 +02:00

95 lines
4.4 KiB
TypeScript

"use client"
import { useEffect, useEffectEvent, useRef, useState } from "react"
import { MonitorIcon, SmartphoneIcon, TabletIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { DEVICES, PREVIEW_CONTENT, PREVIEW_READY, type Device } from "../../../lib/preview"
import type { TemplateContent } from "../../../lib/types"
const icons = { desktop: MonitorIcon, tablet: TabletIcon, mobile: SmartphoneIcon }
// Die Vorlage läuft in echter Gerätebreite im iframe (damit Media Queries greifen) und wird
// passend herunterskaliert. Änderungen gehen per postMessage hinein, ohne Neuladen.
export function PreviewFrame({ templateKey, variantId, content }: { templateKey: string; variantId: string; content: TemplateContent }) {
const frameRef = useRef<HTMLIFrameElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [size, setSize] = useState({ width: 0, height: 0 })
const [device, setDevice] = useState<Device>("desktop")
const post = useEffectEvent(() => {
frameRef.current?.contentWindow?.postMessage({ type: PREVIEW_CONTENT, content }, window.location.origin)
})
useEffect(() => {
post()
}, [content])
useEffect(() => {
function onMessage(event: MessageEvent) {
if (event.origin === window.location.origin && event.source === frameRef.current?.contentWindow && event.data?.type === PREVIEW_READY) {
post()
}
}
window.addEventListener("message", onMessage)
return () => window.removeEventListener("message", onMessage)
}, [])
useEffect(() => {
const container = containerRef.current
if (!container) return
const observer = new ResizeObserver(([entry]) => setSize({ width: entry.contentRect.width, height: entry.contentRect.height }))
observer.observe(container)
return () => observer.disconnect()
}, [])
const deviceWidth = DEVICES[device].width
const inset = device === "desktop" ? 0 : 16
const scale = size.width ? Math.min(1, (size.width - inset * 2) / deviceWidth) : 1
return (
<section className="flex min-h-[75vh] flex-col overflow-hidden rounded-xl border bg-surface lg:min-h-0">
<div className="flex items-center justify-between gap-2 border-b bg-card px-2 py-1.5">
<div className="flex gap-1">
{(Object.keys(DEVICES) as Device[]).map((key) => {
const Icon = icons[key]
return (
<button
key={key}
type="button"
aria-pressed={device === key}
onClick={() => setDevice(key)}
className={cn(
"flex cursor-pointer items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs transition-colors",
device === key ? "bg-surface font-medium" : "text-muted-foreground hover:text-foreground"
)}
>
<Icon className="size-3.5" />
{DEVICES[key].label}
</button>
)
})}
</div>
<span className="font-mono text-[11px] text-muted-foreground">
{deviceWidth}px · {Math.round(scale * 100)}%
</span>
</div>
<div ref={containerRef} className="relative min-h-0 flex-1 overflow-hidden">
{size.width > 0 && (
<iframe
ref={frameRef}
src={`/preview/${templateKey}?live=1&variant=${variantId}`}
title="Vorschau der Variante"
className={cn("absolute origin-top-left border-0 bg-white", inset > 0 && "rounded-2xl shadow-xl ring-1 ring-black/10")}
style={{
width: deviceWidth,
height: (size.height - inset * 2) / scale,
top: inset,
left: Math.max(0, (size.width - deviceWidth * scale) / 2),
transform: `scale(${scale})`,
}}
/>
)}
</div>
</section>
)
}