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