feat: add webhook body template and headers support for channels

This commit is contained in:
2026-03-16 01:10:26 +01:00
parent db461db270
commit e76167134b
12 changed files with 366 additions and 23 deletions

View File

@@ -23,6 +23,26 @@ import type {
RecyclePolicy,
} from "@/lib/types";
// ---------------------------------------------------------------------------
// Webhook preset templates (frontend-only, zero backend changes needed)
// ---------------------------------------------------------------------------
const WEBHOOK_PRESETS = {
discord: `{
"embeds": [{
"title": "📺 {{event}}",
"description": "{{#if data.item.title}}Now playing: **{{data.item.title}}**{{else}}No signal{{/if}}",
"color": 3447003,
"timestamp": "{{timestamp}}"
}]
}`,
slack: `{
"text": "📺 *{{event}}*{{#if data.item.title}} — {{data.item.title}}{{/if}}"
}`,
} as const;
type WebhookFormat = "default" | "discord" | "slack" | "custom";
// ---------------------------------------------------------------------------
// Zod schemas
// ---------------------------------------------------------------------------
@@ -758,6 +778,8 @@ interface EditChannelSheetProps {
logo_opacity?: number;
webhook_url?: string | null;
webhook_poll_interval_secs?: number;
webhook_body_template?: string | null;
webhook_headers?: string | null;
},
) => void;
isPending: boolean;
@@ -791,6 +813,9 @@ export function EditChannelSheet({
const [logoOpacity, setLogoOpacity] = useState(100);
const [webhookUrl, setWebhookUrl] = useState("");
const [webhookPollInterval, setWebhookPollInterval] = useState<number | "">(5);
const [webhookFormat, setWebhookFormat] = useState<WebhookFormat>("default");
const [webhookBodyTemplate, setWebhookBodyTemplate] = useState("");
const [webhookHeaders, setWebhookHeaders] = useState("");
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -810,6 +835,18 @@ export function EditChannelSheet({
setLogoOpacity(Math.round((channel.logo_opacity ?? 1) * 100));
setWebhookUrl(channel.webhook_url ?? "");
setWebhookPollInterval(channel.webhook_poll_interval_secs ?? 5);
const tmpl = channel.webhook_body_template ?? "";
setWebhookBodyTemplate(tmpl);
setWebhookHeaders(channel.webhook_headers ?? "");
if (!tmpl) {
setWebhookFormat("default");
} else if (tmpl === WEBHOOK_PRESETS.discord) {
setWebhookFormat("discord");
} else if (tmpl === WEBHOOK_PRESETS.slack) {
setWebhookFormat("slack");
} else {
setWebhookFormat("custom");
}
setSelectedBlockId(null);
setFieldErrors({});
}
@@ -846,6 +883,8 @@ export function EditChannelSheet({
...(webhookUrl
? { webhook_poll_interval_secs: webhookPollInterval === "" ? 5 : webhookPollInterval }
: {}),
webhook_body_template: webhookBodyTemplate || null,
webhook_headers: webhookHeaders || null,
});
};
@@ -1107,14 +1146,72 @@ export function EditChannelSheet({
/>
</Field>
{webhookUrl && (
<Field label="Poll interval (seconds)" hint="How often to check for broadcast changes">
<NumberInput
value={webhookPollInterval}
onChange={setWebhookPollInterval}
min={1}
placeholder="5"
/>
</Field>
<>
<Field label="Poll interval (seconds)" hint="How often to check for broadcast changes">
<NumberInput
value={webhookPollInterval}
onChange={setWebhookPollInterval}
min={1}
placeholder="5"
/>
</Field>
<div className="space-y-2">
<p className="text-xs text-zinc-400">Payload format</p>
<div className="flex gap-1.5 flex-wrap">
{(["default", "discord", "slack", "custom"] as WebhookFormat[]).map((fmt) => (
<button
key={fmt}
type="button"
onClick={() => {
setWebhookFormat(fmt);
if (fmt === "discord") setWebhookBodyTemplate(WEBHOOK_PRESETS.discord);
else if (fmt === "slack") setWebhookBodyTemplate(WEBHOOK_PRESETS.slack);
else if (fmt === "default") setWebhookBodyTemplate("");
}}
className={`rounded px-2.5 py-1 text-xs font-medium capitalize transition-colors ${
webhookFormat === fmt
? "bg-zinc-600 text-zinc-100"
: "bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
}`}
>
{fmt === "default" ? "K-TV default" : fmt}
</button>
))}
</div>
</div>
{webhookFormat !== "default" && (
<Field
label="Body template (Handlebars)"
hint="Context: event, timestamp, channel_id, data.item.title, data.item.duration_secs, …"
>
<textarea
rows={6}
value={webhookBodyTemplate}
onChange={(e) => {
setWebhookBodyTemplate(e.target.value);
setWebhookFormat("custom");
}}
placeholder={'{\n "text": "Now playing: {{data.item.title}}"\n}'}
className="w-full resize-y rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 font-mono text-xs text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none"
/>
</Field>
)}
<Field
label="Extra headers (JSON)"
hint={'e.g. {"Authorization": "Bearer token"}'}
>
<textarea
rows={2}
value={webhookHeaders}
onChange={(e) => setWebhookHeaders(e.target.value)}
placeholder={'{"Authorization": "Bearer xxx"}'}
className="w-full resize-none rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 font-mono text-xs text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none"
/>
</Field>
</>
)}
</section>
</div>