Files
k-tv/k-tv-frontend/app/(main)/dashboard/page.tsx

416 lines
13 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import { Plus, Upload, RefreshCw, Antenna, Settings2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
useChannels,
useCreateChannel,
useUpdateChannel,
useDeleteChannel,
useGenerateSchedule,
} from "@/hooks/use-channels";
import { useAuthContext } from "@/context/auth-context";
import { useConfig } from "@/hooks/use-config";
import { useRescanLibrary } from "@/hooks/use-library";
import { api } from "@/lib/api";
import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query";
import { ChannelCard } from "./components/channel-card";
import { CreateChannelDialog } from "./components/create-channel-dialog";
import { DeleteChannelDialog } from "./components/delete-channel-dialog";
import { EditChannelSheet } from "./components/edit-channel-sheet";
import { ScheduleSheet } from "./components/schedule-sheet";
import {
ImportChannelDialog,
type ChannelImportData,
} from "./components/import-channel-dialog";
import { IptvExportDialog } from "./components/iptv-export-dialog";
import { TranscodeSettingsDialog } from "./components/transcode-settings-dialog";
import type {
ChannelResponse,
ProgrammingBlock,
RecyclePolicy,
} from "@/lib/types";
export default function DashboardPage() {
const { token } = useAuthContext();
const queryClient = useQueryClient();
const { data: channels, isLoading, error } = useChannels();
const { data: config } = useConfig();
const capabilities = config?.provider_capabilities;
const createChannel = useCreateChannel();
const updateChannel = useUpdateChannel();
const deleteChannel = useDeleteChannel();
const generateSchedule = useGenerateSchedule();
const rescanLibrary = useRescanLibrary();
// Channel ordering — persisted to localStorage
const [channelOrder, setChannelOrder] = useState<string[]>([]);
useEffect(() => {
try {
const stored = localStorage.getItem("k-tv-channel-order");
if (stored) setChannelOrder(JSON.parse(stored));
} catch {}
}, []);
const saveOrder = (order: string[]) => {
setChannelOrder(order);
try {
localStorage.setItem("k-tv-channel-order", JSON.stringify(order));
} catch {}
};
// Sort channels by stored order; new channels appear at the end
const sortedChannels = channels
? [...channels].sort((a, b) => {
const ai = channelOrder.indexOf(a.id);
const bi = channelOrder.indexOf(b.id);
if (ai === -1 && bi === -1) return 0;
if (ai === -1) return 1;
if (bi === -1) return -1;
return ai - bi;
})
: [];
const handleMoveUp = (channelId: string) => {
const ids = sortedChannels.map((c) => c.id);
const idx = ids.indexOf(channelId);
if (idx <= 0) return;
const next = [...ids];
[next[idx - 1], next[idx]] = [next[idx], next[idx - 1]];
saveOrder(next);
};
const handleMoveDown = (channelId: string) => {
const ids = sortedChannels.map((c) => c.id);
const idx = ids.indexOf(channelId);
if (idx === -1 || idx >= ids.length - 1) return;
const next = [...ids];
[next[idx], next[idx + 1]] = [next[idx + 1], next[idx]];
saveOrder(next);
};
// Regenerate all channels
const [isRegeneratingAll, setIsRegeneratingAll] = useState(false);
const handleRegenerateAll = async () => {
if (!token || !channels || channels.length === 0) return;
setIsRegeneratingAll(true);
let failed = 0;
for (const ch of channels) {
try {
await api.schedule.generate(ch.id, token);
queryClient.invalidateQueries({ queryKey: ["schedule", ch.id] });
} catch {
failed++;
}
}
setIsRegeneratingAll(false);
if (failed === 0)
toast.success(`All ${channels.length} schedules regenerated`);
else toast.error(`${failed} schedule(s) failed to generate`);
};
const [iptvOpen, setIptvOpen] = useState(false);
const [transcodeOpen, setTranscodeOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importPending, setImportPending] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const [editChannel, setEditChannel] = useState<ChannelResponse | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ChannelResponse | null>(
null,
);
const [scheduleChannel, setScheduleChannel] =
useState<ChannelResponse | null>(null);
const handleCreate = (data: {
name: string;
timezone: string;
description: string;
access_mode?: import("@/lib/types").AccessMode;
access_password?: string;
}) => {
createChannel.mutate(
{
name: data.name,
timezone: data.timezone,
description: data.description || undefined,
access_mode: data.access_mode,
access_password: data.access_password,
},
{ onSuccess: () => setCreateOpen(false) },
);
};
const handleEdit = (
id: string,
data: {
name: string;
description: string;
timezone: string;
schedule_config: { blocks: ProgrammingBlock[] };
recycle_policy: RecyclePolicy;
auto_schedule: boolean;
access_mode?: import("@/lib/types").AccessMode;
access_password?: string;
logo?: string | null;
logo_position?: import("@/lib/types").LogoPosition;
logo_opacity?: number;
webhook_url?: string | null;
webhook_poll_interval_secs?: number;
webhook_body_template?: string | null;
webhook_headers?: string | null;
},
) => {
updateChannel.mutate(
{ id, data },
{ onSuccess: () => setEditChannel(null) },
);
};
const handleImport = async (data: ChannelImportData) => {
if (!token) return;
setImportPending(true);
setImportError(null);
try {
const created = await api.channels.create(
{
name: data.name,
timezone: data.timezone,
description: data.description,
},
token,
);
await api.channels.update(
created.id,
{
schedule_config: { blocks: data.blocks },
recycle_policy: data.recycle_policy,
},
token,
);
await queryClient.invalidateQueries({ queryKey: ["channels"] });
setImportOpen(false);
} catch (e) {
setImportError(e instanceof Error ? e.message : "Import failed");
} finally {
setImportPending(false);
}
};
const handleExport = (channel: ChannelResponse) => {
const payload = {
name: channel.name,
description: channel.description ?? undefined,
timezone: channel.timezone,
blocks: channel.schedule_config.blocks,
recycle_policy: channel.recycle_policy,
};
const blob = new Blob([JSON.stringify(payload, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${channel.name.toLowerCase().replace(/\s+/g, "-")}.json`;
a.click();
URL.revokeObjectURL(url);
};
const handleDelete = () => {
if (!deleteTarget) return;
deleteChannel.mutate(deleteTarget.id, {
onSuccess: () => setDeleteTarget(null),
});
};
return (
<div className="mx-auto w-full max-w-5xl space-y-6 px-6 py-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-zinc-100">My Channels</h1>
<p className="mt-0.5 text-sm text-zinc-500">
Build your broadcast lineup
</p>
</div>
<div className="flex gap-2">
{config?.providers?.some((p) => p.capabilities.transcode) && (
<Button
onClick={() => setTranscodeOpen(true)}
title="Transcode settings"
className="border-zinc-700 text-zinc-400 hover:text-zinc-100"
>
<Settings2 className="size-4" />
Transcode
</Button>
)}
{capabilities?.rescan && (
<Button
onClick={() =>
rescanLibrary.mutate(undefined, {
onSuccess: (d) => toast.success(`Rescan complete: ${d.items_found} files found`),
onError: () => toast.error("Rescan failed"),
})
}
disabled={rescanLibrary.isPending}
title="Rescan local files directory"
className="border-zinc-700 text-zinc-400 hover:text-zinc-100"
>
<RefreshCw className={`size-4 ${rescanLibrary.isPending ? "animate-spin" : ""}`} />
Rescan library
</Button>
)}
{channels && channels.length > 0 && (
<Button
onClick={handleRegenerateAll}
disabled={isRegeneratingAll}
title="Regenerate schedules for all channels"
className="border-zinc-700 text-zinc-400 hover:text-zinc-100"
>
<RefreshCw
className={`size-4 ${isRegeneratingAll ? "animate-spin" : ""}`}
/>
Regenerate all
</Button>
)}
<Button
onClick={() => setIptvOpen(true)}
className="border-zinc-700 text-zinc-300 hover:text-zinc-100"
>
<Antenna className="size-4" />
IPTV
</Button>
<Button
onClick={() => setImportOpen(true)}
className="border-zinc-700 text-zinc-300 hover:text-zinc-100"
>
<Upload className="size-4" />
Import
</Button>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="size-4" />
New channel
</Button>
</div>
</div>
{/* Content */}
{isLoading && (
<div className="flex items-center justify-center py-20">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-zinc-700 border-t-zinc-300" />
</div>
)}
{error && (
<div className="rounded-lg border border-red-900/50 bg-red-950/20 px-4 py-3 text-sm text-red-400">
{error.message}
</div>
)}
{!isLoading && channels && channels.length === 0 && (
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-zinc-800 py-20 text-center">
<p className="text-sm text-zinc-500">No channels yet</p>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="size-4" />
Create your first channel
</Button>
</div>
)}
{sortedChannels.length > 0 && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{sortedChannels.map((channel, idx) => (
<ChannelCard
key={channel.id}
channel={channel}
isGenerating={
generateSchedule.isPending &&
generateSchedule.variables === channel.id
}
isFirst={idx === 0}
isLast={idx === sortedChannels.length - 1}
onEdit={() => setEditChannel(channel)}
onDelete={() => setDeleteTarget(channel)}
onGenerateSchedule={() => generateSchedule.mutate(channel.id)}
onViewSchedule={() => setScheduleChannel(channel)}
onExport={() => handleExport(channel)}
onMoveUp={() => handleMoveUp(channel.id)}
onMoveDown={() => handleMoveDown(channel.id)}
/>
))}
</div>
)}
{/* Dialogs / sheets */}
<TranscodeSettingsDialog
open={transcodeOpen}
onOpenChange={setTranscodeOpen}
/>
{token && (
<IptvExportDialog
open={iptvOpen}
onOpenChange={setIptvOpen}
token={token}
/>
)}
<ImportChannelDialog
open={importOpen}
onOpenChange={(open) => {
if (!open) {
setImportOpen(false);
setImportError(null);
}
}}
onSubmit={handleImport}
isPending={importPending}
error={importError}
/>
<CreateChannelDialog
open={createOpen}
onOpenChange={setCreateOpen}
onSubmit={handleCreate}
isPending={createChannel.isPending}
error={createChannel.error?.message}
/>
<EditChannelSheet
channel={editChannel}
open={!!editChannel}
onOpenChange={(open) => {
if (!open) setEditChannel(null);
}}
onSubmit={handleEdit}
isPending={updateChannel.isPending}
error={updateChannel.error?.message}
providers={config?.providers ?? []}
/>
<ScheduleSheet
channel={scheduleChannel}
open={!!scheduleChannel}
onOpenChange={(open) => {
if (!open) setScheduleChannel(null);
}}
/>
{deleteTarget && (
<DeleteChannelDialog
channelName={deleteTarget.name}
open={!!deleteTarget}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
onConfirm={handleDelete}
isPending={deleteChannel.isPending}
/>
)}
</div>
);
}