217 lines
6.3 KiB
TypeScript
217 lines
6.3 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 = Object.values(channel.schedule_config.day_blocks).reduce(
|
|
(sum, blocks) => sum + blocks.length, 0
|
|
);
|
|
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>
|
|
);
|
|
}
|