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:
2026-03-16 01:40:28 +01:00
parent e76167134b
commit 40f698acb7
4 changed files with 465 additions and 471 deletions

View File

@@ -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" />

View File

@@ -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) && (

View File

@@ -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>

View File

@@ -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"