refactor: clean up styles and improve layout in dashboard and edit channel components
- Removed unnecessary class names for buttons in ChannelCard and DashboardPage components. - Updated layout styles in RootLayout to apply dark mode by default. - Refactored edit-channel-sheet to streamline block editor and filter editor components. - Adjusted duration input fields to reflect minutes instead of seconds in AlgorithmicFilterEditor. - Enhanced the structure of the EditChannelSheet for better readability and maintainability.
This commit is contained in:
@@ -168,7 +168,6 @@ export function ChannelCard({
|
||||
size="icon-sm"
|
||||
onClick={onViewSchedule}
|
||||
title="View schedule"
|
||||
className="border-zinc-700 text-zinc-400 hover:text-zinc-100"
|
||||
>
|
||||
<CalendarDays className="size-3.5" />
|
||||
</Button>
|
||||
@@ -176,7 +175,6 @@ export function ChannelCard({
|
||||
size="icon-sm"
|
||||
asChild
|
||||
title="Watch on TV"
|
||||
className="border-zinc-700 text-zinc-400 hover:text-zinc-100"
|
||||
>
|
||||
<Link href={`/tv?channel=${channel.id}`}>
|
||||
<Tv2 className="size-3.5" />
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
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 { FilterPreview } from "./filter-preview";
|
||||
import { useCollections, useSeries, useGenres } from "@/hooks/use-library";
|
||||
@@ -447,23 +447,23 @@ function AlgorithmicFilterEditor({
|
||||
error={!!errors[`${pfx}.content.filter.decade`]}
|
||||
/>
|
||||
</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
|
||||
value={content.filter.min_duration_secs ?? ""}
|
||||
value={content.filter.min_duration_secs != null ? Math.round(content.filter.min_duration_secs / 60) : ""}
|
||||
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`]}
|
||||
/>
|
||||
</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
|
||||
value={content.filter.max_duration_secs ?? ""}
|
||||
value={content.filter.max_duration_secs != null ? Math.round(content.filter.max_duration_secs / 60) : ""}
|
||||
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`]}
|
||||
/>
|
||||
</Field>
|
||||
@@ -482,27 +482,12 @@ function AlgorithmicFilterEditor({
|
||||
interface BlockEditorProps {
|
||||
block: ProgrammingBlock;
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
color: string;
|
||||
errors: FieldErrors;
|
||||
onChange: (block: ProgrammingBlock) => void;
|
||||
onRemove: () => void;
|
||||
onSelect: () => void;
|
||||
providers: ProviderInfo[];
|
||||
}
|
||||
|
||||
function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemove, onSelect, 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]);
|
||||
|
||||
function BlockEditor({ block, index, errors, onChange, providers }: BlockEditorProps) {
|
||||
const setField = <K extends keyof ProgrammingBlock>(key: K, value: ProgrammingBlock[K]) =>
|
||||
onChange({ ...block, [key]: value });
|
||||
|
||||
@@ -535,38 +520,7 @@ function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemo
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
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="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Block name" error={errors[`${pfx}.name`]}>
|
||||
<TextInput
|
||||
@@ -697,8 +651,6 @@ function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemo
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -909,14 +861,16 @@ export function EditChannelSheet({
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
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">
|
||||
<SheetTitle className="text-zinc-100">Edit channel</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<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 */}
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Basic info</h3>
|
||||
@@ -1075,56 +1029,6 @@ export function EditChannelSheet({
|
||||
</div>
|
||||
</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 */}
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Recycle policy</h3>
|
||||
@@ -1216,6 +1120,107 @@ export function EditChannelSheet({
|
||||
</section>
|
||||
</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 */}
|
||||
<div className="flex items-center justify-between border-t border-zinc-800 px-6 py-4">
|
||||
{(error || Object.keys(fieldErrors).length > 0) && (
|
||||
|
||||
@@ -241,7 +241,6 @@ export default function DashboardPage() {
|
||||
<Button
|
||||
onClick={() => setTranscodeOpen(true)}
|
||||
title="Transcode settings"
|
||||
className="border-zinc-700 text-zinc-400 hover:text-zinc-100"
|
||||
>
|
||||
<Settings2 className="size-4" />
|
||||
Transcode
|
||||
@@ -257,7 +256,6 @@ export default function DashboardPage() {
|
||||
}
|
||||
disabled={rescanLibrary.isPending}
|
||||
title="Rescan local files directory"
|
||||
className="border-zinc-700 text-zinc-400 hover:text-zinc-100"
|
||||
>
|
||||
<RefreshCw className={`size-4 ${rescanLibrary.isPending ? "animate-spin" : ""}`} />
|
||||
Rescan library
|
||||
@@ -268,7 +266,6 @@ export default function DashboardPage() {
|
||||
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" : ""}`}
|
||||
@@ -276,17 +273,11 @@ export default function DashboardPage() {
|
||||
Regenerate all
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => setIptvOpen(true)}
|
||||
className="border-zinc-700 text-zinc-300 hover:text-zinc-100"
|
||||
>
|
||||
<Button onClick={() => setIptvOpen(true)}>
|
||||
<Antenna className="size-4" />
|
||||
IPTV
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setImportOpen(true)}
|
||||
className="border-zinc-700 text-zinc-300 hover:text-zinc-100"
|
||||
>
|
||||
<Button onClick={() => setImportOpen(true)}>
|
||||
<Upload className="size-4" />
|
||||
Import
|
||||
</Button>
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" className="dark">
|
||||
<head>
|
||||
<Script
|
||||
src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"
|
||||
|
||||
Reference in New Issue
Block a user