@nextclaw/ui 0.5.4 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.html CHANGED
@@ -6,8 +6,8 @@
6
6
  <link rel="icon" type="image/svg+xml" href="/logo.svg" />
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8
8
  <title>NextClaw - 系统配置</title>
9
- <script type="module" crossorigin src="/assets/index-CWt_NEq3.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-BHGBLfqi.css">
9
+ <script type="module" crossorigin src="/assets/index-CsMwBztg.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-B3foa-xK.css">
11
11
  </head>
12
12
 
13
13
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextclaw/ui",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/App.tsx CHANGED
@@ -5,6 +5,7 @@ import { ProvidersList } from '@/components/config/ProvidersList';
5
5
  import { ChannelsList } from '@/components/config/ChannelsList';
6
6
  import { RuntimeConfig } from '@/components/config/RuntimeConfig';
7
7
  import { SessionsConfig } from '@/components/config/SessionsConfig';
8
+ import { CronConfig } from '@/components/config/CronConfig';
8
9
  import { MarketplacePage } from '@/components/marketplace/MarketplacePage';
9
10
  import { useWebSocket } from '@/hooks/useWebSocket';
10
11
  import { Toaster } from 'sonner';
@@ -33,6 +34,7 @@ function AppContent() {
33
34
  <Route path="/channels" element={<ChannelsList />} />
34
35
  <Route path="/runtime" element={<RuntimeConfig />} />
35
36
  <Route path="/sessions" element={<SessionsConfig />} />
37
+ <Route path="/cron" element={<CronConfig />} />
36
38
  <Route path="/marketplace" element={<MarketplacePage />} />
37
39
  <Route path="/" element={<Navigate to="/model" replace />} />
38
40
  <Route path="*" element={<Navigate to="/model" replace />} />
package/src/api/config.ts CHANGED
@@ -11,7 +11,11 @@ import type {
11
11
  ConfigActionExecuteResult,
12
12
  SessionsListView,
13
13
  SessionHistoryView,
14
- SessionPatchUpdate
14
+ SessionPatchUpdate,
15
+ CronListView,
16
+ CronEnableRequest,
17
+ CronRunRequest,
18
+ CronActionResult
15
19
  } from './types';
16
20
 
17
21
  // GET /api/config
@@ -162,3 +166,44 @@ export async function deleteSession(key: string): Promise<{ deleted: boolean }>
162
166
  }
163
167
  return response.data;
164
168
  }
169
+
170
+ // GET /api/cron
171
+ export async function fetchCronJobs(params?: { all?: boolean }): Promise<CronListView> {
172
+ const query = new URLSearchParams();
173
+ if (params?.all) {
174
+ query.set('all', '1');
175
+ }
176
+ const suffix = query.toString();
177
+ const response = await api.get<CronListView>(suffix ? '/api/cron?' + suffix : '/api/cron');
178
+ if (!response.ok) {
179
+ throw new Error(response.error.message);
180
+ }
181
+ return response.data;
182
+ }
183
+
184
+ // DELETE /api/cron/:id
185
+ export async function deleteCronJob(id: string): Promise<{ deleted: boolean }> {
186
+ const response = await api.delete<{ deleted: boolean }>(`/api/cron/${encodeURIComponent(id)}`);
187
+ if (!response.ok) {
188
+ throw new Error(response.error.message);
189
+ }
190
+ return response.data;
191
+ }
192
+
193
+ // PUT /api/cron/:id/enable
194
+ export async function setCronJobEnabled(id: string, data: CronEnableRequest): Promise<CronActionResult> {
195
+ const response = await api.put<CronActionResult>(`/api/cron/${encodeURIComponent(id)}/enable`, data);
196
+ if (!response.ok) {
197
+ throw new Error(response.error.message);
198
+ }
199
+ return response.data;
200
+ }
201
+
202
+ // POST /api/cron/:id/run
203
+ export async function runCronJob(id: string, data: CronRunRequest): Promise<CronActionResult> {
204
+ const response = await api.post<CronActionResult>(`/api/cron/${encodeURIComponent(id)}/run`, data);
205
+ if (!response.ok) {
206
+ throw new Error(response.error.message);
207
+ }
208
+ return response.data;
209
+ }
package/src/api/types.ts CHANGED
@@ -92,6 +92,56 @@ export type SessionPatchUpdate = {
92
92
  clearHistory?: boolean;
93
93
  };
94
94
 
