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