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

159 lines
7.8 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, 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 [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
})
}
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="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>
)
}