feat: add IPTV export functionality with M3U and XMLTV generation, including UI components for export dialog
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Plus, Upload, RefreshCw } from "lucide-react";
|
||||
import { Plus, Upload, RefreshCw, Antenna } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
useChannels,
|
||||
@@ -19,8 +19,16 @@ 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";
|
||||
import {
|
||||
ImportChannelDialog,
|
||||
type ChannelImportData,
|
||||
} from "./components/import-channel-dialog";
|
||||
import { IptvExportDialog } from "./components/iptv-export-dialog";
|
||||
import type {
|
||||
ChannelResponse,
|
||||
ProgrammingBlock,
|
||||
RecyclePolicy,
|
||||
} from "@/lib/types";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { token } = useAuthContext();
|
||||
@@ -43,7 +51,9 @@ export default function DashboardPage() {
|
||||
|
||||
const saveOrder = (order: string[]) => {
|
||||
setChannelOrder(order);
|
||||
try { localStorage.setItem("k-tv-channel-order", JSON.stringify(order)); } catch {}
|
||||
try {
|
||||
localStorage.setItem("k-tv-channel-order", JSON.stringify(order));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
// Sort channels by stored order; new channels appear at the end
|
||||
@@ -91,17 +101,22 @@ export default function DashboardPage() {
|
||||
}
|
||||
}
|
||||
setIsRegeneratingAll(false);
|
||||
if (failed === 0) toast.success(`All ${channels.length} schedules regenerated`);
|
||||
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 [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 [deleteTarget, setDeleteTarget] = useState<ChannelResponse | null>(
|
||||
null,
|
||||
);
|
||||
const [scheduleChannel, setScheduleChannel] =
|
||||
useState<ChannelResponse | null>(null);
|
||||
|
||||
const handleCreate = (data: {
|
||||
name: string;
|
||||
@@ -147,12 +162,19 @@ export default function DashboardPage() {
|
||||
setImportError(null);
|
||||
try {
|
||||
const created = await api.channels.create(
|
||||
{ name: data.name, timezone: data.timezone, description: data.description },
|
||||
{
|
||||
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 },
|
||||
{
|
||||
schedule_config: { blocks: data.blocks },
|
||||
recycle_policy: data.recycle_policy,
|
||||
},
|
||||
token,
|
||||
);
|
||||
await queryClient.invalidateQueries({ queryKey: ["channels"] });
|
||||
@@ -172,7 +194,9 @@ export default function DashboardPage() {
|
||||
blocks: channel.schedule_config.blocks,
|
||||
recycle_policy: channel.recycle_policy,
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
|
||||
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;
|
||||
@@ -201,17 +225,28 @@ export default function DashboardPage() {
|
||||
<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" : ""}`} />
|
||||
<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">
|
||||
<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>
|
||||
@@ -238,7 +273,7 @@ export default function DashboardPage() {
|
||||
{!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)}>
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
Create your first channel
|
||||
</Button>
|
||||
@@ -270,9 +305,22 @@ export default function DashboardPage() {
|
||||
)}
|
||||
|
||||
{/* Dialogs / sheets */}
|
||||
{token && (
|
||||
<IptvExportDialog
|
||||
open={iptvOpen}
|
||||
onOpenChange={setIptvOpen}
|
||||
token={token}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ImportChannelDialog
|
||||
open={importOpen}
|
||||
onOpenChange={(open) => { if (!open) { setImportOpen(false); setImportError(null); } }}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setImportOpen(false);
|
||||
setImportError(null);
|
||||
}
|
||||
}}
|
||||
onSubmit={handleImport}
|
||||
isPending={importPending}
|
||||
error={importError}
|
||||
@@ -289,7 +337,9 @@ export default function DashboardPage() {
|
||||
<EditChannelSheet
|
||||
channel={editChannel}
|
||||
open={!!editChannel}
|
||||
onOpenChange={(open) => { if (!open) setEditChannel(null); }}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditChannel(null);
|
||||
}}
|
||||
onSubmit={handleEdit}
|
||||
isPending={updateChannel.isPending}
|
||||
error={updateChannel.error?.message}
|
||||
@@ -298,14 +348,18 @@ export default function DashboardPage() {
|
||||
<ScheduleSheet
|
||||
channel={scheduleChannel}
|
||||
open={!!scheduleChannel}
|
||||
onOpenChange={(open) => { if (!open) setScheduleChannel(null); }}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setScheduleChannel(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{deleteTarget && (
|
||||
<DeleteChannelDialog
|
||||
channelName={deleteTarget.name}
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteTarget(null);
|
||||
}}
|
||||
onConfirm={handleDelete}
|
||||
isPending={deleteChannel.isPending}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user