feat: add schedule UI — day picker, time, emails in theme management
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>master
parent
a068d04fa8
commit
f989f592a9
@ -0,0 +1,35 @@
|
||||
import { api } from './client';
|
||||
|
||||
// ---- Schedule types ----
|
||||
|
||||
export interface ScheduleResponse {
|
||||
id: string;
|
||||
theme_id: string;
|
||||
enabled: boolean;
|
||||
days: string[];
|
||||
time_utc: string;
|
||||
emails: string[];
|
||||
last_run_at: string | null;
|
||||
}
|
||||
|
||||
export interface UpsertScheduleRequest {
|
||||
enabled: boolean;
|
||||
days: string[];
|
||||
time_utc: string;
|
||||
emails: string[];
|
||||
}
|
||||
|
||||
/** Schedules API endpoints (per-theme generation schedule). */
|
||||
export const schedulesApi = {
|
||||
/** GET /themes/:themeId/schedule -- retrieve the schedule for a theme (404 if none). */
|
||||
get: (themeId: string): Promise<ScheduleResponse> =>
|
||||
api.get<ScheduleResponse>(`/themes/${themeId}/schedule`),
|
||||
|
||||
/** PUT /themes/:themeId/schedule -- create or update the schedule for a theme. */
|
||||
upsert: (themeId: string, data: UpsertScheduleRequest): Promise<ScheduleResponse> =>
|
||||
api.put<ScheduleResponse>(`/themes/${themeId}/schedule`, data),
|
||||
|
||||
/** DELETE /themes/:themeId/schedule -- remove the schedule for a theme. */
|
||||
remove: (themeId: string): Promise<void> =>
|
||||
api.delete<void>(`/themes/${themeId}/schedule`),
|
||||
};
|
||||
@ -0,0 +1,286 @@
|
||||
import { type Component, createSignal, onMount, For, Show } from 'solid-js';
|
||||
import { Trash2, Plus } from 'lucide-solid';
|
||||
import { schedulesApi } from '~/api/schedules';
|
||||
import type { ScheduleResponse } from '~/api/schedules';
|
||||
import { useI18n } from '~/i18n';
|
||||
import { isApiError } from '~/types';
|
||||
import { useToast } from '~/components/ui/Toast';
|
||||
|
||||
interface SettingsScheduleProps {
|
||||
themeId: string;
|
||||
}
|
||||
|
||||
const DAYS = [
|
||||
{ code: 'mon', labelKey: 'schedule.dayMon' as const },
|
||||
{ code: 'tue', labelKey: 'schedule.dayTue' as const },
|
||||
{ code: 'wed', labelKey: 'schedule.dayWed' as const },
|
||||
{ code: 'thu', labelKey: 'schedule.dayThu' as const },
|
||||
{ code: 'fri', labelKey: 'schedule.dayFri' as const },
|
||||
{ code: 'sat', labelKey: 'schedule.daySat' as const },
|
||||
{ code: 'sun', labelKey: 'schedule.daySun' as const },
|
||||
];
|
||||
|
||||
/**
|
||||
* Schedule configuration section within the ThemeManager.
|
||||
*
|
||||
* Loads the existing schedule on mount (treats 404 as "no schedule yet"),
|
||||
* and auto-saves on every change. Grays out the form when the schedule is
|
||||
* disabled, but keeps it visible so users can still configure it before
|
||||
* re-enabling.
|
||||
*/
|
||||
const SettingsSchedule: Component<SettingsScheduleProps> = (props) => {
|
||||
const { t } = useI18n();
|
||||
const { addToast } = useToast();
|
||||
|
||||
const [schedule, setSchedule] = createSignal<ScheduleResponse | null>(null);
|
||||
const [enabled, setEnabled] = createSignal(false);
|
||||
const [selectedDays, setSelectedDays] = createSignal<string[]>([]);
|
||||
const [timeUtc, setTimeUtc] = createSignal('08:00');
|
||||
const [emails, setEmails] = createSignal<string[]>([]);
|
||||
const [newEmail, setNewEmail] = createSignal('');
|
||||
const [saving, setSaving] = createSignal(false);
|
||||
|
||||
// ---- Load schedule on mount ----
|
||||
onMount(async () => {
|
||||
try {
|
||||
const data = await schedulesApi.get(props.themeId);
|
||||
setSchedule(data);
|
||||
setEnabled(data.enabled);
|
||||
setSelectedDays(data.days);
|
||||
setTimeUtc(data.time_utc);
|
||||
setEmails(data.emails);
|
||||
} catch (err) {
|
||||
// 404 means no schedule configured yet — that's fine
|
||||
if (isApiError(err) && err.status === 404) {
|
||||
setSchedule(null);
|
||||
} else {
|
||||
console.error('Failed to load schedule:', err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Auto-save helper ----
|
||||
const save = async (patch: {
|
||||
enabled?: boolean;
|
||||
days?: string[];
|
||||
time_utc?: string;
|
||||
emails?: string[];
|
||||
}) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await schedulesApi.upsert(props.themeId, {
|
||||
enabled: patch.enabled ?? enabled(),
|
||||
days: patch.days ?? selectedDays(),
|
||||
time_utc: patch.time_utc ?? timeUtc(),
|
||||
emails: patch.emails ?? emails(),
|
||||
});
|
||||
setSchedule(updated);
|
||||
addToast({ type: 'success', message: t('schedule.saved'), duration: 3000 });
|
||||
} catch (err) {
|
||||
const msg = isApiError(err) ? err.message : t('common.error');
|
||||
addToast({ type: 'error', message: msg, duration: 5000 });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Toggle enabled ----
|
||||
const handleToggleEnabled = (checked: boolean) => {
|
||||
setEnabled(checked);
|
||||
void save({ enabled: checked });
|
||||
};
|
||||
|
||||
// ---- Toggle a day ----
|
||||
const toggleDay = (code: string) => {
|
||||
const next = selectedDays().includes(code)
|
||||
? selectedDays().filter((d) => d !== code)
|
||||
: [...selectedDays(), code];
|
||||
setSelectedDays(next);
|
||||
void save({ days: next });
|
||||
};
|
||||
|
||||
// ---- Time change ----
|
||||
const handleTimeChange = (value: string) => {
|
||||
setTimeUtc(value);
|
||||
void save({ time_utc: value });
|
||||
};
|
||||
|
||||
// ---- Email management ----
|
||||
const handleAddEmail = () => {
|
||||
const email = newEmail().trim();
|
||||
if (!email || emails().length >= 3 || emails().includes(email)) return;
|
||||
const next = [...emails(), email];
|
||||
setEmails(next);
|
||||
setNewEmail('');
|
||||
void save({ emails: next });
|
||||
};
|
||||
|
||||
const handleRemoveEmail = (email: string) => {
|
||||
const next = emails().filter((e) => e !== email);
|
||||
setEmails(next);
|
||||
void save({ emails: next });
|
||||
};
|
||||
|
||||
// ---- Delete schedule ----
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await schedulesApi.remove(props.themeId);
|
||||
setSchedule(null);
|
||||
setEnabled(false);
|
||||
setSelectedDays([]);
|
||||
setTimeUtc('08:00');
|
||||
setEmails([]);
|
||||
addToast({ type: 'success', message: t('schedule.deleted'), duration: 3000 });
|
||||
} catch (err) {
|
||||
const msg = isApiError(err) ? err.message : t('common.error');
|
||||
addToast({ type: 'error', message: msg, duration: 5000 });
|
||||
}
|
||||
};
|
||||
|
||||
const isDisabled = () => !enabled();
|
||||
|
||||
return (
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-1">{t('schedule.title')}</h2>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
{t('schedule.noSchedule')}
|
||||
</p>
|
||||
<div class="bg-white shadow-sm rounded-lg border border-gray-200 p-6 space-y-4">
|
||||
|
||||
{/* Enable toggle */}
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="schedule-enabled"
|
||||
checked={enabled()}
|
||||
onChange={(e) => handleToggleEnabled(e.currentTarget.checked)}
|
||||
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label
|
||||
for="schedule-enabled"
|
||||
class="text-sm font-medium text-gray-700"
|
||||
>
|
||||
{t('schedule.enabled')}
|
||||
</label>
|
||||
<Show when={saving()}>
|
||||
<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-indigo-600" />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Day picker */}
|
||||
<div class={isDisabled() ? 'opacity-50 pointer-events-none' : ''}>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
{t('schedule.days')}
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<For each={DAYS}>
|
||||
{(day) => (
|
||||
<button
|
||||
type="button"
|
||||
class={`w-9 h-9 rounded-full text-sm font-medium transition-colors ${
|
||||
selectedDays().includes(day.code)
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
onClick={() => toggleDay(day.code)}
|
||||
>
|
||||
{t(day.labelKey)}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time input */}
|
||||
<div class={isDisabled() ? 'opacity-50 pointer-events-none' : ''}>
|
||||
<label
|
||||
for="schedule-time"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
{t('schedule.time')}
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
id="schedule-time"
|
||||
value={timeUtc()}
|
||||
onChange={(e) => handleTimeChange(e.currentTarget.value)}
|
||||
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md py-2 px-3 border"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email recipients */}
|
||||
<div class={isDisabled() ? 'opacity-50 pointer-events-none' : ''}>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
{t('schedule.emails')}
|
||||
</label>
|
||||
|
||||
{/* Existing emails */}
|
||||
<div class="space-y-2 mb-3">
|
||||
<For each={emails()}>
|
||||
{(email) => (
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-700 flex-1 font-mono">{email}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveEmail(email)}
|
||||
class="p-1 text-gray-400 hover:text-red-600 transition-colors"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* Add email row (hidden when 3 reached) */}
|
||||
<Show when={emails().length < 3}>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="email"
|
||||
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block flex-1 sm:text-sm border-gray-300 rounded-md py-2 px-3 border"
|
||||
placeholder={t('schedule.addEmail')}
|
||||
value={newEmail()}
|
||||
onInput={(e) => setNewEmail(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddEmail();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddEmail}
|
||||
disabled={!newEmail().trim()}
|
||||
class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
||||
>
|
||||
<Plus class="h-4 w-4 mr-1" />
|
||||
{t('schedule.addEmail')}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={emails().length >= 3}>
|
||||
<p class="text-xs text-gray-500">{t('schedule.maxEmails')}</p>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Delete schedule (only shown when a schedule exists) */}
|
||||
<Show when={schedule()}>
|
||||
<div class="pt-2 border-t border-gray-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-md text-red-700 bg-red-50 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-red-500"
|
||||
>
|
||||
<Trash2 class="h-3 w-3 mr-1.5" />
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsSchedule;
|
||||
Loading…
Reference in New Issue