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