212 lines
8.1 KiB
TypeScript
212 lines
8.1 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { useChannels, useUpdateChannel } from "@/hooks/use-channels";
|
|
import type { LibraryItemFull, ShowSummary, Weekday, ProgrammingBlock, ScheduleConfig } from "@/lib/types";
|
|
import { WEEKDAYS, WEEKDAY_LABELS } from "@/lib/types";
|
|
|
|
interface Props {
|
|
selectedItems: LibraryItemFull[];
|
|
selectedShows?: ShowSummary[];
|
|
}
|
|
|
|
export function ScheduleFromLibraryDialog({ selectedItems, selectedShows }: Props) {
|
|
const [open, setOpen] = useState(false);
|
|
const [channelId, setChannelId] = useState("");
|
|
const [selectedDays, setSelectedDays] = useState<Set<Weekday>>(new Set());
|
|
const [startTime, setStartTime] = useState("20:00");
|
|
const [durationMins, setDurationMins] = useState(() => {
|
|
if (selectedItems.length === 1) return Math.ceil(selectedItems[0].duration_secs / 60);
|
|
return 60;
|
|
});
|
|
const [strategy, setStrategy] = useState<"sequential" | "random" | "best_fit">("sequential");
|
|
|
|
const { data: channels } = useChannels();
|
|
const updateChannel = useUpdateChannel();
|
|
|
|
const selectedChannel = channels?.find(c => c.id === channelId);
|
|
const isEpisodic = selectedItems.every(i => i.content_type === "episode");
|
|
const allSameSeries =
|
|
isEpisodic &&
|
|
selectedItems.length > 0 &&
|
|
new Set(selectedItems.map(i => i.series_name)).size === 1;
|
|
|
|
function toggleDay(day: Weekday) {
|
|
setSelectedDays(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(day)) next.delete(day);
|
|
else next.add(day);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
async function handleConfirm() {
|
|
if (!selectedChannel || selectedDays.size === 0) return;
|
|
const startTimeFull = startTime.length === 5 ? `${startTime}:00` : startTime;
|
|
|
|
const hasShows = selectedShows && selectedShows.length > 0;
|
|
|
|
const newBlock: ProgrammingBlock = hasShows
|
|
? {
|
|
id: globalThis.crypto.randomUUID(),
|
|
name: selectedShows!.length === 1
|
|
? `${selectedShows![0].series_name} — ${startTime}`
|
|
: `${selectedShows!.length} shows — ${startTime}`,
|
|
start_time: startTimeFull,
|
|
duration_mins: durationMins,
|
|
content: {
|
|
type: "algorithmic",
|
|
filter: {
|
|
content_type: "episode",
|
|
series_names: selectedShows!.map(s => s.series_name),
|
|
genres: [],
|
|
tags: [],
|
|
collections: [],
|
|
},
|
|
strategy,
|
|
},
|
|
}
|
|
: allSameSeries
|
|
? {
|
|
id: globalThis.crypto.randomUUID(),
|
|
name: `${selectedItems[0].series_name ?? "Series"} — ${startTime}`,
|
|
start_time: startTimeFull,
|
|
duration_mins: durationMins,
|
|
content: {
|
|
type: "algorithmic",
|
|
filter: {
|
|
content_type: "episode",
|
|
series_names: [selectedItems[0].series_name!],
|
|
genres: [],
|
|
tags: [],
|
|
collections: [],
|
|
},
|
|
strategy,
|
|
provider_id: selectedItems[0].id.split("::")[0],
|
|
},
|
|
}
|
|
: {
|
|
id: globalThis.crypto.randomUUID(),
|
|
name: `${selectedItems.length} items — ${startTime}`,
|
|
start_time: startTimeFull,
|
|
duration_mins: durationMins,
|
|
content: { type: "manual", items: selectedItems.map(i => i.id) },
|
|
};
|
|
|
|
const updatedDayBlocks = { ...selectedChannel.schedule_config.day_blocks };
|
|
for (const day of selectedDays) {
|
|
updatedDayBlocks[day] = [...(updatedDayBlocks[day] ?? []), newBlock];
|
|
}
|
|
|
|
const scheduleConfig: ScheduleConfig = { day_blocks: updatedDayBlocks };
|
|
|
|
await updateChannel.mutateAsync({
|
|
id: channelId,
|
|
data: { schedule_config: scheduleConfig },
|
|
});
|
|
setOpen(false);
|
|
}
|
|
|
|
const canConfirm = !!channelId && selectedDays.size > 0;
|
|
const daysLabel = [...selectedDays].map(d => WEEKDAY_LABELS[d]).join(", ");
|
|
const hasShows = selectedShows && selectedShows.length > 0;
|
|
const contentLabel = hasShows
|
|
? (selectedShows!.length === 1 ? selectedShows![0].series_name : `${selectedShows!.length} shows`)
|
|
: `${selectedItems.length} item(s)`;
|
|
const preview = canConfirm
|
|
? `${selectedDays.size} block(s) of ${contentLabel} will be created on ${selectedChannel?.name} — ${daysLabel} at ${startTime}, ${strategy}`
|
|
: null;
|
|
|
|
return (
|
|
<>
|
|
<Button size="sm" onClick={() => setOpen(true)}>Schedule on channel</Button>
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader><DialogTitle>Schedule on channel</DialogTitle></DialogHeader>
|
|
<div className="flex flex-col gap-4">
|
|
<div>
|
|
<p className="mb-1.5 text-xs text-zinc-400">Channel</p>
|
|
<Select value={channelId} onValueChange={setChannelId}>
|
|
<SelectTrigger><SelectValue placeholder="Select channel…" /></SelectTrigger>
|
|
<SelectContent>
|
|
{channels?.map(c => (
|
|
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<p className="mb-1.5 text-xs text-zinc-400">Days</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{WEEKDAYS.map(day => (
|
|
<label key={day} className="flex items-center gap-1.5 cursor-pointer">
|
|
<Checkbox checked={selectedDays.has(day)} onCheckedChange={() => toggleDay(day)} />
|
|
<span className="text-xs">{WEEKDAY_LABELS[day]}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-4">
|
|
<div className="flex-1">
|
|
<p className="mb-1.5 text-xs text-zinc-400">
|
|
Start time{selectedChannel?.timezone ? ` (${selectedChannel.timezone})` : ""}
|
|
</p>
|
|
<Input
|
|
type="time"
|
|
value={startTime}
|
|
onChange={e => setStartTime(e.target.value)}
|
|
disabled={!channelId}
|
|
/>
|
|
</div>
|
|
<div className="flex-1">
|
|
<p className="mb-1.5 text-xs text-zinc-400">Duration (mins)</p>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
value={durationMins}
|
|
onChange={e => setDurationMins(Number(e.target.value))}
|
|
disabled={!channelId}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p className="mb-1.5 text-xs text-zinc-400">Fill strategy</p>
|
|
<Select
|
|
value={strategy}
|
|
onValueChange={(v: "sequential" | "random" | "best_fit") => setStrategy(v)}
|
|
disabled={!channelId}
|
|
>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="sequential">Sequential</SelectItem>
|
|
<SelectItem value="random">Random</SelectItem>
|
|
<SelectItem value="best_fit">Best fit</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{preview && (
|
|
<p className="rounded-md bg-emerald-950/30 border border-emerald-800 px-3 py-2 text-xs text-emerald-300">
|
|
{preview}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
|
<Button
|
|
disabled={!canConfirm || updateChannel.isPending}
|
|
onClick={handleConfirm}
|
|
>
|
|
{updateChannel.isPending ? "Saving…" : `Create ${selectedDays.size} block(s)`}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|