Refactor scheduler module and enhance scheduled message handling
- Updated `SchedulerValidationError` to include error codes for better error management. - Renamed functions for clarity: `listSchedules` to `listScheduledMessagesDashboard` and `createSchedule` to `createScheduledMessageDashboard`. - Improved validation logic for cron expressions and scheduling times. - Enhanced the `deleteSchedule` function to ensure proper handling of existing scheduled messages. - Adjusted database query order and limits for better performance in fetching scheduled messages.
This commit is contained in:
184
apps/webui/src/components/modules/scheduler-manager.tsx
Normal file
184
apps/webui/src/components/modules/scheduler-manager.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
'use client';
|
||||
|
||||
import type { ScheduledMessageDashboard } from '@nexumi/shared';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslations } from '@/components/locale-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
const EMPTY_DRAFT = {
|
||||
channelId: '',
|
||||
content: '',
|
||||
rolePingId: '',
|
||||
cron: '',
|
||||
runAt: ''
|
||||
};
|
||||
|
||||
function formatTiming(schedule: ScheduledMessageDashboard, t: (key: string) => string): string {
|
||||
if (schedule.cron) {
|
||||
return `${t('modulePages.scheduler.cron')}: ${schedule.cron}`;
|
||||
}
|
||||
if (schedule.runAt) {
|
||||
return `${t('modulePages.scheduler.runAt')}: ${new Date(schedule.runAt).toLocaleString(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
})}`;
|
||||
}
|
||||
return '—';
|
||||
}
|
||||
|
||||
interface SchedulerManagerProps {
|
||||
guildId: string;
|
||||
initialSchedules: ScheduledMessageDashboard[];
|
||||
}
|
||||
|
||||
export function SchedulerManager({ guildId, initialSchedules }: SchedulerManagerProps) {
|
||||
const t = useTranslations();
|
||||
const [schedules, setSchedules] = useState<ScheduledMessageDashboard[]>(initialSchedules);
|
||||
const [draft, setDraft] = useState(EMPTY_DRAFT);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
async function handleCreate() {
|
||||
if (!draft.channelId.trim() || !draft.content.trim() || (!draft.cron.trim() && !draft.runAt)) {
|
||||
toast.error(t('modulePages.scheduler.formIncomplete'));
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const response = await fetch(`/api/guilds/${guildId}/scheduler`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
channelId: draft.channelId.trim(),
|
||||
content: draft.content.trim(),
|
||||
rolePingId: draft.rolePingId.trim() || undefined,
|
||||
cron: draft.cron.trim() || null,
|
||||
runAt: draft.cron.trim() ? null : draft.runAt ? new Date(draft.runAt).toISOString() : null
|
||||
})
|
||||
});
|
||||
const body = (await response.json().catch(() => null)) as
|
||||
| (ScheduledMessageDashboard & { error?: string })
|
||||
| null;
|
||||
if (!response.ok || !body) {
|
||||
toast.error(body?.error ?? t('common.saveError'));
|
||||
return;
|
||||
}
|
||||
setSchedules((prev) => [...prev, body]);
|
||||
setDraft(EMPTY_DRAFT);
|
||||
toast.success(t('modulePages.scheduler.created'));
|
||||
} catch {
|
||||
toast.error(t('common.saveError'));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(schedule: ScheduledMessageDashboard) {
|
||||
if (!window.confirm(t('modulePages.scheduler.confirmDelete'))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/api/guilds/${guildId}/scheduler/${schedule.id}`, { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
toast.error(t('common.saveError'));
|
||||
return;
|
||||
}
|
||||
setSchedules((prev) => prev.filter((entry) => entry.id !== schedule.id));
|
||||
toast.success(t('common.saveSuccess'));
|
||||
} catch {
|
||||
toast.error(t('common.saveError'));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.scheduler.createTitle')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.scheduler.createDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.scheduler.channelId')}</Label>
|
||||
<Input
|
||||
value={draft.channelId}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, channelId: event.target.value }))}
|
||||
placeholder="123456789012345678"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.scheduler.rolePingId')}</Label>
|
||||
<Input
|
||||
value={draft.rolePingId}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, rolePingId: event.target.value }))}
|
||||
placeholder={t('common.optional')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.scheduler.cron')}</Label>
|
||||
<Input
|
||||
value={draft.cron}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, cron: event.target.value }))}
|
||||
placeholder="0 9 * * *"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.scheduler.runAt')}</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={draft.runAt}
|
||||
disabled={Boolean(draft.cron.trim())}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, runAt: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.scheduler.content')}</Label>
|
||||
<Textarea
|
||||
value={draft.content}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, content: event.target.value }))}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('modulePages.scheduler.cronHint')}</p>
|
||||
<Button type="button" onClick={handleCreate} disabled={creating}>
|
||||
<Plus className="size-4" />
|
||||
{creating ? t('common.saving') : t('modulePages.scheduler.create')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.scheduler.listTitle')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.scheduler.listDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{schedules.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t('modulePages.scheduler.empty')}</p>
|
||||
)}
|
||||
{schedules.map((schedule) => (
|
||||
<div key={schedule.id} className="flex flex-wrap items-start justify-between gap-3 rounded-md border border-border p-4">
|
||||
<div>
|
||||
<p className="font-medium">{schedule.content ?? t('modulePages.scheduler.noContent')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
#{schedule.channelId} · {formatTiming(schedule, t)}
|
||||
{schedule.rolePingId ? ` · @${schedule.rolePingId}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon" aria-label={t('common.remove')} onClick={() => handleDelete(schedule)}>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user