Compare commits
2 Commits
e76167134b
...
4df6522952
| Author | SHA1 | Date | |
|---|---|---|---|
| 4df6522952 | |||
| 40f698acb7 |
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
Pencil,
|
Pencil,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useActiveSchedule } from "@/hooks/use-channels";
|
import { useActiveSchedule } from "@/hooks/use-channels";
|
||||||
import type { ChannelResponse } from "@/lib/types";
|
import type { ChannelResponse } from "@/lib/types";
|
||||||
|
import { ConfirmDialog } from "./confirm-dialog";
|
||||||
|
|
||||||
interface ChannelCardProps {
|
interface ChannelCardProps {
|
||||||
channel: ChannelResponse;
|
channel: ChannelResponse;
|
||||||
@@ -65,6 +67,7 @@ export function ChannelCard({
|
|||||||
onMoveUp,
|
onMoveUp,
|
||||||
onMoveDown,
|
onMoveDown,
|
||||||
}: ChannelCardProps) {
|
}: ChannelCardProps) {
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const blockCount = channel.schedule_config.blocks.length;
|
const blockCount = channel.schedule_config.blocks.length;
|
||||||
const { status, label } = useScheduleStatus(channel.id);
|
const { status, label } = useScheduleStatus(channel.id);
|
||||||
|
|
||||||
@@ -155,7 +158,13 @@ export function ChannelCard({
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onGenerateSchedule}
|
onClick={() => {
|
||||||
|
if (status !== "none") {
|
||||||
|
setConfirmOpen(true);
|
||||||
|
} else {
|
||||||
|
onGenerateSchedule();
|
||||||
|
}
|
||||||
|
}}
|
||||||
disabled={isGenerating}
|
disabled={isGenerating}
|
||||||
className={`flex-1 ${status === "expired" ? "border border-red-800/50 bg-red-950/30 text-red-300 hover:bg-red-900/40" : ""}`}
|
className={`flex-1 ${status === "expired" ? "border border-red-800/50 bg-red-950/30 text-red-300 hover:bg-red-900/40" : ""}`}
|
||||||
>
|
>
|
||||||
@@ -168,7 +177,6 @@ export function ChannelCard({
|
|||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onClick={onViewSchedule}
|
onClick={onViewSchedule}
|
||||||
title="View schedule"
|
title="View schedule"
|
||||||
className="border-zinc-700 text-zinc-400 hover:text-zinc-100"
|
|
||||||
>
|
>
|
||||||
<CalendarDays className="size-3.5" />
|
<CalendarDays className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -176,13 +184,28 @@ export function ChannelCard({
|
|||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
asChild
|
asChild
|
||||||
title="Watch on TV"
|
title="Watch on TV"
|
||||||
className="border-zinc-700 text-zinc-400 hover:text-zinc-100"
|
|
||||||
>
|
>
|
||||||
<Link href={`/tv?channel=${channel.id}`}>
|
<Link href={`/tv?channel=${channel.id}`}>
|
||||||
<Tv2 className="size-3.5" />
|
<Tv2 className="size-3.5" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmOpen}
|
||||||
|
onOpenChange={setConfirmOpen}
|
||||||
|
title="Regenerate schedule?"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
<span className="font-medium text-zinc-200">{channel.name}</span>
|
||||||
|
{" "}already has an active schedule. Generating a new one will overwrite it immediately.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
confirmLabel="Regenerate"
|
||||||
|
onConfirm={() => {
|
||||||
|
setConfirmOpen(false);
|
||||||
|
onGenerateSchedule();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogAction,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
|
interface ConfirmDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
title: string;
|
||||||
|
description: React.ReactNode;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
isPending?: boolean;
|
||||||
|
destructive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmLabel = "Confirm",
|
||||||
|
cancelLabel = "Cancel",
|
||||||
|
onConfirm,
|
||||||
|
isPending,
|
||||||
|
destructive,
|
||||||
|
}: ConfirmDialogProps) {
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="text-zinc-400">
|
||||||
|
{description}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel
|
||||||
|
disabled={isPending}
|
||||||
|
className="border-zinc-700 bg-transparent text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100"
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={isPending}
|
||||||
|
className={
|
||||||
|
destructive ? "bg-red-600 text-white hover:bg-red-700" : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Trash2, Plus, ChevronDown, ChevronUp } from "lucide-react";
|
import { Trash2, Plus } from "lucide-react";
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { TagInput } from "./tag-input";
|
import { TagInput } from "./tag-input";
|
||||||
import { BlockTimeline, BLOCK_COLORS, timeToMins, minsToTime } from "./block-timeline";
|
import { BlockTimeline, BLOCK_COLORS, minsToTime } from "./block-timeline";
|
||||||
import { SeriesPicker } from "./series-picker";
|
import { SeriesPicker } from "./series-picker";
|
||||||
import { FilterPreview } from "./filter-preview";
|
import { FilterPreview } from "./filter-preview";
|
||||||
import { useCollections, useSeries, useGenres } from "@/hooks/use-library";
|
import { useCollections, useSeries, useGenres } from "@/hooks/use-library";
|
||||||
@@ -447,23 +447,23 @@ function AlgorithmicFilterEditor({
|
|||||||
error={!!errors[`${pfx}.content.filter.decade`]}
|
error={!!errors[`${pfx}.content.filter.decade`]}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Min duration (s)" error={errors[`${pfx}.content.filter.min_duration_secs`]}>
|
<Field label="Min duration (min)" error={errors[`${pfx}.content.filter.min_duration_secs`]}>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={content.filter.min_duration_secs ?? ""}
|
value={content.filter.min_duration_secs != null ? Math.round(content.filter.min_duration_secs / 60) : ""}
|
||||||
onChange={(v) =>
|
onChange={(v) =>
|
||||||
setFilter({ min_duration_secs: v === "" ? null : (v as number) })
|
setFilter({ min_duration_secs: v === "" ? null : (v as number) * 60 })
|
||||||
}
|
}
|
||||||
placeholder="1200"
|
placeholder="30"
|
||||||
error={!!errors[`${pfx}.content.filter.min_duration_secs`]}
|
error={!!errors[`${pfx}.content.filter.min_duration_secs`]}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Max duration (s)" error={errors[`${pfx}.content.filter.max_duration_secs`]}>
|
<Field label="Max duration (min)" error={errors[`${pfx}.content.filter.max_duration_secs`]}>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={content.filter.max_duration_secs ?? ""}
|
value={content.filter.max_duration_secs != null ? Math.round(content.filter.max_duration_secs / 60) : ""}
|
||||||
onChange={(v) =>
|
onChange={(v) =>
|
||||||
setFilter({ max_duration_secs: v === "" ? null : (v as number) })
|
setFilter({ max_duration_secs: v === "" ? null : (v as number) * 60 })
|
||||||
}
|
}
|
||||||
placeholder="3600"
|
placeholder="120"
|
||||||
error={!!errors[`${pfx}.content.filter.max_duration_secs`]}
|
error={!!errors[`${pfx}.content.filter.max_duration_secs`]}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
@@ -482,27 +482,12 @@ function AlgorithmicFilterEditor({
|
|||||||
interface BlockEditorProps {
|
interface BlockEditorProps {
|
||||||
block: ProgrammingBlock;
|
block: ProgrammingBlock;
|
||||||
index: number;
|
index: number;
|
||||||
isSelected: boolean;
|
|
||||||
color: string;
|
|
||||||
errors: FieldErrors;
|
errors: FieldErrors;
|
||||||
onChange: (block: ProgrammingBlock) => void;
|
onChange: (block: ProgrammingBlock) => void;
|
||||||
onRemove: () => void;
|
|
||||||
onSelect: () => void;
|
|
||||||
providers: ProviderInfo[];
|
providers: ProviderInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemove, onSelect, providers }: BlockEditorProps) {
|
function BlockEditor({ block, index, errors, onChange, providers }: BlockEditorProps) {
|
||||||
const [expanded, setExpanded] = useState(isSelected);
|
|
||||||
const elRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Scroll into view when selected
|
|
||||||
useEffect(() => {
|
|
||||||
if (isSelected) {
|
|
||||||
setExpanded(true);
|
|
||||||
elRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
||||||
}
|
|
||||||
}, [isSelected]);
|
|
||||||
|
|
||||||
const setField = <K extends keyof ProgrammingBlock>(key: K, value: ProgrammingBlock[K]) =>
|
const setField = <K extends keyof ProgrammingBlock>(key: K, value: ProgrammingBlock[K]) =>
|
||||||
onChange({ ...block, [key]: value });
|
onChange({ ...block, [key]: value });
|
||||||
|
|
||||||
@@ -535,38 +520,7 @@ function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemo
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="space-y-3">
|
||||||
ref={elRef}
|
|
||||||
className={`rounded-lg border bg-zinc-800/50 ${isSelected ? "border-zinc-500" : "border-zinc-700"}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2">
|
|
||||||
<div className="h-2.5 w-2.5 rounded-full shrink-0" style={{ backgroundColor: color }} />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { setExpanded((v) => !v); onSelect(); }}
|
|
||||||
className="flex flex-1 items-center gap-2 text-left text-sm font-medium text-zinc-200"
|
|
||||||
>
|
|
||||||
{expanded ? (
|
|
||||||
<ChevronUp className="size-3.5 shrink-0 text-zinc-500" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="size-3.5 shrink-0 text-zinc-500" />
|
|
||||||
)}
|
|
||||||
<span className="truncate">{block.name || "Unnamed block"}</span>
|
|
||||||
<span className="shrink-0 font-mono text-[11px] text-zinc-500">
|
|
||||||
{block.start_time.slice(0, 5)} · {block.duration_mins}m
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onRemove}
|
|
||||||
className="rounded p-1 text-zinc-600 hover:bg-zinc-700 hover:text-red-400"
|
|
||||||
>
|
|
||||||
<Trash2 className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{expanded && (
|
|
||||||
<div className="space-y-3 border-t border-zinc-700 px-3 py-3">
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Block name" error={errors[`${pfx}.name`]}>
|
<Field label="Block name" error={errors[`${pfx}.name`]}>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -697,8 +651,6 @@ function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemo
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -909,14 +861,16 @@ export function EditChannelSheet({
|
|||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent
|
<SheetContent
|
||||||
side="right"
|
side="right"
|
||||||
className="flex w-full flex-col gap-0 border-zinc-800 bg-zinc-900 p-0 text-zinc-100 sm:max-w-2xl"
|
className="flex w-full flex-col gap-0 border-zinc-800 bg-zinc-900 p-0 text-zinc-100 sm:!max-w-[min(1100px,95vw)]"
|
||||||
>
|
>
|
||||||
<SheetHeader className="border-b border-zinc-800 px-6 py-4">
|
<SheetHeader className="border-b border-zinc-800 px-6 py-4">
|
||||||
<SheetTitle className="text-zinc-100">Edit channel</SheetTitle>
|
<SheetTitle className="text-zinc-100">Edit channel</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-1 flex-col overflow-hidden">
|
<form onSubmit={handleSubmit} className="flex flex-1 flex-col overflow-hidden">
|
||||||
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-4">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* Left column — basic info, logo, recycle policy, webhook */}
|
||||||
|
<div className="w-[300px] shrink-0 overflow-y-auto border-r border-zinc-800 px-5 py-4 space-y-6">
|
||||||
{/* Basic info */}
|
{/* Basic info */}
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Basic info</h3>
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Basic info</h3>
|
||||||
@@ -1075,56 +1029,6 @@ export function EditChannelSheet({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Programming blocks */}
|
|
||||||
<section className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">
|
|
||||||
Programming blocks
|
|
||||||
</h3>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="xs"
|
|
||||||
onClick={() => addBlock()}
|
|
||||||
className="border-zinc-700 text-zinc-300 hover:text-zinc-100"
|
|
||||||
>
|
|
||||||
<Plus className="size-3" />
|
|
||||||
Add block
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<BlockTimeline
|
|
||||||
blocks={blocks}
|
|
||||||
selectedId={selectedBlockId}
|
|
||||||
onSelect={setSelectedBlockId}
|
|
||||||
onChange={setBlocks}
|
|
||||||
onCreateBlock={(startMins, durationMins) => addBlock(startMins, durationMins)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{blocks.length === 0 && (
|
|
||||||
<p className="rounded-md border border-dashed border-zinc-700 px-4 py-6 text-center text-xs text-zinc-600">
|
|
||||||
No blocks yet. Drag on the timeline or click Add block.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{blocks.map((block, idx) => (
|
|
||||||
<BlockEditor
|
|
||||||
key={block.id}
|
|
||||||
block={block}
|
|
||||||
index={idx}
|
|
||||||
isSelected={block.id === selectedBlockId}
|
|
||||||
color={BLOCK_COLORS[idx % BLOCK_COLORS.length]}
|
|
||||||
errors={fieldErrors}
|
|
||||||
onChange={(b) => updateBlock(idx, b)}
|
|
||||||
onRemove={() => removeBlock(idx)}
|
|
||||||
onSelect={() => setSelectedBlockId(block.id)}
|
|
||||||
providers={providers}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Recycle policy */}
|
{/* Recycle policy */}
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Recycle policy</h3>
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Recycle policy</h3>
|
||||||
@@ -1216,6 +1120,107 @@ export function EditChannelSheet({
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Right column — programming blocks */}
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
{/* Upper half: timeline + block list */}
|
||||||
|
<div className="shrink-0 border-b border-zinc-800 px-5 py-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">
|
||||||
|
Programming blocks
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => addBlock()}
|
||||||
|
className="border-zinc-700 text-zinc-300 hover:text-zinc-100"
|
||||||
|
>
|
||||||
|
<Plus className="size-3" />
|
||||||
|
Add block
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BlockTimeline
|
||||||
|
blocks={blocks}
|
||||||
|
selectedId={selectedBlockId}
|
||||||
|
onSelect={setSelectedBlockId}
|
||||||
|
onChange={setBlocks}
|
||||||
|
onCreateBlock={(startMins, durationMins) => addBlock(startMins, durationMins)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{blocks.length === 0 ? (
|
||||||
|
<p className="rounded-md border border-dashed border-zinc-700 px-4 py-4 text-center text-xs text-zinc-600">
|
||||||
|
No blocks yet. Drag on the timeline or click Add block.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1 max-h-48 overflow-y-auto">
|
||||||
|
{blocks.map((block, idx) => (
|
||||||
|
<button
|
||||||
|
key={block.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedBlockId(block.id)}
|
||||||
|
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-left transition-colors ${
|
||||||
|
block.id === selectedBlockId
|
||||||
|
? "border border-zinc-600 bg-zinc-700/60"
|
||||||
|
: "border border-transparent hover:bg-zinc-800/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: BLOCK_COLORS[idx % BLOCK_COLORS.length] }}
|
||||||
|
/>
|
||||||
|
<span className="flex-1 truncate text-sm text-zinc-200">
|
||||||
|
{block.name || "Unnamed block"}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 font-mono text-[11px] text-zinc-500">
|
||||||
|
{block.start_time.slice(0, 5)} · {block.duration_mins}m
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); removeBlock(idx); }}
|
||||||
|
className="rounded p-1 text-zinc-600 hover:bg-zinc-700 hover:text-red-400"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lower half: selected block detail */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||||
|
{(() => {
|
||||||
|
if (selectedBlockId === null) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-zinc-600">
|
||||||
|
Select a block or create one
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const selectedIdx = blocks.findIndex((b) => b.id === selectedBlockId);
|
||||||
|
const selectedBlock = selectedIdx >= 0 ? blocks[selectedIdx] : null;
|
||||||
|
if (!selectedBlock) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-zinc-600">
|
||||||
|
Select a block or create one
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<BlockEditor
|
||||||
|
block={selectedBlock}
|
||||||
|
index={selectedIdx}
|
||||||
|
errors={fieldErrors}
|
||||||
|
onChange={(b) => updateBlock(selectedIdx, b)}
|
||||||
|
providers={providers}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex items-center justify-between border-t border-zinc-800 px-6 py-4">
|
<div className="flex items-center justify-between border-t border-zinc-800 px-6 py-4">
|
||||||
{(error || Object.keys(fieldErrors).length > 0) && (
|
{(error || Object.keys(fieldErrors).length > 0) && (
|
||||||
|
|||||||
@@ -241,7 +241,6 @@ export default function DashboardPage() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => setTranscodeOpen(true)}
|
onClick={() => setTranscodeOpen(true)}
|
||||||
title="Transcode settings"
|
title="Transcode settings"
|
||||||
className="border-zinc-700 text-zinc-400 hover:text-zinc-100"
|
|
||||||
>
|
>
|
||||||
<Settings2 className="size-4" />
|
<Settings2 className="size-4" />
|
||||||
Transcode
|
Transcode
|
||||||
@@ -257,7 +256,6 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
disabled={rescanLibrary.isPending}
|
disabled={rescanLibrary.isPending}
|
||||||
title="Rescan local files directory"
|
title="Rescan local files directory"
|
||||||
className="border-zinc-700 text-zinc-400 hover:text-zinc-100"
|
|
||||||
>
|
>
|
||||||
<RefreshCw className={`size-4 ${rescanLibrary.isPending ? "animate-spin" : ""}`} />
|
<RefreshCw className={`size-4 ${rescanLibrary.isPending ? "animate-spin" : ""}`} />
|
||||||
Rescan library
|
Rescan library
|
||||||
@@ -268,7 +266,6 @@ export default function DashboardPage() {
|
|||||||
onClick={handleRegenerateAll}
|
onClick={handleRegenerateAll}
|
||||||
disabled={isRegeneratingAll}
|
disabled={isRegeneratingAll}
|
||||||
title="Regenerate schedules for all channels"
|
title="Regenerate schedules for all channels"
|
||||||
className="border-zinc-700 text-zinc-400 hover:text-zinc-100"
|
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
className={`size-4 ${isRegeneratingAll ? "animate-spin" : ""}`}
|
className={`size-4 ${isRegeneratingAll ? "animate-spin" : ""}`}
|
||||||
@@ -276,17 +273,11 @@ export default function DashboardPage() {
|
|||||||
Regenerate all
|
Regenerate all
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button onClick={() => setIptvOpen(true)}>
|
||||||
onClick={() => setIptvOpen(true)}
|
|
||||||
className="border-zinc-700 text-zinc-300 hover:text-zinc-100"
|
|
||||||
>
|
|
||||||
<Antenna className="size-4" />
|
<Antenna className="size-4" />
|
||||||
IPTV
|
IPTV
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button onClick={() => setImportOpen(true)}>
|
||||||
onClick={() => setImportOpen(true)}
|
|
||||||
className="border-zinc-700 text-zinc-300 hover:text-zinc-100"
|
|
||||||
>
|
|
||||||
<Upload className="size-4" />
|
<Upload className="size-4" />
|
||||||
Import
|
Import
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" className="dark">
|
||||||
<head>
|
<head>
|
||||||
<Script
|
<Script
|
||||||
src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"
|
src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"
|
||||||
|
|||||||
Reference in New Issue
Block a user