757 lines
27 KiB
TypeScript
757 lines
27 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { Trash2, Plus } from "lucide-react";
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from "@/components/ui/sheet";
|
|
import { Button } from "@/components/ui/button";
|
|
import { BlockTimeline, BLOCK_COLORS } from "./block-timeline";
|
|
import { AlgorithmicFilterEditor } from "./algorithmic-filter-editor";
|
|
import { RecyclePolicyEditor } from "./recycle-policy-editor";
|
|
import { WebhookEditor } from "./webhook-editor";
|
|
import { AccessSettingsEditor } from "./access-settings-editor";
|
|
import { LogoEditor } from "./logo-editor";
|
|
import { useChannelForm } from "@/hooks/use-channel-form";
|
|
import { channelFormSchema, extractErrors } from "@/lib/schemas";
|
|
import type { FieldErrors } from "@/lib/schemas";
|
|
import type {
|
|
AccessMode,
|
|
ChannelResponse,
|
|
LogoPosition,
|
|
ProgrammingBlock,
|
|
FillStrategy,
|
|
MediaFilter,
|
|
ProviderInfo,
|
|
RecyclePolicy,
|
|
Weekday,
|
|
} from "@/lib/types";
|
|
import { WEEKDAYS, WEEKDAY_LABELS } from "@/lib/types";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Local shared primitives (only used inside this file)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function Field({
|
|
label,
|
|
hint,
|
|
error,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
hint?: string;
|
|
error?: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<label className="block text-xs font-medium text-zinc-400">{label}</label>
|
|
{children}
|
|
{error ? (
|
|
<p className="text-[11px] text-red-400">{error}</p>
|
|
) : hint ? (
|
|
<p className="text-[11px] text-zinc-600">{hint}</p>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TextInput({
|
|
value,
|
|
onChange,
|
|
placeholder,
|
|
required,
|
|
error,
|
|
}: {
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
placeholder?: string;
|
|
required?: boolean;
|
|
error?: boolean;
|
|
}) {
|
|
return (
|
|
<input
|
|
required={required}
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={placeholder}
|
|
className={`w-full rounded-md border bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:outline-none ${error ? "border-red-500 focus:border-red-400" : "border-zinc-700 focus:border-zinc-500"}`}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function NumberInput({
|
|
value,
|
|
onChange,
|
|
min,
|
|
error,
|
|
}: {
|
|
value: number | "";
|
|
onChange: (v: number | "") => void;
|
|
min?: number;
|
|
error?: boolean;
|
|
}) {
|
|
return (
|
|
<input
|
|
type="number"
|
|
min={min}
|
|
value={value}
|
|
onChange={(e) =>
|
|
onChange(e.target.value === "" ? "" : Number(e.target.value))
|
|
}
|
|
className={`w-full rounded-md border bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:outline-none ${error ? "border-red-500 focus:border-red-400" : "border-zinc-700 focus:border-zinc-500"}`}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function NativeSelect({
|
|
value,
|
|
onChange,
|
|
children,
|
|
}: {
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<select
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:border-zinc-500 focus:outline-none"
|
|
>
|
|
{children}
|
|
</select>
|
|
);
|
|
}
|
|
|
|
function defaultFilter(): MediaFilter {
|
|
return {
|
|
content_type: null,
|
|
genres: [],
|
|
decade: null,
|
|
tags: [],
|
|
min_duration_secs: null,
|
|
max_duration_secs: null,
|
|
collections: [],
|
|
series_names: [],
|
|
search_term: null,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BlockEditor — inline because it's only used here and depends on local types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface BlockEditorProps {
|
|
block: ProgrammingBlock;
|
|
index: number;
|
|
errors: FieldErrors;
|
|
providers: ProviderInfo[];
|
|
onChange: (block: ProgrammingBlock) => void;
|
|
}
|
|
|
|
function BlockEditor({ block, index, errors, providers, onChange }: BlockEditorProps) {
|
|
const setField = <K extends keyof ProgrammingBlock>(key: K, value: ProgrammingBlock[K]) =>
|
|
onChange({ ...block, [key]: value });
|
|
|
|
const content = block.content;
|
|
const pfx = `blocks.${index}`;
|
|
|
|
const setContentType = (type: "algorithmic" | "manual") => {
|
|
const pid = content.provider_id ?? "";
|
|
onChange({
|
|
...block,
|
|
content:
|
|
type === "algorithmic"
|
|
? { type: "algorithmic", filter: defaultFilter(), strategy: "random", provider_id: pid }
|
|
: { type: "manual", items: [], provider_id: pid },
|
|
});
|
|
};
|
|
|
|
const setFilter = (patch: Partial<MediaFilter>) => {
|
|
if (content.type !== "algorithmic") return;
|
|
onChange({ ...block, content: { ...content, filter: { ...content.filter, ...patch } } });
|
|
};
|
|
|
|
const setStrategy = (strategy: FillStrategy) => {
|
|
if (content.type !== "algorithmic") return;
|
|
onChange({ ...block, content: { ...content, strategy } });
|
|
};
|
|
|
|
const setProviderId = (id: string) =>
|
|
onChange({ ...block, content: { ...content, provider_id: id } });
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Field label="Block name" error={errors[`${pfx}.name`]}>
|
|
<TextInput
|
|
value={block.name}
|
|
onChange={(v) => setField("name", v)}
|
|
placeholder="Evening Sitcoms"
|
|
error={!!errors[`${pfx}.name`]}
|
|
/>
|
|
</Field>
|
|
<Field label="Content type">
|
|
<NativeSelect
|
|
value={content.type}
|
|
onChange={(v) => setContentType(v as "algorithmic" | "manual")}
|
|
>
|
|
<option value="algorithmic">Algorithmic</option>
|
|
<option value="manual">Manual</option>
|
|
</NativeSelect>
|
|
</Field>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Field label="Start time">
|
|
<input
|
|
type="time"
|
|
value={block.start_time.slice(0, 5)}
|
|
onChange={(e) => setField("start_time", e.target.value + ":00")}
|
|
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:border-zinc-500 focus:outline-none"
|
|
/>
|
|
</Field>
|
|
<Field label="Duration (minutes)" error={errors[`${pfx}.duration_mins`]}>
|
|
<NumberInput
|
|
value={block.duration_mins}
|
|
onChange={(v) => setField("duration_mins", v === "" ? 60 : v)}
|
|
min={1}
|
|
error={!!errors[`${pfx}.duration_mins`]}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
|
|
{content.type === "algorithmic" && (
|
|
<>
|
|
<AlgorithmicFilterEditor
|
|
content={content}
|
|
pfx={pfx}
|
|
errors={errors}
|
|
providers={providers}
|
|
setFilter={setFilter}
|
|
setStrategy={setStrategy}
|
|
setProviderId={setProviderId}
|
|
/>
|
|
|
|
{content.strategy === "sequential" && (
|
|
<div className="space-y-2 rounded-md border border-zinc-700/50 bg-zinc-800 p-3">
|
|
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
|
|
Sequential options
|
|
</p>
|
|
<label className="flex cursor-pointer items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={block.loop_on_finish ?? true}
|
|
onChange={(e) =>
|
|
onChange({ ...block, loop_on_finish: e.target.checked })
|
|
}
|
|
className="accent-zinc-400"
|
|
/>
|
|
<span className="text-sm text-zinc-300">Loop series</span>
|
|
<span className="text-[11px] text-zinc-600">
|
|
Restart from episode 1 after the final episode
|
|
</span>
|
|
</label>
|
|
<label className="flex cursor-pointer items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={block.ignore_recycle_policy ?? false}
|
|
onChange={(e) =>
|
|
onChange({ ...block, ignore_recycle_policy: e.target.checked })
|
|
}
|
|
className="accent-zinc-400"
|
|
/>
|
|
<span className="text-sm text-zinc-300">Independent scheduling</span>
|
|
<span className="text-[11px] text-zinc-600">
|
|
Play episodes in order even if they aired in another block today
|
|
</span>
|
|
</label>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{content.type === "manual" && (
|
|
<div className="space-y-2 rounded-md border border-zinc-700/50 bg-zinc-800 p-3">
|
|
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
|
|
Item IDs
|
|
</p>
|
|
<textarea
|
|
rows={3}
|
|
value={content.items.join("\n")}
|
|
onChange={(e) =>
|
|
onChange({
|
|
...block,
|
|
content: {
|
|
type: "manual",
|
|
items: e.target.value
|
|
.split("\n")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean),
|
|
},
|
|
})
|
|
}
|
|
placeholder={"abc123\ndef456\nghi789"}
|
|
className="w-full resize-none rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 font-mono text-xs text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none"
|
|
/>
|
|
<p className="text-[11px] text-zinc-600">
|
|
One Jellyfin item ID per line, played in order.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-2 rounded-md border border-zinc-700/50 bg-zinc-800 p-3">
|
|
<p className="text-[11px] font-medium uppercase tracking-wider text-zinc-500">
|
|
Block access
|
|
</p>
|
|
<AccessSettingsEditor
|
|
accessMode={(block.access_mode as AccessMode) ?? "public"}
|
|
accessPassword={block.access_password ?? ""}
|
|
onAccessModeChange={(m) => onChange({ ...block, access_mode: m })}
|
|
onAccessPasswordChange={(pw) => onChange({ ...block, access_password: pw })}
|
|
label="Access mode"
|
|
passwordLabel="Block password"
|
|
passwordHint="Leave blank to keep existing"
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// EditChannelSheet
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface EditChannelSheetProps {
|
|
channel: ChannelResponse | null;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onSubmit: (
|
|
id: string,
|
|
data: {
|
|
name: string;
|
|
description: string;
|
|
timezone: string;
|
|
schedule_config: { day_blocks: Record<Weekday, ProgrammingBlock[]> };
|
|
recycle_policy: RecyclePolicy;
|
|
auto_schedule: boolean;
|
|
access_mode?: AccessMode;
|
|
access_password?: string;
|
|
logo?: string | null;
|
|
logo_position?: LogoPosition;
|
|
logo_opacity?: number;
|
|
webhook_url?: string | null;
|
|
webhook_poll_interval_secs?: number;
|
|
webhook_body_template?: string | null;
|
|
webhook_headers?: string | null;
|
|
},
|
|
) => void;
|
|
isPending: boolean;
|
|
error?: string | null;
|
|
providers?: ProviderInfo[];
|
|
}
|
|
|
|
export function EditChannelSheet({
|
|
channel,
|
|
open,
|
|
onOpenChange,
|
|
onSubmit,
|
|
isPending,
|
|
error,
|
|
providers = [],
|
|
}: EditChannelSheetProps) {
|
|
const form = useChannelForm(channel);
|
|
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
|
const [activeDay, setActiveDay] = useState<Weekday>('monday');
|
|
const [copyTarget, setCopyTarget] = useState<Weekday | 'all' | ''>('');
|
|
const [configHistoryOpen, setConfigHistoryOpen] = useState(false);
|
|
|
|
const handleCopyTo = () => {
|
|
if (!copyTarget) return;
|
|
const sourceBlocks = form.dayBlocks[activeDay] ?? [];
|
|
if (copyTarget === 'all') {
|
|
const newDayBlocks = { ...form.dayBlocks };
|
|
for (const day of WEEKDAYS) {
|
|
if (day !== activeDay) {
|
|
newDayBlocks[day] = sourceBlocks.map(b => ({ ...b, id: crypto.randomUUID() }));
|
|
}
|
|
}
|
|
form.setDayBlocks(newDayBlocks);
|
|
} else {
|
|
form.setDayBlocks({
|
|
...form.dayBlocks,
|
|
[copyTarget]: sourceBlocks.map(b => ({ ...b, id: crypto.randomUUID() })),
|
|
});
|
|
}
|
|
setCopyTarget('');
|
|
};
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!channel) return;
|
|
|
|
const result = channelFormSchema.safeParse({
|
|
name: form.name,
|
|
description: form.description,
|
|
timezone: form.timezone,
|
|
day_blocks: form.dayBlocks,
|
|
recycle_policy: form.recyclePolicy,
|
|
auto_schedule: form.autoSchedule,
|
|
access_mode: form.accessMode,
|
|
access_password: form.accessPassword,
|
|
});
|
|
|
|
if (!result.success) {
|
|
setFieldErrors(extractErrors(result.error));
|
|
return;
|
|
}
|
|
|
|
setFieldErrors({});
|
|
onSubmit(channel.id, {
|
|
name: form.name,
|
|
description: form.description,
|
|
timezone: form.timezone,
|
|
schedule_config: { day_blocks: form.dayBlocks },
|
|
recycle_policy: form.recyclePolicy,
|
|
auto_schedule: form.autoSchedule,
|
|
access_mode: form.accessMode !== "public" ? form.accessMode : "public",
|
|
access_password: form.accessPassword || "",
|
|
logo: form.logo,
|
|
logo_position: form.logoPosition,
|
|
logo_opacity: form.logoOpacity / 100,
|
|
webhook_url: form.webhookUrl || null,
|
|
...(form.webhookUrl
|
|
? {
|
|
webhook_poll_interval_secs:
|
|
form.webhookPollInterval === "" ? 5 : form.webhookPollInterval,
|
|
}
|
|
: {}),
|
|
webhook_body_template: form.webhookBodyTemplate || null,
|
|
webhook_headers: form.webhookHeaders || null,
|
|
});
|
|
};
|
|
|
|
|
|
return (
|
|
<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-[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 flex-1 overflow-hidden">
|
|
{/* Left sidebar */}
|
|
<div className="w-[300px] shrink-0 space-y-6 overflow-y-auto border-r border-zinc-800 px-5 py-4">
|
|
<section className="space-y-3">
|
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">
|
|
Basic info
|
|
</h3>
|
|
|
|
<Field label="Name" error={fieldErrors["name"]}>
|
|
<TextInput
|
|
required
|
|
value={form.name}
|
|
onChange={form.setName}
|
|
placeholder="90s Sitcom Network"
|
|
error={!!fieldErrors["name"]}
|
|
/>
|
|
</Field>
|
|
|
|
<Field
|
|
label="Timezone"
|
|
hint="IANA timezone, e.g. America/New_York"
|
|
error={fieldErrors["timezone"]}
|
|
>
|
|
<TextInput
|
|
required
|
|
value={form.timezone}
|
|
onChange={form.setTimezone}
|
|
placeholder="UTC"
|
|
error={!!fieldErrors["timezone"]}
|
|
/>
|
|
</Field>
|
|
|
|
<Field label="Description">
|
|
<textarea
|
|
value={form.description}
|
|
onChange={(e) => form.setDescription(e.target.value)}
|
|
rows={2}
|
|
placeholder="Nothing but classic sitcoms, all day"
|
|
className="w-full resize-none rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none"
|
|
/>
|
|
</Field>
|
|
|
|
<label className="flex cursor-pointer items-center justify-between gap-3 rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-2.5">
|
|
<div>
|
|
<p className="text-sm text-zinc-200">Auto-generate schedule</p>
|
|
<p className="text-[11px] text-zinc-600">
|
|
Automatically regenerate when the schedule is about to expire
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={form.autoSchedule}
|
|
onClick={() => form.setAutoSchedule((v) => !v)}
|
|
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none ${form.autoSchedule ? "bg-zinc-300" : "bg-zinc-700"}`}
|
|
>
|
|
<span
|
|
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${form.autoSchedule ? "translate-x-4" : "translate-x-0"}`}
|
|
/>
|
|
</button>
|
|
</label>
|
|
|
|
<Field label="Channel access">
|
|
<AccessSettingsEditor
|
|
accessMode={form.accessMode}
|
|
accessPassword={form.accessPassword}
|
|
onAccessModeChange={form.setAccessMode}
|
|
onAccessPasswordChange={form.setAccessPassword}
|
|
label="Access mode"
|
|
passwordLabel="Channel password"
|
|
passwordHint="Leave blank to keep existing password"
|
|
/>
|
|
</Field>
|
|
</section>
|
|
|
|
<section className="space-y-3">
|
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">
|
|
Logo
|
|
</h3>
|
|
<LogoEditor
|
|
logo={form.logo}
|
|
logoPosition={form.logoPosition}
|
|
logoOpacity={form.logoOpacity}
|
|
onLogoChange={form.setLogo}
|
|
onPositionChange={form.setLogoPosition}
|
|
onOpacityChange={form.setLogoOpacity}
|
|
/>
|
|
</section>
|
|
|
|
<section className="space-y-3">
|
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">
|
|
Recycle policy
|
|
</h3>
|
|
<RecyclePolicyEditor
|
|
policy={form.recyclePolicy}
|
|
errors={fieldErrors}
|
|
onChange={form.setRecyclePolicy}
|
|
/>
|
|
</section>
|
|
|
|
<section className="space-y-3">
|
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">
|
|
Webhook
|
|
</h3>
|
|
<WebhookEditor
|
|
webhookUrl={form.webhookUrl}
|
|
webhookPollInterval={form.webhookPollInterval}
|
|
webhookFormat={form.webhookFormat}
|
|
webhookBodyTemplate={form.webhookBodyTemplate}
|
|
webhookHeaders={form.webhookHeaders}
|
|
onWebhookUrlChange={form.setWebhookUrl}
|
|
onWebhookPollIntervalChange={form.setWebhookPollInterval}
|
|
onWebhookFormatChange={form.setWebhookFormat}
|
|
onWebhookBodyTemplateChange={form.setWebhookBodyTemplate}
|
|
onWebhookHeadersChange={form.setWebhookHeaders}
|
|
/>
|
|
</section>
|
|
</div>
|
|
|
|
{/* Right: block editor */}
|
|
<div className="flex flex-1 flex-col overflow-hidden">
|
|
{/* Day tab bar */}
|
|
<div className="shrink-0 flex items-center border-b border-zinc-800 overflow-x-auto">
|
|
{WEEKDAYS.map(day => (
|
|
<button
|
|
key={day}
|
|
type="button"
|
|
onClick={() => { setActiveDay(day); form.setSelectedBlockId(null); }}
|
|
className={cn(
|
|
'px-4 py-2.5 text-sm whitespace-nowrap transition-colors shrink-0',
|
|
activeDay === day
|
|
? 'border-b-2 border-blue-400 text-blue-400'
|
|
: 'text-zinc-500 hover:text-zinc-300'
|
|
)}
|
|
>
|
|
{WEEKDAY_LABELS[day]}
|
|
</button>
|
|
))}
|
|
{/* Copy-to control */}
|
|
<div className="ml-auto flex items-center gap-1.5 px-3 py-1 text-xs text-zinc-500 shrink-0">
|
|
<span>Copy to</span>
|
|
<select
|
|
value={copyTarget}
|
|
onChange={e => setCopyTarget(e.target.value as Weekday | 'all' | '')}
|
|
className="bg-zinc-800 border border-zinc-700 rounded px-1 py-0.5 text-xs text-zinc-300"
|
|
>
|
|
<option value="">day…</option>
|
|
{WEEKDAYS.filter(d => d !== activeDay).map(d => (
|
|
<option key={d} value={d}>{WEEKDAY_LABELS[d]}</option>
|
|
))}
|
|
<option value="all">All days</option>
|
|
</select>
|
|
<button
|
|
type="button"
|
|
onClick={handleCopyTo}
|
|
className="bg-blue-900/40 border border-blue-700 text-blue-400 px-2 py-0.5 rounded text-xs hover:bg-blue-900/60"
|
|
>
|
|
Copy
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="shrink-0 space-y-3 border-b border-zinc-800 px-5 py-4">
|
|
<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={() => form.addBlock(activeDay)}
|
|
className="border-zinc-700 text-zinc-300 hover:text-zinc-100"
|
|
>
|
|
<Plus className="size-3" />
|
|
Add block for {WEEKDAY_LABELS[activeDay]}
|
|
</Button>
|
|
</div>
|
|
|
|
<BlockTimeline
|
|
blocks={form.dayBlocks[activeDay] ?? []}
|
|
selectedId={form.selectedBlockId}
|
|
onSelect={form.setSelectedBlockId}
|
|
onChange={(blocks) => form.setDayBlocks(prev => ({ ...prev, [activeDay]: blocks }))}
|
|
onCreateBlock={(startMins, durationMins) =>
|
|
form.addBlock(activeDay, startMins, durationMins)
|
|
}
|
|
/>
|
|
|
|
{(form.dayBlocks[activeDay] ?? []).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 for {WEEKDAY_LABELS[activeDay]}. Drag on the timeline or click Add block.
|
|
</p>
|
|
) : (
|
|
<div className="max-h-48 space-y-1 overflow-y-auto">
|
|
{(form.dayBlocks[activeDay] ?? []).map((block, idx) => (
|
|
<button
|
|
key={block.id}
|
|
type="button"
|
|
onClick={() => form.setSelectedBlockId(block.id)}
|
|
className={`flex w-full items-center gap-2 rounded-md px-3 py-2 text-left transition-colors ${
|
|
block.id === form.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();
|
|
form.removeBlock(activeDay, 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>
|
|
|
|
<div className="flex-1 overflow-y-auto px-5 py-4">
|
|
{(() => {
|
|
if (form.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 activeDayBlocks = form.dayBlocks[activeDay] ?? [];
|
|
const selectedIdx = activeDayBlocks.findIndex(
|
|
(b) => b.id === form.selectedBlockId,
|
|
);
|
|
const selectedBlock =
|
|
selectedIdx >= 0 ? activeDayBlocks[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}
|
|
providers={providers}
|
|
onChange={(b) => form.updateBlock(activeDay, selectedIdx, b)}
|
|
/>
|
|
);
|
|
})()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between border-t border-zinc-800 px-6 py-4">
|
|
{(error || Object.keys(fieldErrors).length > 0) && (
|
|
<p className="text-xs text-red-400">
|
|
{error ?? "Please fix the errors above"}
|
|
</p>
|
|
)}
|
|
<div className="ml-auto flex gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setConfigHistoryOpen(true)}
|
|
className="border-zinc-700 text-zinc-400 hover:text-zinc-100"
|
|
>
|
|
Config history
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => onOpenChange(false)}
|
|
disabled={isPending}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" disabled={isPending}>
|
|
{isPending ? "Saving…" : "Save changes"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{/* TODO: ConfigHistorySheet — wired in Task 16 */}
|
|
</form>
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
}
|