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

225 lines
7.4 KiB
TypeScript

"use client";
import { useState } from "react";
import { Plus, Upload } 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 { 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();
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">
<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>
)}
{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>
)}
{channels && channels.length > 0 && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{channels.map((channel) => (
<ChannelCard
key={channel.id}
channel={channel}
isGenerating={
generateSchedule.isPending &&
generateSchedule.variables === channel.id
}
onEdit={() => setEditChannel(channel)}
onDelete={() => setDeleteTarget(channel)}
onGenerateSchedule={() => generateSchedule.mutate(channel.id)}
onViewSchedule={() => setScheduleChannel(channel)}
onExport={() => handleExport(channel)}
/>
))}
</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>
);
}