274 lines
9.1 KiB
TypeScript
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>
|
|
);
|
|
}
|