Files
k-tv/k-tv-frontend/app/(main)/dashboard/components/channel-card.tsx
Gabriel Kaszewski c4d2e48f73 fix(frontend): resolve all eslint warnings and errors
- block-timeline: ref updates moved to useLayoutEffect
- channel-card, guide/page: Date.now() wrapped in useMemo + suppress purity rule
- auth-context: lazy localStorage init (removes setState-in-effect)
- use-channel-order: lazy localStorage init (removes setState-in-effect)
- use-idle: start timer on mount without calling resetIdle (removes setState-in-effect)
- use-subtitles, transcode-settings-dialog: inline eslint-disable on exact violating line
- providers: block-level eslint-disable for tokenRef closure in useState initializer
- edit-channel-sheet: remove unused minsToTime and BlockContent imports
- docs/page: escape unescaped quote and apostrophe entities
2026-03-17 02:40:32 +01:00

215 lines
6.2 KiB
TypeScript

"use client";
import { useState, useMemo } from "react";
import Link from "next/link";
import {
Pencil,
Trash2,
RefreshCw,
Tv2,
CalendarDays,
Download,
ChevronUp,
ChevronDown,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useActiveSchedule } from "@/hooks/use-channels";
import type { ChannelResponse } from "@/lib/types";
import { ConfirmDialog } from "./confirm-dialog";
interface ChannelCardProps {
channel: ChannelResponse;
isGenerating: boolean;
isFirst: boolean;
isLast: boolean;
onEdit: () => void;
onDelete: () => void;
onGenerateSchedule: () => void;
onViewSchedule: () => void;
onExport: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
}
function useScheduleStatus(channelId: string) {
const { data: schedule } = useActiveSchedule(channelId);
// eslint-disable-next-line react-hooks/purity -- Date.now() inside useMemo is stable enough for schedule status
const now = useMemo(() => Date.now(), []);
if (!schedule) return { status: "none" as const, label: null };
const expiresAt = new Date(schedule.valid_until);
const hoursLeft = (expiresAt.getTime() - now) / (1000 * 60 * 60);
if (hoursLeft < 0) {
return { status: "expired" as const, label: "Schedule expired" };
}
if (hoursLeft < 6) {
const h = Math.ceil(hoursLeft);
return { status: "expiring" as const, label: `Expires in ${h}h` };
}
const fmt = expiresAt.toLocaleDateString(undefined, {
weekday: "short",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
return { status: "ok" as const, label: `Until ${fmt}` };
}
export function ChannelCard({
channel,
isGenerating,
isFirst,
isLast,
onEdit,
onDelete,
onGenerateSchedule,
onViewSchedule,
onExport,
onMoveUp,
onMoveDown,
}: ChannelCardProps) {
const [confirmOpen, setConfirmOpen] = useState(false);
const blockCount = channel.schedule_config.blocks.length;
const { status, label } = useScheduleStatus(channel.id);
const scheduleColor =
status === "expired"
? "text-red-400"
: status === "expiring"
? "text-amber-400"
: status === "ok"
? "text-zinc-500"
: "text-zinc-600";
return (
<div className="flex flex-col gap-4 rounded-xl border border-zinc-800 bg-zinc-900 p-5 transition-colors hover:border-zinc-700">
{/* Top row */}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<h2 className="truncate text-base font-semibold text-zinc-100">
{channel.name}
</h2>
{channel.description && (
<p className="line-clamp-2 text-sm text-zinc-500">
{channel.description}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{/* Order controls */}
<div className="flex flex-col">
<button
onClick={onMoveUp}
disabled={isFirst}
title="Move up"
className="rounded p-0.5 text-zinc-600 transition-colors hover:text-zinc-300 disabled:opacity-20 disabled:cursor-not-allowed"
>
<ChevronUp className="size-3.5" />
</button>
<button
onClick={onMoveDown}
disabled={isLast}
title="Move down"
className="rounded p-0.5 text-zinc-600 transition-colors hover:text-zinc-300 disabled:opacity-20 disabled:cursor-not-allowed"
>
<ChevronDown className="size-3.5" />
</button>
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={onExport}
title="Export as JSON"
className="text-zinc-600 hover:text-zinc-200"
>
<Download className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={onEdit}
title="Edit channel"
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={onDelete}
title="Delete channel"
className="text-zinc-600 hover:text-red-400"
>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
{/* Meta */}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-zinc-500">
<span className="text-zinc-400">{channel.timezone}</span>
<span>
{blockCount} {blockCount === 1 ? "block" : "blocks"}
</span>
{label && <span className={scheduleColor}>{label}</span>}
</div>
{/* Actions */}
<div className="flex gap-2">
<Button
size="sm"
onClick={() => {
if (status !== "none") {
setConfirmOpen(true);
} else {
onGenerateSchedule();
}
}}
disabled={isGenerating}
className={`flex-1 ${status === "expired" ? "border border-red-800/50 bg-red-950/30 text-red-300 hover:bg-red-900/40" : ""}`}
>
<RefreshCw
className={`size-3.5 ${isGenerating ? "animate-spin" : ""}`}
/>
{isGenerating ? "Generating…" : "Generate schedule"}
</Button>
<Button
size="icon-sm"
onClick={onViewSchedule}
title="View schedule"
>
<CalendarDays className="size-3.5" />
</Button>
<Button
size="icon-sm"
asChild
title="Watch on TV"
>
<Link href={`/tv?channel=${channel.id}`}>
<Tv2 className="size-3.5" />
</Link>
</Button>
</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>
);
}