305 lines
10 KiB
TypeScript
305 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { Plus, Upload, RefreshCw } 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 { 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 type { ChannelResponse, ProgrammingBlock, RecyclePolicy } from "@/lib/types";
|
|
|
|
export default function DashboardPage() {
|
|
const { token } = useAuthContext();
|
|
const queryClient = useQueryClient();
|
|
const { data: channels, isLoading, error } = useChannels();
|
|
|
|
const createChannel = useCreateChannel();
|
|
const updateChannel = useUpdateChannel();
|
|
const deleteChannel = useDeleteChannel();
|
|
const generateSchedule = useGenerateSchedule();
|
|
|
|
// 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 [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;
|
|
}) => {
|
|
createChannel.mutate(
|
|
{ name: data.name, timezone: data.timezone, description: data.description || undefined },
|
|
{ onSuccess: () => setCreateOpen(false) },
|
|
);
|
|
};
|
|
|
|
const handleEdit = (
|
|
id: string,
|
|
data: {
|
|
name: string;
|
|
description: string;
|
|
timezone: string;
|
|
schedule_config: { blocks: ProgrammingBlock[] };
|
|
recycle_policy: RecyclePolicy;
|
|
},
|
|
) => {
|
|
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">
|
|
{channels && channels.length > 0 && (
|
|
<Button
|
|
variant="outline"
|
|
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 variant="outline" 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 variant="outline" 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 */}
|
|
<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}
|
|
/>
|
|
|
|
<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>
|
|
);
|
|
}
|