95
+ export type CronScheduleView =
96
+ | { kind: "at"; atMs?: number | null }
97
+ | { kind: "every"; everyMs?: number | null }
98
+ | { kind: "cron"; expr?: string | null; tz?: string | null };
99
+
100
+ export type CronPayloadView = {
101
+ kind?: "system_event" | "agent_turn";
102
+ message: string;
103
+ deliver?: boolean;
104
+ channel?: string | null;
105
+ to?: string | null;
106
+ };
107
+
108
+ export type CronJobStateView = {
109
+ nextRunAt?: string | null;
110
+ lastRunAt?: string | null;
111
+ lastStatus?: "ok" | "error" | "skipped" | null;
112
+ lastError?: string | null;
113
+ };
114
+
115
+ export type CronJobView = {
116
+ id: string;
117
+ name: string;
118
+ enabled: boolean;
119
+ schedule: CronScheduleView;
120
+ payload: CronPayloadView;
121
+ state: CronJobStateView;
122
+ createdAt: string;
123
+ updatedAt: string;
124
+ deleteAfterRun: boolean;
125
+ };
126
+
127
+ export type CronListView = {
128
+ jobs: CronJobView[];
129
+ total: number;
130
+ };
131
+
132
+ export type CronEnableRequest = {
133
+ enabled: boolean;
134
+ };
135
+
136
+ export type CronRunRequest = {
137
+ force?: boolean;
138
+ };
139
+
140
+ export type CronActionResult = {
141
+ job: CronJobView | null;
142
+ executed?: boolean;
143
+ };
144
+
95
145
  export type RuntimeConfigUpdate = {
96
146
  agents?: {
97
147
  defaults?: {
@@ -0,0 +1,292 @@
1
+ import { useMemo, useState } from 'react';
2
+ import type { CronJobView } from '@/api/types';
3
+ import { useConfirmDialog } from '@/hooks/useConfirmDialog';
4
+ import { useCronJobs, useDeleteCronJob, useToggleCronJob, useRunCronJob } from '@/hooks/useConfig';
5
+ import { Button } from '@/components/ui/button';
6
+ import { Input } from '@/components/ui/input';
7
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
8
+ import { Card, CardContent } from '@/components/ui/card';
9
+ import { cn } from '@/lib/utils';
10
+ import { t } from '@/lib/i18n';
11
+ import { AlarmClock, RefreshCw, Trash2, Play, Power } from 'lucide-react';
12
+
13
+ type StatusFilter = 'all' | 'enabled' | 'disabled';
14
+
15
+ function formatDate(value?: string | null): string {
16
+ if (!value) {
17
+ return '-';
18
+ }
19
+ const date = new Date(value);
20
+ if (Number.isNaN(date.getTime())) {
21
+ return value;
22
+ }
23
+ return date.toLocaleString();
24
+ }
25
+
26
+ function formatDateFromMs(value?: number | null): string {
27
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
28
+ return '-';
29
+ }
30
+ const date = new Date(value);
31
+ if (Number.isNaN(date.getTime())) {
32
+ return '-';
33
+ }
34
+ return date.toLocaleString();
35
+ }
36
+
37
+ function formatEveryDuration(ms?: number | null): string {
38
+ if (typeof ms !== 'number' || !Number.isFinite(ms)) {
39
+ return '-';
40
+ }
41
+ const seconds = Math.round(ms / 1000);
42
+ if (seconds < 60) return `${seconds}s`;
43
+ const minutes = Math.round(seconds / 60);
44
+ if (minutes < 60) return `${minutes}m`;
45
+ const hours = Math.round(minutes / 60);
46
+ if (hours < 24) return `${hours}h`;
47
+ const days = Math.round(hours / 24);
48
+ return `${days}d`;
49
+ }
50
+
51
+ function describeSchedule(job: CronJobView): string {
52
+ const schedule = job.schedule;
53
+ if (schedule.kind === 'cron') {
54
+ return schedule.expr ? `cron ${schedule.expr}` : 'cron';
55
+ }
56
+ if (schedule.kind === 'every') {
57
+ return `every ${formatEveryDuration(schedule.everyMs)}`;
58
+ }
59
+ if (schedule.kind === 'at') {
60
+ return `at ${formatDateFromMs(schedule.atMs)}`;
61
+ }
62
+ return '-';
63
+ }
64
+
65
+ function describeDelivery(job: CronJobView): string {
66
+ if (!job.payload.deliver) {
67
+ return '-';
68
+ }
69
+ const channel = job.payload.channel ?? '-';
70
+ const target = job.payload.to ?? '-';
71
+ return `${channel}:${target}`;
72
+ }
73
+
74
+ function matchQuery(job: CronJobView, query: string): boolean {
75
+ const q = query.trim().toLowerCase();
76
+ if (!q) return true;
77
+ const haystack = [
78
+ job.id,
79
+ job.name,
80
+ job.payload.message,
81
+ job.payload.channel ?? '',
82
+ job.payload.to ?? ''
83
+ ].join(' ').toLowerCase();
84
+ return haystack.includes(q);
85
+ }
86
+
87
+ function filterByStatus(job: CronJobView, status: StatusFilter): boolean {
88
+ if (status === 'all') return true;
89
+ if (status === 'enabled') return job.enabled;
90
+ return !job.enabled;
91
+ }
92
+
93
+ export function CronConfig() {
94
+ const [query, setQuery] = useState('');
95
+ const [status, setStatus] = useState<StatusFilter>('all');
96
+ const cronQuery = useCronJobs({ all: true });
97
+ const deleteCronJob = useDeleteCronJob();
98
+ const toggleCronJob = useToggleCronJob();
99
+ const runCronJob = useRunCronJob();
100
+ const { confirm, ConfirmDialog } = useConfirmDialog();
101
+
102
+ const jobs = useMemo(() => {
103
+ const data = cronQuery.data?.jobs ?? [];
104
+ return data
105
+ .filter((job) => matchQuery(job, query))
106
+ .filter((job) => filterByStatus(job, status));
107
+ }, [cronQuery.data, query, status]);
108
+
109
+ const handleDelete = async (job: CronJobView) => {
110
+ const confirmed = await confirm({
111
+ title: `${t('cronDeleteConfirm')}?`,
112
+ description: job.name ? `${job.name} (${job.id})` : job.id,
113
+ variant: 'destructive',
114
+ confirmLabel: t('delete')
115
+ });
116
+ if (!confirmed) return;
117
+ deleteCronJob.mutate({ id: job.id });
118
+ };
119
+
120
+ const handleToggle = async (job: CronJobView) => {
121
+ const nextEnabled = !job.enabled;
122
+ const confirmed = await confirm({
123
+ title: nextEnabled ? `${t('cronEnableConfirm')}?` : `${t('cronDisableConfirm')}?`,
124
+ description: job.name ? `${job.name} (${job.id})` : job.id,
125
+ variant: nextEnabled ? 'default' : 'destructive',
126
+ confirmLabel: nextEnabled ? t('cronEnable') : t('cronDisable')
127
+ });
128
+ if (!confirmed) return;
129
+ toggleCronJob.mutate({ id: job.id, enabled: nextEnabled });
130
+ };
131
+
132
+ const handleRun = async (job: CronJobView) => {
133
+ const force = !job.enabled;
134
+ const confirmed = await confirm({
135
+ title: force ? `${t('cronRunForceConfirm')}?` : `${t('cronRunConfirm')}?`,
136
+ description: job.name ? `${job.name} (${job.id})` : job.id,
137
+ confirmLabel: t('cronRunNow')
138
+ });
139
+ if (!confirmed) return;
140
+ runCronJob.mutate({ id: job.id, force });
141
+ };
142
+
143
+ return (
144
+ <div className="h-[calc(100vh-80px)] w-full max-w-[1200px] mx-auto animate-fade-in flex flex-col pt-6 pb-2">
145
+ <div className="flex items-center justify-between mb-6 shrink-0">
146
+ <div>
147
+ <h2 className="text-2xl font-bold text-gray-900 tracking-tight">{t('cronPageTitle')}</h2>
148
+ <p className="text-sm text-gray-500 mt-1">{t('cronPageDescription')}</p>
149
+ </div>
150
+ <Button
151
+ variant="ghost"
152
+ size="icon"
153
+ className="h-9 w-9 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100"
154
+ onClick={() => cronQuery.refetch()}
155
+ >
156
+ <RefreshCw className={cn('h-4 w-4', cronQuery.isFetching && 'animate-spin')} />
157
+ </Button>
158
+ </div>
159
+
160
+ <Card hover={false} className="mb-6">
161
+ <CardContent className="pt-6">
162
+ <div className="flex flex-wrap gap-3 items-center">
163
+ <div className="relative flex-1 min-w-[240px]">
164
+ <Input
165
+ value={query}
166
+ onChange={(e) => setQuery(e.target.value)}
167
+ placeholder={t('cronSearchPlaceholder')}
168
+ className="pl-9"
169
+ />
170
+ <AlarmClock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
171
+ </div>
172
+ <div className="min-w-[180px]">
173
+ <Select value={status} onValueChange={(value) => setStatus(value as StatusFilter)}>
174
+ <SelectTrigger className="w-full">
175
+ <SelectValue placeholder={t('cronStatusLabel')} />
176
+ </SelectTrigger>
177
+ <SelectContent>
178
+ <SelectItem value="all">{t('cronStatusAll')}</SelectItem>
179
+ <SelectItem value="enabled">{t('cronStatusEnabled')}</SelectItem>
180
+ <SelectItem value="disabled">{t('cronStatusDisabled')}</SelectItem>
181
+ </SelectContent>
182
+ </Select>
183
+ </div>
184
+ <div className="text-xs text-gray-500 ml-auto">
185
+ {t('cronTotalLabel')}: {cronQuery.data?.total ?? 0} / {jobs.length}
186
+ </div>
187
+ </div>
188
+ </CardContent>
189
+ </Card>
190
+
191
+ <div className="flex-1 overflow-auto custom-scrollbar">
192
+ {cronQuery.isLoading ? (
193
+ <div className="text-sm text-gray-400 p-4 text-center">{t('cronLoading')}</div>
194
+ ) : jobs.length === 0 ? (
195
+ <div className="text-sm text-gray-400 p-4 text-center">{t('cronEmpty')}</div>
196
+ ) : (
197
+ <div className="space-y-4">
198
+ {jobs.map((job) => (
199
+ <Card key={job.id} className="border border-gray-200">
200
+ <CardContent className="pt-5 pb-5">
201
+ <div className="flex flex-wrap items-start justify-between gap-4">
202
+ <div className="min-w-[220px] flex-1">
203
+ <div className="flex flex-wrap items-center gap-2">
204
+ <span className="text-sm font-semibold text-gray-900">
205
+ {job.name || job.id}
206
+ </span>
207
+ <span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-gray-100 text-gray-500">
208
+ {job.id}
209
+ </span>
210
+ <span
211
+ className={cn(
212
+ 'text-[10px] font-semibold px-2 py-0.5 rounded-full',
213
+ job.enabled ? 'bg-emerald-50 text-emerald-700' : 'bg-gray-100 text-gray-500'
214
+ )}
215
+ >
216
+ {job.enabled ? t('enabled') : t('disabled')}
217
+ </span>
218
+ {job.deleteAfterRun && (
219
+ <span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-amber-50 text-amber-700">
220
+ {t('cronOneShot')}
221
+ </span>
222
+ )}
223
+ </div>
224
+ <div className="mt-2 text-xs text-gray-500">
225
+ {t('cronScheduleLabel')}: {describeSchedule(job)}
226
+ </div>
227
+ <div className="mt-2 text-sm text-gray-700 whitespace-pre-wrap break-words">
228
+ {job.payload.message}
229
+ </div>
230
+ <div className="mt-2 text-xs text-gray-500">
231
+ {t('cronDeliverTo')}: {describeDelivery(job)}
232
+ </div>
233
+ </div>
234
+ <div className="min-w-[220px] text-xs text-gray-500 space-y-2">
235
+ <div>
236
+ <span className="font-medium text-gray-700">{t('cronNextRun')}:</span>{' '}
237
+ {formatDate(job.state.nextRunAt)}
238
+ </div>
239
+ <div>
240
+ <span className="font-medium text-gray-700">{t('cronLastRun')}:</span>{' '}
241
+ {formatDate(job.state.lastRunAt)}
242
+ </div>
243
+ <div>
244
+ <span className="font-medium text-gray-700">{t('cronLastStatus')}:</span>{' '}
245
+ {job.state.lastStatus ?? '-'}
246
+ </div>
247
+ {job.state.lastError && (
248
+ <div className="text-[11px] text-red-500 break-words">
249
+ {job.state.lastError}
250
+ </div>
251
+ )}
252
+ </div>
253
+ <div className="flex items-start gap-2 flex-wrap justify-end">
254
+ <Button
255
+ variant="subtle"
256
+ size="sm"
257
+ onClick={() => handleRun(job)}
258
+ className="gap-1"
259
+ >
260
+ <Play className="h-3.5 w-3.5" />
261
+ {t('cronRunNow')}
262
+ </Button>
263
+ <Button
264
+ variant={job.enabled ? 'outline' : 'primary'}
265
+ size="sm"
266
+ onClick={() => handleToggle(job)}
267
+ className="gap-1"
268
+ >
269
+ <Power className="h-3.5 w-3.5" />
270
+ {job.enabled ? t('cronDisable') : t('cronEnable')}
271
+ </Button>
272
+ <Button
273
+ variant="destructive"
274
+ size="sm"
275
+ onClick={() => handleDelete(job)}
276
+ className="gap-1"
277
+ >
278
+ <Trash2 className="h-3.5 w-3.5" />
279
+ {t('delete')}
280
+ </Button>
281
+ </div>
282
+ </div>
283
+ </CardContent>
284
+ </Card>
285
+ ))}
286
+ </div>
287
+ )}
288
+ </div>
289
+ <ConfirmDialog />
290
+ </div>
291
+ );
292
+ }
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useMemo, useState } from 'react';
2
2
  import type { SessionEntryView, SessionMessageView } from '@/api/types';
3
+ import { useConfirmDialog } from '@/hooks/useConfirmDialog';
3
4
  import { useDeleteSession, useSessionHistory, useSessions, useUpdateSession } from '@/hooks/useConfig';
4
5
  import { Button } from '@/components/ui/button';
5
6
  import { Input } from '@/components/ui/input';
@@ -143,6 +144,7 @@ export function SessionsConfig() {
143
144
 
144
145
  const updateSession = useUpdateSession();
145
146
  const deleteSession = useDeleteSession();
147
+ const { confirm, ConfirmDialog } = useConfirmDialog();
146
148
 
147
149
  const sessions = useMemo(() => sessionsQuery.data?.sessions ?? [], [sessionsQuery.data?.sessions]);
148
150
  const selectedSession = useMemo(() => sessions.find(s => s.key === selectedKey), [sessions, selectedKey]);
@@ -188,16 +190,26 @@ export function SessionsConfig() {
188
190
  setIsEditingMeta(false); // Close editor on save
189
191
  };
190
192
 
191
- const handleClearHistory = () => {
193
+ const handleClearHistory = async () => {
192
194
  if (!selectedKey) return;
193
- if (window.confirm(t('sessionsClearHistory') + "?")) {
195
+ const confirmed = await confirm({
196
+ title: t('sessionsClearHistory') + '?',
197
+ variant: 'destructive',
198
+ confirmLabel: t('sessionsClearHistory')
199
+ });
200
+ if (confirmed) {
194
201
  updateSession.mutate({ key: selectedKey, data: { clearHistory: true } });
195
202
  }
196
203
  };
197
204
 
198
- const handleDeleteSession = () => {
205
+ const handleDeleteSession = async () => {
199
206
  if (!selectedKey) return;
200
- if (window.confirm(`${t('sessionsDeleteConfirm')} ?`)) {
207
+ const confirmed = await confirm({
208
+ title: t('sessionsDeleteConfirm') + '?',
209
+ variant: 'destructive',
210
+ confirmLabel: t('sessionsDeleteConfirm')
211
+ });
212
+ if (confirmed) {
201
213
  deleteSession.mutate(
202
214
  { key: selectedKey },
203
215
  {
@@ -393,6 +405,7 @@ export function SessionsConfig() {
393
405
  )}
394
406
  </div>
395
407
  </div>
408
+ <ConfirmDialog />
396
409
  </div>
397
410
  );
398
411
  }
@@ -1,6 +1,6 @@
1
1
  import { cn } from '@/lib/utils';
2
2
  import { t } from '@/lib/i18n';
3
- import { Cpu, GitBranch, History, MessageSquare, Sparkles, BookOpen, Store } from 'lucide-react';
3
+ import { Cpu, GitBranch, History, MessageSquare, Sparkles, BookOpen, Store, AlarmClock } from 'lucide-react';
4
4
  import { NavLink } from 'react-router-dom';
5
5
  import { useDocBrowser } from '@/components/doc-browser';
6
6
 
@@ -30,6 +30,11 @@ const navItems = [
30
30
  label: t('sessions'),
31
31
  icon: History,
32
32
  },
33
+ {
34
+ target: '/cron',
35
+ label: t('cron'),
36
+ icon: AlarmClock,
37
+ },
33
38
  {
34
39
  target: '/marketplace',
35
40
  label: 'Marketplace',
@@ -3,6 +3,7 @@ import type { MarketplaceInstalledRecord, MarketplaceItemSummary, MarketplaceMan
3
3
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
4
4
  import { Tabs } from '@/components/ui/tabs-custom';
5
5
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
6
+ import { useConfirmDialog } from '@/hooks/useConfirmDialog';
6
7
  import {
7
8
  useInstallMarketplaceItem,
8
9
  useManageMarketplaceItem,
@@ -391,6 +392,7 @@ export function MarketplacePage() {
391
392
 
392
393
  const installMutation = useInstallMarketplaceItem();
393
394
  const manageMutation = useManageMarketplaceItem();
395
+ const { confirm, ConfirmDialog } = useConfirmDialog();
394
396
 
395
397
  const installedRecords = useMemo(
396
398
  () => installedQuery.data?.records ?? [],
@@ -479,7 +481,7 @@ export function MarketplacePage() {
479
481
  installMutation.mutate({ type: item.type, spec: item.install.spec, kind: item.install.kind });
480
482
  };
481
483
 
482
- const handleManage = (action: MarketplaceManageAction, record: MarketplaceInstalledRecord) => {
484
+ const handleManage = async (action: MarketplaceManageAction, record: MarketplaceInstalledRecord) => {
483
485
  if (manageMutation.isPending) {
484
486
  return;
485
487
  }
@@ -490,7 +492,12 @@ export function MarketplacePage() {
490
492
  }
491
493
 
492
494
  if (action === 'uninstall') {
493
- const confirmed = window.confirm(`Confirm ${action} ${targetId}?`);
495
+ const confirmed = await confirm({
496
+ title: `Uninstall ${targetId}?`,
497
+ description: 'This will remove the extension. You can install it again from the marketplace.',
498
+ confirmLabel: 'Uninstall',
499
+ variant: 'destructive'
500
+ });
494
501
  if (!confirmed) {
495
502
  return;
496
503
  }
@@ -597,6 +604,7 @@ export function MarketplacePage() {
597
604
  onNext={() => setPage((current) => (totalPages > 0 ? Math.min(totalPages, current + 1) : current + 1))}
598
605
  />
599
606
  )}
607
+ <ConfirmDialog />
600
608
  </div>
601
609
  );
602
610
  }
@@ -0,0 +1,67 @@
1
+ import * as React from 'react';
2
+ import {
3
+ Dialog,
4
+ DialogContent,
5
+ DialogDescription,
6
+ DialogFooter,
7
+ DialogHeader,
8
+ DialogTitle
9
+ } from '@/components/ui/dialog';
10
+ import { Button } from '@/components/ui/button';
11
+
12
+ export type ConfirmDialogVariant = 'default' | 'destructive';
13
+
14
+ export type ConfirmDialogProps = {
15
+ open: boolean;
16
+ onOpenChange: (open: boolean) => void;
17
+ title: string;
18
+ description?: string;
19
+ confirmLabel?: string;
20
+ cancelLabel?: string;
21
+ variant?: ConfirmDialogVariant;
22
+ onConfirm: () => void;
23
+ onCancel: () => void;
24
+ };
25
+
26
+ export const ConfirmDialog = ({
27
+ open,
28
+ onOpenChange,
29
+ title,
30
+ description,
31
+ confirmLabel = 'Confirm',
32
+ cancelLabel = 'Cancel',
33
+ variant = 'default',
34
+ onConfirm,
35
+ onCancel
36
+ }: ConfirmDialogProps) => {
37
+ const handleConfirm = () => {
38
+ onConfirm();
39
+ onOpenChange(false);
40
+ };
41
+ const handleCancel = () => {
42
+ onCancel();
43
+ onOpenChange(false);
44
+ };
45
+ return (
46
+ <Dialog open={open} onOpenChange={onOpenChange}>
47
+ <DialogContent className="[&>:last-child]:hidden" onCloseAutoFocus={(e) => e.preventDefault()}>
48
+ <DialogHeader>
49
+ <DialogTitle>{title}</DialogTitle>
50
+ {description ? <DialogDescription>{description}</DialogDescription> : null}
51
+ </DialogHeader>
52
+ <DialogFooter className="gap-2 sm:gap-0">
53
+ <Button type="button" variant="outline" onClick={handleCancel}>
54
+ {cancelLabel}
55
+ </Button>
56
+ <Button
57
+ type="button"
58
+ variant={variant === 'destructive' ? 'destructive' : 'default'}
59
+ onClick={handleConfirm}
60
+ >
61
+ {confirmLabel}
62
+ </Button>
63
+ </DialogFooter>
64
+ </DialogContent>
65
+ </Dialog>
66
+ );
67
+ };