feat(frontend): weekly grid editor with day tabs and copy shortcut
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user