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, RecyclePolicy,
Weekday, Weekday,
} from "@/lib/types"; } 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) // Local shared primitives (only used inside this file)
@@ -366,6 +367,29 @@ export function EditChannelSheet({
}: EditChannelSheetProps) { }: EditChannelSheetProps) {
const form = useChannelForm(channel); const form = useChannelForm(channel);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({}); 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) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -375,9 +399,7 @@ export function EditChannelSheet({
name: form.name, name: form.name,
description: form.description, description: form.description,
timezone: form.timezone, timezone: form.timezone,
day_blocks: Object.fromEntries( day_blocks: form.dayBlocks,
WEEKDAYS.map(d => [d, d === 'monday' ? form.blocks : []])
) as Record<Weekday, ProgrammingBlock[]>,
recycle_policy: form.recyclePolicy, recycle_policy: form.recyclePolicy,
auto_schedule: form.autoSchedule, auto_schedule: form.autoSchedule,
access_mode: form.accessMode, access_mode: form.accessMode,
@@ -394,11 +416,7 @@ export function EditChannelSheet({
name: form.name, name: form.name,
description: form.description, description: form.description,
timezone: form.timezone, timezone: form.timezone,
schedule_config: { schedule_config: { day_blocks: form.dayBlocks },
day_blocks: Object.fromEntries(
WEEKDAYS.map(d => [d, d === 'monday' ? form.blocks : []])
) as Record<Weekday, ProgrammingBlock[]>,
},
recycle_policy: form.recyclePolicy, recycle_policy: form.recyclePolicy,
auto_schedule: form.autoSchedule, auto_schedule: form.autoSchedule,
access_mode: form.accessMode !== "public" ? form.accessMode : "public", access_mode: form.accessMode !== "public" ? form.accessMode : "public",
@@ -418,6 +436,7 @@ export function EditChannelSheet({
}); });
}; };
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent <SheetContent
@@ -550,6 +569,47 @@ export function EditChannelSheet({
{/* Right: block editor */} {/* Right: block editor */}
<div className="flex flex-1 flex-col overflow-hidden"> <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="shrink-0 space-y-3 border-b border-zinc-800 px-5 py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500"> <h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">
@@ -559,31 +619,31 @@ export function EditChannelSheet({
type="button" type="button"
variant="outline" variant="outline"
size="xs" size="xs"
onClick={() => form.addBlock()} onClick={() => form.addBlock(activeDay)}
className="border-zinc-700 text-zinc-300 hover:text-zinc-100" className="border-zinc-700 text-zinc-300 hover:text-zinc-100"
> >
<Plus className="size-3" /> <Plus className="size-3" />
Add block Add block for {WEEKDAY_LABELS[activeDay]}
</Button> </Button>
</div> </div>
<BlockTimeline <BlockTimeline
blocks={form.blocks} blocks={form.dayBlocks[activeDay] ?? []}
selectedId={form.selectedBlockId} selectedId={form.selectedBlockId}
onSelect={form.setSelectedBlockId} onSelect={form.setSelectedBlockId}
onChange={form.setBlocks} onChange={(blocks) => form.setDayBlocks(prev => ({ ...prev, [activeDay]: blocks }))}
onCreateBlock={(startMins, durationMins) => 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"> <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> </p>
) : ( ) : (
<div className="max-h-48 space-y-1 overflow-y-auto"> <div className="max-h-48 space-y-1 overflow-y-auto">
{form.blocks.map((block, idx) => ( {(form.dayBlocks[activeDay] ?? []).map((block, idx) => (
<button <button
key={block.id} key={block.id}
type="button" type="button"
@@ -611,7 +671,7 @@ export function EditChannelSheet({
role="button" role="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
form.removeBlock(idx); form.removeBlock(activeDay, idx);
}} }}
className="rounded p-1 text-zinc-600 hover:bg-zinc-700 hover:text-red-400" className="rounded p-1 text-zinc-600 hover:bg-zinc-700 hover:text-red-400"
> >
@@ -632,11 +692,12 @@ export function EditChannelSheet({
</div> </div>
); );
} }
const selectedIdx = form.blocks.findIndex( const activeDayBlocks = form.dayBlocks[activeDay] ?? [];
const selectedIdx = activeDayBlocks.findIndex(
(b) => b.id === form.selectedBlockId, (b) => b.id === form.selectedBlockId,
); );
const selectedBlock = const selectedBlock =
selectedIdx >= 0 ? form.blocks[selectedIdx] : null; selectedIdx >= 0 ? activeDayBlocks[selectedIdx] : null;
if (!selectedBlock) { if (!selectedBlock) {
return ( return (
<div className="flex h-full items-center justify-center text-sm text-zinc-600"> <div className="flex h-full items-center justify-center text-sm text-zinc-600">
@@ -650,7 +711,7 @@ export function EditChannelSheet({
index={selectedIdx} index={selectedIdx}
errors={fieldErrors} errors={fieldErrors}
providers={providers} providers={providers}
onChange={(b) => form.updateBlock(selectedIdx, b)} onChange={(b) => form.updateBlock(activeDay, selectedIdx, b)}
/> />
); );
})()} })()}
@@ -665,6 +726,15 @@ export function EditChannelSheet({
</p> </p>
)} )}
<div className="ml-auto flex gap-2"> <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 <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -678,6 +748,7 @@ export function EditChannelSheet({
</Button> </Button>
</div> </div>
</div> </div>
{/* TODO: ConfigHistorySheet — wired in Task 16 */}
</form> </form>
</SheetContent> </SheetContent>
</Sheet> </Sheet>

View File

@@ -9,7 +9,9 @@ import type {
ProgrammingBlock, ProgrammingBlock,
MediaFilter, MediaFilter,
RecyclePolicy, RecyclePolicy,
Weekday,
} from "@/lib/types"; } from "@/lib/types";
import { WEEKDAYS } from "@/lib/types";
export const WEBHOOK_PRESETS = { export const WEBHOOK_PRESETS = {
discord: `{ discord: `{
@@ -54,11 +56,17 @@ export function defaultBlock(startMins = 20 * 60, durationMins = 60): Programmin
}; };
} }
function emptyDayBlocks(): Record<Weekday, ProgrammingBlock[]> {
const result = {} as Record<Weekday, ProgrammingBlock[]>;
for (const d of WEEKDAYS) result[d] = [];
return result;
}
export function useChannelForm(channel: ChannelResponse | null) { export function useChannelForm(channel: ChannelResponse | null) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [timezone, setTimezone] = useState("UTC"); const [timezone, setTimezone] = useState("UTC");
const [blocks, setBlocks] = useState<ProgrammingBlock[]>([]); const [dayBlocks, setDayBlocks] = useState<Record<Weekday, ProgrammingBlock[]>>(emptyDayBlocks);
const [recyclePolicy, setRecyclePolicy] = useState<RecyclePolicy>({ const [recyclePolicy, setRecyclePolicy] = useState<RecyclePolicy>({
cooldown_days: null, cooldown_days: null,
cooldown_generations: null, cooldown_generations: null,
@@ -84,7 +92,10 @@ export function useChannelForm(channel: ChannelResponse | null) {
setName(channel.name); setName(channel.name);
setDescription(channel.description ?? ""); setDescription(channel.description ?? "");
setTimezone(channel.timezone); setTimezone(channel.timezone);
setBlocks(channel.schedule_config.day_blocks['monday'] ?? []); setDayBlocks({
...emptyDayBlocks(),
...channel.schedule_config.day_blocks,
});
setRecyclePolicy(channel.recycle_policy); setRecyclePolicy(channel.recycle_policy);
setAutoSchedule(channel.auto_schedule); setAutoSchedule(channel.auto_schedule);
setAccessMode(channel.access_mode ?? "public"); setAccessMode(channel.access_mode ?? "public");
@@ -110,20 +121,23 @@ export function useChannelForm(channel: ChannelResponse | null) {
} }
}, [channel]); }, [channel]);
const addBlock = (startMins = 20 * 60, durationMins = 60) => { const addBlock = (day: Weekday, startMins = 20 * 60, durationMins = 60) => {
const block = defaultBlock(startMins, durationMins); const block = defaultBlock(startMins, durationMins);
setBlocks((prev) => [...prev, block]); setDayBlocks((prev) => ({ ...prev, [day]: [...(prev[day] ?? []), block] }));
setSelectedBlockId(block.id); setSelectedBlockId(block.id);
}; };
const updateBlock = (idx: number, block: ProgrammingBlock) => const updateBlock = (day: Weekday, idx: number, block: ProgrammingBlock) =>
setBlocks((prev) => prev.map((b, i) => (i === idx ? block : b))); setDayBlocks((prev) => ({
...prev,
[day]: (prev[day] ?? []).map((b, i) => (i === idx ? block : b)),
}));
const removeBlock = (idx: number) => { const removeBlock = (day: Weekday, idx: number) => {
setBlocks((prev) => { setDayBlocks((prev) => {
const next = prev.filter((_, i) => i !== idx); const dayArr = prev[day] ?? [];
if (selectedBlockId === prev[idx].id) setSelectedBlockId(null); if (selectedBlockId === dayArr[idx]?.id) setSelectedBlockId(null);
return next; return { ...prev, [day]: dayArr.filter((_, i) => i !== idx) };
}); });
}; };
@@ -147,8 +161,8 @@ export function useChannelForm(channel: ChannelResponse | null) {
webhookFormat, setWebhookFormat, webhookFormat, setWebhookFormat,
webhookBodyTemplate, setWebhookBodyTemplate, webhookBodyTemplate, setWebhookBodyTemplate,
webhookHeaders, setWebhookHeaders, webhookHeaders, setWebhookHeaders,
// Blocks // Blocks (day-keyed)
blocks, setBlocks, dayBlocks, setDayBlocks,
selectedBlockId, setSelectedBlockId, selectedBlockId, setSelectedBlockId,
recyclePolicy, setRecyclePolicy, recyclePolicy, setRecyclePolicy,
addBlock, addBlock,