Files

274 lines
9.1 KiB
TypeScript

"use client";
import { useState } from "react";
import { toast } from "sonner";
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 { useChannelOrder } from "@/hooks/use-channel-order";
import { useImportChannel } from "@/hooks/use-import-channel";
import { useRegenerateAllSchedules } from "@/hooks/use-regenerate-all";
import { exportChannel } from "@/lib/channel-export";
import { DashboardHeader } from "./components/dashboard-header";
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 { ScheduleHistoryDialog } from "./components/schedule-history-dialog";
import type {
ChannelResponse,
ProgrammingBlock,
RecyclePolicy,
Weekday,
} from "@/lib/types";
export default function DashboardPage() {
const { token } = useAuthContext();
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();
const { sortedChannels, handleMoveUp, handleMoveDown } = useChannelOrder(channels);
const { isRegeneratingAll, handleRegenerateAll } = useRegenerateAllSchedules(channels, token);
const importChannel = useImportChannel(token);
// Dialog state
const [iptvOpen, setIptvOpen] = useState(false);
const [transcodeOpen, setTranscodeOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [editChannel, setEditChannel] = useState<ChannelResponse | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ChannelResponse | null>(null);
const [scheduleChannel, setScheduleChannel] = useState<ChannelResponse | null>(null);
const [scheduleHistoryChannelId, setScheduleHistoryChannelId] = useState<string | 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: { day_blocks: Record<Weekday, 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) => {
const ok = await importChannel.handleImport(data);
if (ok) setImportOpen(false);
};
const handleDelete = () => {
if (!deleteTarget) return;
deleteChannel.mutate(deleteTarget.id, {
onSuccess: () => setDeleteTarget(null),
});
};
const canTranscode = !!config?.providers?.some((p) => p.capabilities.transcode);
const canRescan = !!capabilities?.rescan;
return (
<div className="mx-auto w-full max-w-5xl space-y-6 px-6 py-8">
<DashboardHeader
hasChannels={!!channels && channels.length > 0}
canTranscode={canTranscode}
canRescan={canRescan}
isRegeneratingAll={isRegeneratingAll}
isRescanPending={rescanLibrary.isPending}
capabilities={capabilities}
onTranscodeOpen={() => setTranscodeOpen(true)}
onRescan={() =>
rescanLibrary.mutate(undefined, {
onSuccess: (d) =>
toast.success(`Rescan complete: ${d.items_found} files found`),
onError: () => toast.error("Rescan failed"),
})
}
onRegenerateAll={handleRegenerateAll}
onIptvOpen={() => setIptvOpen(true)}
onImportOpen={() => setImportOpen(true)}
onCreateOpen={() => setCreateOpen(true)}
/>
{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)}
className="inline-flex items-center gap-1.5 rounded-md border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-sm text-zinc-300 transition-colors hover:bg-zinc-700 hover:text-zinc-100"
>
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={() => exportChannel(channel)}
onMoveUp={() => handleMoveUp(channel.id)}
onMoveDown={() => handleMoveDown(channel.id)}
onScheduleHistory={() => setScheduleHistoryChannelId(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);
importChannel.clearError();
}
}}
onSubmit={handleImport}
isPending={importChannel.isPending}
error={importChannel.error}
/>
<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);
}}
/>
{scheduleHistoryChannelId && (
<ScheduleHistoryDialog
channelId={scheduleHistoryChannelId}
open={!!scheduleHistoryChannelId}
onOpenChange={open => !open && setScheduleHistoryChannelId(null)}
/>
)}
{deleteTarget && (
<DeleteChannelDialog
channelName={deleteTarget.name}
open={!!deleteTarget}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
onConfirm={handleDelete}
isPending={deleteChannel.isPending}
/>
)}
</div>
);
}