since3-portal/modules/templates/ui/components/editor/share-panel.tsx
2026-10-10 02:38:50 +02:00

194 lines
9.4 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.

"use client"
import { useRouter } from "next/navigation"
import { useState, useSyncExternalStore, useTransition } from "react"
import { CopyPlusIcon, DownloadIcon, ExternalLinkIcon, RefreshCwIcon, Trash2Icon } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button"
import { CopyField } from "@/modules/admin/ui/components/copy-field"
import { deleteVariant, duplicateVariant, updateShare } from "../../../actions/variants"
import { dateFormat, SHARE_EXPIRY_OPTIONS } from "../../../lib/preview"
import type { EditorVariant } from "../../views/variant-editor"
const selectClass = "h-10 w-full rounded-md border bg-background px-3 text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
const subscribe = () => () => {}
function Heading({ title, text }: { title: string; text?: string }) {
return (
<div className="mb-3">
<h3 className="text-sm font-medium">{title}</h3>
{text && <p className="mt-0.5 text-xs text-muted-foreground">{text}</p>}
</div>
)
}
export function SharePanel({ variant, clients, clientId, onClientChange, dirty }: {
variant: EditorVariant
clients: { id: string; name: string }[]
clientId: string | null
onClientChange: (clientId: string | null) => void
dirty: boolean
}) {
const router = useRouter()
const origin = useSyncExternalStore(subscribe, () => window.location.origin, () => "")
const [share, setShare] = useState({ token: variant.shareToken, expiresAt: variant.shareExpiresAt, expired: variant.shareState === "expired" })
// -1 = bestehendes Ablaufdatum beibehalten
const [expiry, setExpiry] = useState<number>(variant.shareExpiresAt ? -1 : 0)
const [confirmDelete, setConfirmDelete] = useState(false)
const [exporting, setExporting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [pending, startTransition] = useTransition()
function run(action: () => Promise<{ error: string } | unknown>) {
startTransition(async () => {
const result = await action()
if (result && typeof result === "object" && "error" in result) setError(String(result.error))
else setError(null)
})
}
function applyShare(enabled: boolean, options: { regenerate?: boolean; days?: number } = {}) {
run(async () => {
const days = options.days ?? (expiry < 0 ? 0 : expiry)
const result = await updateShare(variant.id, { enabled, expiresInDays: days, regenerate: options.regenerate })
if (!("error" in result)) {
setShare({ ...result, expired: false })
setExpiry(result.expiresAt ? days : 0)
}
return result
})
}
async function exportProject() {
setExporting(true)
try {
const response = await fetch(`/api/templates/${variant.id}/export`)
if (!response.ok) {
const body = await response.json().catch(() => null)
setError(body?.error ?? "Export fehlgeschlagen.")
return
}
const fileName = /filename="([^"]+)"/.exec(response.headers.get("Content-Disposition") ?? "")?.[1] ?? "website.zip"
const link = document.createElement("a")
link.href = URL.createObjectURL(await response.blob())
link.download = fileName
link.click()
URL.revokeObjectURL(link.href)
setError(null)
} catch {
setError("Export fehlgeschlagen.")
} finally {
setExporting(false)
}
}
const url = share.token ? `${origin}/demo/${share.token}` : ""
return (
<div className="flex flex-col gap-8">
<section>
<Heading title="Client" text="Optional – ordnet die Variante einem Kunden zu. Wird mit „Speichern“ übernommen." />
<select
value={clientId ?? ""}
onChange={(event) => onClientChange(event.target.value || null)}
className={selectClass}
aria-label="Client"
>
<option value="">Kein Client (Pitch)</option>
{clients.map((client) => (
<option key={client.id} value={client.id}>{client.name}</option>
))}
</select>
</section>
<section>
<Heading title="Link teilen" text="Kunden öffnen den Link ohne Login. Er zeigt immer den zuletzt gespeicherten Stand." />
{share.token ? (
<div className="flex flex-col gap-3">
{share.expired && (
<p className="rounded-md bg-signal-warning/12 px-3 py-2 text-xs text-signal-warning">
Dieser Link ist abgelaufen. Wähle eine neue Gültigkeit, um ihn wieder zu aktivieren.
</p>
)}
<CopyField label="Öffentlicher Link" value={url} />
<label className="flex flex-col gap-1.5 text-sm">
Gültigkeit
<select
value={expiry}
disabled={pending}
onChange={(event) => applyShare(true, { days: Number(event.target.value) })}
className={selectClass}
>
{expiry < 0 && share.expiresAt && (
<option value={-1}>bis {dateFormat.format(new Date(share.expiresAt))}</option>
)}
{SHARE_EXPIRY_OPTIONS.map((option) => (
<option key={option.days} value={option.days}>{option.label}</option>
))}
</select>
</label>
<div className="flex flex-wrap gap-2">
<a href={url} target="_blank" rel="noreferrer" className={buttonVariants({ variant: "outline", size: "sm" })}>
<ExternalLinkIcon /> Öffnen
</a>
<Button variant="outline" size="sm" disabled={pending} onClick={() => applyShare(true, { regenerate: true })}>
<RefreshCwIcon /> Neuer Link
</Button>
<Button variant="ghost" size="sm" disabled={pending} onClick={() => applyShare(false)}>
Freigabe beenden
</Button>
</div>
<p className="text-xs text-muted-foreground">„Neuer Link“ macht den bisherigen Link ungültig.</p>
</div>
) : (
<Button disabled={pending} onClick={() => applyShare(true)}>Link erstellen</Button>
)}
</section>
<section>
<Heading
title="Als Next.js-Projekt exportieren"
text="Eigenständiges Projekt (ZIP) mit Vorlage, Inhalten und eigenen Bildern – ohne Entwurfs-Hinweis und KI-Funktionen. Die README listet, was vor dem Livegang noch fehlt."
/>
<Button variant="outline" className="w-full" disabled={exporting} onClick={exportProject}>
<DownloadIcon /> {exporting ? "Wird erstellt …" : "ZIP herunterladen"}
</Button>
{dirty && <p className="mt-2 text-xs text-muted-foreground">Exportiert wird der gespeicherte Stand.</p>}
</section>
<section>
<Heading title="Variante" />
<div className="flex flex-col gap-2">
<Button variant="outline" disabled={pending} onClick={() => run(() => duplicateVariant(variant.id))}>
<CopyPlusIcon /> Duplizieren
</Button>
{dirty && <p className="text-xs text-muted-foreground">Dupliziert wird der gespeicherte Stand.</p>}
{confirmDelete ? (
<div className="flex gap-2">
<Button
variant="destructive"
className="flex-1"
disabled={pending}
onClick={() =>
run(async () => {
const result = await deleteVariant(variant.id)
if ("success" in result) router.push("/admin/templates")
return result
})
}
>
Endgültig löschen
</Button>
<Button variant="outline" onClick={() => setConfirmDelete(false)}>Abbrechen</Button>
</div>
) : (
<Button variant="ghost" className="text-destructive" onClick={() => setConfirmDelete(true)}>
<Trash2Icon /> Löschen
</Button>
)}
</div>
</section>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
)
}