refactor(frontend): extract logic to hooks, split inline components

Area 1 (tv/page.tsx 964→423 lines):
- hooks: use-fullscreen, use-idle, use-volume, use-quality, use-subtitles,
  use-channel-input, use-channel-passwords, use-tv-keyboard
- components: SubtitlePicker, VolumeControl, QualityPicker, TopControlBar,
  LogoWatermark, AutoplayPrompt, ChannelNumberOverlay, TvBaseLayer

Area 2 (edit-channel-sheet.tsx 1244→678 lines):
- hooks: use-channel-form (all form state + reset logic)
- lib/schemas.ts: extracted Zod schemas + extractErrors
- components: AlgorithmicFilterEditor, RecyclePolicyEditor, WebhookEditor,
  AccessSettingsEditor, LogoEditor

Area 3 (dashboard/page.tsx 406→261 lines):
- hooks: use-channel-order, use-import-channel, use-regenerate-all
- lib/channel-export.ts: pure export utility
- components: DashboardHeader
This commit is contained in:
2026-03-17 02:25:02 +01:00
parent ce92b43205
commit 8ed8da2d90
32 changed files with 2629 additions and 1689 deletions

View File

@@ -0,0 +1,158 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { minsToTime } from "@/app/(main)/dashboard/components/block-timeline";
import type {
AccessMode,
ChannelResponse,
LogoPosition,
ProgrammingBlock,
MediaFilter,
RecyclePolicy,
} from "@/lib/types";
export 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;
export type WebhookFormat = "default" | "discord" | "slack" | "custom";
export function defaultFilter(): MediaFilter {
return {
content_type: null,
genres: [],
decade: null,
tags: [],
min_duration_secs: null,
max_duration_secs: null,
collections: [],
series_names: [],
search_term: null,
};
}
export function defaultBlock(startMins = 20 * 60, durationMins = 60): ProgrammingBlock {
return {
id: crypto.randomUUID(),
name: "",
start_time: minsToTime(startMins),
duration_mins: durationMins,
content: { type: "algorithmic", filter: defaultFilter(), strategy: "random" },
loop_on_finish: true,
ignore_recycle_policy: false,
access_mode: "public",
};
}
export function useChannelForm(channel: ChannelResponse | null) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [timezone, setTimezone] = useState("UTC");
const [blocks, setBlocks] = useState<ProgrammingBlock[]>([]);
const [recyclePolicy, setRecyclePolicy] = useState<RecyclePolicy>({
cooldown_days: null,
cooldown_generations: null,
min_available_ratio: 0.1,
});
const [autoSchedule, setAutoSchedule] = useState(false);
const [accessMode, setAccessMode] = useState<AccessMode>("public");
const [accessPassword, setAccessPassword] = useState("");
const [logo, setLogo] = useState<string | null>(null);
const [logoPosition, setLogoPosition] = useState<LogoPosition>("top_right");
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 fileInputRef = useRef<HTMLInputElement>(null);
// Reset form when channel changes
useEffect(() => {
if (channel) {
setName(channel.name);
setDescription(channel.description ?? "");
setTimezone(channel.timezone);
setBlocks(channel.schedule_config.blocks);
setRecyclePolicy(channel.recycle_policy);
setAutoSchedule(channel.auto_schedule);
setAccessMode(channel.access_mode ?? "public");
setAccessPassword("");
setLogo(channel.logo ?? null);
setLogoPosition(channel.logo_position ?? "top_right");
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);
}
}, [channel]);
const addBlock = (startMins = 20 * 60, durationMins = 60) => {
const block = defaultBlock(startMins, durationMins);
setBlocks((prev) => [...prev, block]);
setSelectedBlockId(block.id);
};
const updateBlock = (idx: number, block: ProgrammingBlock) =>
setBlocks((prev) => prev.map((b, i) => (i === idx ? block : b)));
const removeBlock = (idx: number) => {
setBlocks((prev) => {
const next = prev.filter((_, i) => i !== idx);
if (selectedBlockId === prev[idx].id) setSelectedBlockId(null);
return next;
});
};
return {
// Basic info
name, setName,
description, setDescription,
timezone, setTimezone,
autoSchedule, setAutoSchedule,
// Access
accessMode, setAccessMode,
accessPassword, setAccessPassword,
// Logo
logo, setLogo,
logoPosition, setLogoPosition,
logoOpacity, setLogoOpacity,
fileInputRef,
// Webhook
webhookUrl, setWebhookUrl,
webhookPollInterval, setWebhookPollInterval,
webhookFormat, setWebhookFormat,
webhookBodyTemplate, setWebhookBodyTemplate,
webhookHeaders, setWebhookHeaders,
// Blocks
blocks, setBlocks,
selectedBlockId, setSelectedBlockId,
recyclePolicy, setRecyclePolicy,
addBlock,
updateBlock,
removeBlock,
};
}