feat(frontend): weekly grid editor with day tabs and copy shortcut

This commit is contained in:
2026-03-17 14:46:34 +01:00
parent c0da075f03
commit ba6abad602
2 changed files with 119 additions and 34 deletions

View File

@@ -29,7 +29,8 @@ import type {
RecyclePolicy,
Weekday,
} from "@/lib/types";
import { WEEKDAYS } from "@/lib/types";
import { WEEKDAYS, WEEKDAY_LABELS } from "@/lib/types";
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
// Local shared primitives (only used inside this file)
@@ -366,6 +367,29 @@ export function EditChannelSheet({
}: 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();
@@ -375,9 +399,7 @@ export function EditChannelSheet({
name: form.name,
description: form.description,
timezone: form.timezone,
day_blocks: Object.fromEntries(
WEEKDAYS.map(d => [d, d === 'monday' ? form.blocks : []])
) as Record<Weekday, ProgrammingBlock[]>,
day_blocks: form.dayBlocks,
recycle_policy: form.recyclePolicy,
auto_schedule: form.autoSchedule,
access_mode: form.accessMode,
@@ -394,11 +416,7 @@ export function EditChannelSheet({
name: form.name,
description: form.description,
timezone: form.timezone,
schedule_config: {
day_blocks: Object.fromEntries(
WEEKDAYS.map(d => [d, d === 'monday' ? form.blocks : []])
) as Record<Weekday, ProgrammingBlock[]>,
},
schedule_config: { day_blocks: form.dayBlocks },
recycle_policy: form.recyclePolicy,
auto_schedule: form.autoSchedule,
access_mode: form.accessMode !== "public" ? form.accessMode : "public",
@@ -418,6 +436,7 @@ export function EditChannelSheet({
});
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
@@ -550,6 +569,47 @@ export function EditChannelSheet({
{/* 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">
@@ -559,31 +619,31 @@ export function EditChannelSheet({
type="button"
variant="outline"
size="xs"
onClick={() => form.addBlock()}
onClick={() => form.addBlock(activeDay)}
className="border-zinc-700 text-zinc-300 hover:text-zinc-100"
>
<Plus className="size-3" />
Add block
Add block for {WEEKDAY_LABELS[activeDay]}
</Button>
</div>
<BlockTimeline
blocks={form.blocks}
blocks={form.dayBlocks[activeDay] ?? []}
selectedId={form.selectedBlockId}
onSelect={form.setSelectedBlockId}
onChange={form.setBlocks}
onChange={(blocks) => form.setDayBlocks(prev => ({ ...prev, [activeDay]: blocks }))}
onCreateBlock={(startMins, durationMins) =>
form.addBlock(startMins, durationMins)
form.addBlock(activeDay, startMins, durationMins)
}
/>
{form.blocks.length === 0 ? (
{(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 yet. Drag on the timeline or click Add block.
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.blocks.map((block, idx) => (
{(form.dayBlocks[activeDay] ?? []).map((block, idx) => (
<button
key={block.id}
type="button"
@@ -611,7 +671,7 @@ export function EditChannelSheet({
role="button"
onClick={(e) => {
e.stopPropagation();
form.removeBlock(idx);
form.removeBlock(activeDay, idx);
}}
className="rounded p-1 text-zinc-600 hover:bg-zinc-700 hover:text-red-400"
>
@@ -632,11 +692,12 @@ export function EditChannelSheet({
</div>
);
}
const selectedIdx = form.blocks.findIndex(
const activeDayBlocks = form.dayBlocks[activeDay] ?? [];
const selectedIdx = activeDayBlocks.findIndex(
(b) => b.id === form.selectedBlockId,
);
const selectedBlock =
selectedIdx >= 0 ? form.blocks[selectedIdx] : null;
selectedIdx >= 0 ? activeDayBlocks[selectedIdx] : null;
if (!selectedBlock) {
return (
<div className="flex h-full items-center justify-center text-sm text-zinc-600">
@@ -650,7 +711,7 @@ export function EditChannelSheet({
index={selectedIdx}
errors={fieldErrors}
providers={providers}
onChange={(b) => form.updateBlock(selectedIdx, b)}
onChange={(b) => form.updateBlock(activeDay, selectedIdx, b)}
/>
);
})()}
@@ -665,6 +726,15 @@ export function EditChannelSheet({
</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"
@@ -678,6 +748,7 @@ export function EditChannelSheet({
</Button>
</div>
</div>
{/* TODO: ConfigHistorySheet — wired in Task 16 */}
</form>
</SheetContent>
</Sheet>