mongo-scheduler-ui 1.0.0

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.
@@ -0,0 +1,372 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import {
3
+ X,
4
+ Save,
5
+ Clock,
6
+ Repeat,
7
+ AlertCircle,
8
+ Database,
9
+ Globe,
10
+ } from "lucide-react";
11
+ import type { Job } from "../types/job";
12
+
13
+ interface JobFormModalProps {
14
+ job?: Job | null;
15
+ isOpen: boolean;
16
+ onClose: () => void;
17
+ onSave: (jobData: Partial<Job>) => Promise<void>;
18
+ }
19
+
20
+ export default function JobFormModal({
21
+ job,
22
+ isOpen,
23
+ onClose,
24
+ onSave,
25
+ }: JobFormModalProps) {
26
+ const [loading, setLoading] = useState(false);
27
+ const [error, setError] = useState<string | null>(null);
28
+
29
+ // Form State
30
+ const [name, setName] = useState("");
31
+ const [runAt, setRunAt] = useState("");
32
+ const [dataJson, setDataJson] = useState("{}");
33
+ const [repeatType, setRepeatType] = useState<"none" | "cron" | "every">(
34
+ "none"
35
+ );
36
+ const [cronExpression, setCronExpression] = useState("");
37
+ const [everyInterval, setEveryInterval] = useState("");
38
+ const [timezone, setTimezone] = useState("");
39
+ const [maxRetries, setMaxRetries] = useState("3");
40
+ const [retryDelay, setRetryDelay] = useState("1000");
41
+
42
+ useEffect(() => {
43
+ if (isOpen) {
44
+ if (job) {
45
+ // Edit Mode
46
+ setName(job.name);
47
+ setRunAt(
48
+ job.nextRunAt
49
+ ? new Date(job.nextRunAt).toISOString().slice(0, 16)
50
+ : ""
51
+ );
52
+ setDataJson(JSON.stringify(job.data || {}, null, 2));
53
+
54
+ if (job.repeat?.cron) {
55
+ setRepeatType("cron");
56
+ setCronExpression(job.repeat.cron);
57
+ setTimezone(job.repeat.timezone || "");
58
+ } else if (job.repeat?.every) {
59
+ setRepeatType("every");
60
+ setEveryInterval(job.repeat.every.toString());
61
+ } else {
62
+ setRepeatType("none");
63
+ }
64
+
65
+ if (job.retry) {
66
+ setMaxRetries(job.retry.maxAttempts?.toString() || "3");
67
+ const delay =
68
+ typeof job.retry.delay === "number" ? job.retry.delay : 1000;
69
+ setRetryDelay(delay.toString());
70
+ }
71
+ } else {
72
+ // Create Mode - Reset
73
+ setName("");
74
+ setRunAt("");
75
+ setDataJson("{}");
76
+ setRepeatType("none");
77
+ setCronExpression("");
78
+ setTimezone("");
79
+ setEveryInterval("");
80
+ setMaxRetries("3");
81
+ setRetryDelay("1000");
82
+ }
83
+ setError(null);
84
+ }
85
+ }, [isOpen, job]);
86
+
87
+ const handleSubmit = async (e: React.FormEvent) => {
88
+ e.preventDefault();
89
+ setLoading(true);
90
+ setError(null);
91
+
92
+ try {
93
+ let parsedData = {};
94
+ try {
95
+ parsedData = JSON.parse(dataJson);
96
+ } catch (err) {
97
+ throw new Error("Invalid JSON in Data field");
98
+ }
99
+
100
+ const payload: any = {
101
+ name,
102
+ data: parsedData,
103
+ runAt: runAt ? new Date(runAt).toISOString() : undefined,
104
+ retry: {
105
+ maxAttempts: parseInt(maxRetries),
106
+ delay: parseInt(retryDelay),
107
+ },
108
+ };
109
+
110
+ if (repeatType === "cron" && cronExpression) {
111
+ payload.repeat = {
112
+ cron: cronExpression,
113
+ ...(timezone && { timezone }),
114
+ };
115
+ } else if (repeatType === "every" && everyInterval) {
116
+ payload.repeat = { every: parseInt(everyInterval) };
117
+ } else {
118
+ payload.repeat = undefined;
119
+ }
120
+
121
+ await onSave(payload);
122
+ onClose();
123
+ } catch (err: any) {
124
+ setError(err.message || "Failed to save job");
125
+ } finally {
126
+ setLoading(false);
127
+ }
128
+ };
129
+
130
+ if (!isOpen) return null;
131
+
132
+ return (
133
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 overflow-y-auto">
134
+ <div className="bg-gray-800 border border-gray-700 rounded-xl w-full max-w-2xl shadow-xl flex flex-col max-h-[90vh]">
135
+ {/* Header */}
136
+ <div className="flex items-center justify-between p-6 border-b border-gray-700 bg-gray-800/50 rounded-t-xl sticky top-0 z-10">
137
+ <div className="flex items-center gap-3">
138
+ <div className="p-2 bg-blue-500/10 rounded-lg">
139
+ {job ? (
140
+ <Database className="w-5 h-5 text-blue-400" />
141
+ ) : (
142
+ <Save className="w-5 h-5 text-green-400" />
143
+ )}
144
+ </div>
145
+ <h2 className="text-xl font-semibold text-white">
146
+ {job ? "Edit Job" : "Create New Job"}
147
+ </h2>
148
+ </div>
149
+ <button
150
+ onClick={onClose}
151
+ className="text-gray-400 hover:text-white transition-colors"
152
+ >
153
+ <X className="w-6 h-6" />
154
+ </button>
155
+ </div>
156
+
157
+ {/* Body */}
158
+ <form
159
+ onSubmit={handleSubmit}
160
+ className="p-6 space-y-6 overflow-y-auto flex-1"
161
+ >
162
+ {error && (
163
+ <div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4 flex items-center gap-3 text-red-400">
164
+ <AlertCircle className="w-5 h-5" />
165
+ <p>{error}</p>
166
+ </div>
167
+ )}
168
+
169
+ <div className="space-y-4">
170
+ {/* Name */}
171
+ <div>
172
+ <label className="block text-sm font-medium text-gray-300 mb-1">
173
+ Job Name
174
+ </label>
175
+ <input
176
+ type="text"
177
+ required
178
+ value={name}
179
+ onChange={(e) => setName(e.target.value)}
180
+ className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2.5 text-white focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 outline-none transition-all"
181
+ placeholder="e.g. send-email-campaign"
182
+ disabled={!!job} // Usually name is immutable or hard to change logic for, but API lets us. Let's keep editable if API allows, but typical patterns lock it. User specifically asked for Create UI for API, API code uses name. I will leave it enabled. Actually, re-reading API code, update doesn't take name. So disable if job exists.
183
+ />
184
+ {job && (
185
+ <p className="text-xs text-gray-500 mt-1">
186
+ Job name cannot be changed once created.
187
+ </p>
188
+ )}
189
+ </div>
190
+
191
+ {/* Run At */}
192
+ <div>
193
+ <label className="block text-sm font-medium text-gray-300 mb-1">
194
+ Run At (Optional)
195
+ </label>
196
+ <div className="relative">
197
+ <Clock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
198
+ <input
199
+ type="datetime-local"
200
+ value={runAt}
201
+ onChange={(e) => setRunAt(e.target.value)}
202
+ className="w-full bg-gray-900 border border-gray-700 rounded-lg pl-10 pr-4 py-2.5 text-white focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 outline-none transition-all [&::-webkit-calendar-picker-indicator]:invert"
203
+ />
204
+ </div>
205
+ <p className="text-xs text-gray-500 mt-1">
206
+ Leave empty to run immediately (or based on repeat schedule)
207
+ </p>
208
+ </div>
209
+
210
+ {/* Repeat Configuration */}
211
+ <div>
212
+ <label className="block text-sm font-medium text-gray-300 mb-1 flex items-center gap-2">
213
+ <Repeat className="w-4 h-4 text-gray-400" />
214
+ Repeat Schedule
215
+ </label>
216
+ <div className="grid grid-cols-3 gap-3 mb-3">
217
+ <button
218
+ type="button"
219
+ onClick={() => setRepeatType("none")}
220
+ className={`px-4 py-2 rounded-lg border text-sm font-medium transition-all ${
221
+ repeatType === "none"
222
+ ? "bg-blue-500/10 border-blue-500 text-blue-400"
223
+ : "bg-gray-900 border-gray-700 text-gray-400 hover:border-gray-600"
224
+ }`}
225
+ >
226
+ None
227
+ </button>
228
+ <button
229
+ type="button"
230
+ onClick={() => setRepeatType("cron")}
231
+ className={`px-4 py-2 rounded-lg border text-sm font-medium transition-all ${
232
+ repeatType === "cron"
233
+ ? "bg-blue-500/10 border-blue-500 text-blue-400"
234
+ : "bg-gray-900 border-gray-700 text-gray-400 hover:border-gray-600"
235
+ }`}
236
+ >
237
+ Cron
238
+ </button>
239
+ <button
240
+ type="button"
241
+ onClick={() => setRepeatType("every")}
242
+ className={`px-4 py-2 rounded-lg border text-sm font-medium transition-all ${
243
+ repeatType === "every"
244
+ ? "bg-blue-500/10 border-blue-500 text-blue-400"
245
+ : "bg-gray-900 border-gray-700 text-gray-400 hover:border-gray-600"
246
+ }`}
247
+ >
248
+ Interval
249
+ </button>
250
+ </div>
251
+
252
+ {repeatType === "cron" && (
253
+ <div className="space-y-3">
254
+ <div>
255
+ <input
256
+ type="text"
257
+ value={cronExpression}
258
+ onChange={(e) => setCronExpression(e.target.value)}
259
+ placeholder="Cron Expression (* * * * *)"
260
+ className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 outline-none"
261
+ />
262
+ </div>
263
+ <div className="relative">
264
+ <Globe className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
265
+ <input
266
+ type="text"
267
+ list="timezones"
268
+ value={timezone}
269
+ onChange={(e) => setTimezone(e.target.value)}
270
+ placeholder="Timezone (e.g. UTC, America/New_York)"
271
+ className="w-full bg-gray-900 border border-gray-700 rounded-lg pl-10 pr-4 py-2 text-white focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 outline-none"
272
+ />
273
+ <datalist id="timezones">
274
+ <option value="UTC" />
275
+ <option value="America/New_York" />
276
+ <option value="America/Los_Angeles" />
277
+ <option value="Europe/London" />
278
+ <option value="Europe/Paris" />
279
+ <option value="Asia/Tokyo" />
280
+ <option value="Asia/Kolkata" />
281
+ <option value="Australia/Sydney" />
282
+ </datalist>
283
+ </div>
284
+ </div>
285
+ )}
286
+ {repeatType === "every" && (
287
+ <div className="flex items-center gap-2">
288
+ <input
289
+ type="number"
290
+ value={everyInterval}
291
+ onChange={(e) => setEveryInterval(e.target.value)}
292
+ placeholder="Time in ms"
293
+ className="flex-1 bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 outline-none"
294
+ />
295
+ <span className="text-gray-400 text-sm">ms</span>
296
+ </div>
297
+ )}
298
+ </div>
299
+
300
+ {/* Retry Configuration */}
301
+ <div className="grid grid-cols-2 gap-4">
302
+ <div>
303
+ <label className="block text-sm font-medium text-gray-300 mb-1">
304
+ Max Retries
305
+ </label>
306
+ <input
307
+ type="number"
308
+ min="0"
309
+ value={maxRetries}
310
+ onChange={(e) => setMaxRetries(e.target.value)}
311
+ className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 outline-none"
312
+ />
313
+ </div>
314
+ <div>
315
+ <label className="block text-sm font-medium text-gray-300 mb-1">
316
+ Retry Delay (ms)
317
+ </label>
318
+ <input
319
+ type="number"
320
+ min="0"
321
+ value={retryDelay}
322
+ onChange={(e) => setRetryDelay(e.target.value)}
323
+ className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 outline-none"
324
+ />
325
+ </div>
326
+ </div>
327
+
328
+ {/* Data JSON */}
329
+ <div>
330
+ <label className="block text-sm font-medium text-gray-300 mb-1">
331
+ Data Payload (JSON)
332
+ </label>
333
+ <textarea
334
+ value={dataJson}
335
+ onChange={(e) => setDataJson(e.target.value)}
336
+ rows={5}
337
+ className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2.5 text-white font-mono text-sm focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 outline-none transition-all resize-none"
338
+ />
339
+ </div>
340
+ </div>
341
+ </form>
342
+
343
+ {/* Footer */}
344
+ <div className="p-6 border-t border-gray-700 bg-gray-800/50 rounded-b-xl flex justify-end gap-3 sticky bottom-0 z-10">
345
+ <button
346
+ onClick={onClose}
347
+ className="px-4 py-2 text-gray-300 hover:text-white transition-colors"
348
+ >
349
+ Cancel
350
+ </button>
351
+ <button
352
+ onClick={handleSubmit}
353
+ disabled={loading}
354
+ className="px-6 py-2 bg-gradient-to-r from-blue-500 to-purple-600 text-white font-medium rounded-lg hover:from-blue-600 hover:to-purple-700 transition-all flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
355
+ >
356
+ {loading ? (
357
+ <>
358
+ <span className="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin" />
359
+ Saving...
360
+ </>
361
+ ) : (
362
+ <>
363
+ <Save className="w-4 h-4" />
364
+ {job ? "Update Job" : "Create Job"}
365
+ </>
366
+ )}
367
+ </button>
368
+ </div>
369
+ </div>
370
+ </div>
371
+ );
372
+ }
@@ -0,0 +1,171 @@
1
+ import { RefreshCw, Trash2, RotateCcw, X, Edit } from 'lucide-react';
2
+ import type { Job } from '../types/job';
3
+ import { formatDate, formatRelativeTime, getStatusColor } from '../utils/helpers';
4
+ import Pagination from './Pagination';
5
+
6
+ interface JobListProps {
7
+ jobs: Job[];
8
+ loading: boolean;
9
+ onJobClick: (job: Job) => void;
10
+ onEdit: (job: Job) => void;
11
+ onRefresh: () => void;
12
+ onDelete: (id: string) => void;
13
+ onRetry: (id: string) => void;
14
+ onCancel: (id: string) => void;
15
+ // Pagination Props
16
+ currentPage: number;
17
+ totalPages: number;
18
+ onPageChange: (page: number) => void;
19
+ itemsPerPage: number;
20
+ onItemsPerPageChange: (items: number) => void;
21
+ totalItems: number;
22
+ }
23
+
24
+ export default function JobList({
25
+ jobs,
26
+ loading,
27
+ onJobClick,
28
+ onEdit,
29
+ onRefresh,
30
+ onDelete,
31
+ onRetry,
32
+ onCancel,
33
+ currentPage,
34
+ totalPages,
35
+ onPageChange,
36
+ itemsPerPage,
37
+ onItemsPerPageChange,
38
+ totalItems
39
+ }: JobListProps) {
40
+ if (loading) {
41
+ return (
42
+ <div className="space-y-3">
43
+ {[...Array(5)].map((_, i) => (
44
+ <div key={i} className="bg-gray-800/50 rounded-lg p-4 animate-pulse">
45
+ <div className="h-4 bg-gray-700 rounded w-3/4 mb-2"></div>
46
+ <div className="h-3 bg-gray-700 rounded w-1/2"></div>
47
+ </div>
48
+ ))}
49
+ </div>
50
+ );
51
+ }
52
+
53
+ if (jobs.length === 0 && totalItems === 0) {
54
+ return (
55
+ <div className="text-center py-16 text-gray-400">
56
+ <p className="text-lg">No jobs found</p>
57
+ <p className="text-sm mt-2">Try adjusting your filters</p>
58
+ </div>
59
+ );
60
+ }
61
+
62
+ return (
63
+ <div className="space-y-3">
64
+ <div className="flex flex-col xl:flex-row justify-between items-start xl:items-center mb-4 gap-4">
65
+ <h3 className="text-lg font-semibold text-white">Jobs</h3>
66
+
67
+ <div className="flex items-center gap-2 self-end xl:self-auto">
68
+ <Pagination
69
+ currentPage={currentPage}
70
+ totalPages={totalPages}
71
+ onPageChange={onPageChange}
72
+ itemsPerPage={itemsPerPage}
73
+ onItemsPerPageChange={onItemsPerPageChange}
74
+ totalItems={totalItems}
75
+ className="!py-0"
76
+ />
77
+ <div className="h-8 w-px bg-gray-700 mx-2 hidden sm:block"></div>
78
+ <button
79
+ onClick={onRefresh}
80
+ className="p-2 rounded-lg bg-gray-800/50 hover:bg-gray-700/50 transition-colors border border-gray-700/50"
81
+ title="Refresh"
82
+ >
83
+ <RefreshCw className="w-5 h-5 text-gray-400" />
84
+ </button>
85
+ </div>
86
+ </div>
87
+
88
+ {jobs.map((job) => (
89
+ <div
90
+ key={job._id}
91
+ onClick={() => onJobClick(job)}
92
+ className="bg-gradient-to-br from-gray-800/80 to-gray-900/80 backdrop-blur-sm rounded-lg p-4 border border-gray-700/50 hover:border-gray-600/50 cursor-pointer transition-all duration-200 group"
93
+ >
94
+ <div className="flex items-start justify-between mb-2">
95
+ <div className="flex-1">
96
+ <h4 className="text-white font-medium mb-1">{job.name}</h4>
97
+ <div className="flex items-center gap-2 text-sm text-gray-400">
98
+ <span className={`px-2 py-1 rounded text-xs border ${getStatusColor(job.status)}`}>
99
+ {job.status}
100
+ </span>
101
+ <span>•</span>
102
+ <span title={formatDate(job.createdAt)}>Created {formatRelativeTime(job.createdAt)}</span>
103
+ </div>
104
+ </div>
105
+
106
+ <div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
107
+ <button
108
+ onClick={(e) => { e.stopPropagation(); onEdit(job); }}
109
+ className="p-2 rounded bg-green-500/20 hover:bg-green-500/30 text-green-400"
110
+ title="Edit"
111
+ >
112
+ <Edit className="w-4 h-4" />
113
+ </button>
114
+ {job.status === 'failed' && (
115
+ <button
116
+ onClick={(e) => { e.stopPropagation(); onRetry(job._id); }}
117
+ className="p-2 rounded bg-blue-500/20 hover:bg-blue-500/30 text-blue-400"
118
+ title="Retry"
119
+ >
120
+ <RotateCcw className="w-4 h-4" />
121
+ </button>
122
+ )}
123
+ {(job.status === 'pending' || job.status === 'running') && (
124
+ <button
125
+ onClick={(e) => { e.stopPropagation(); onCancel(job._id); }}
126
+ className="p-2 rounded bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-400"
127
+ title="Cancel"
128
+ >
129
+ <X className="w-4 h-4" />
130
+ </button>
131
+ )}
132
+ <button
133
+ onClick={(e) => { e.stopPropagation(); onDelete(job._id); }}
134
+ className="p-2 rounded bg-red-500/20 hover:bg-red-500/30 text-red-400"
135
+ title="Delete"
136
+ >
137
+ <Trash2 className="w-4 h-4" />
138
+ </button>
139
+ </div>
140
+ </div>
141
+
142
+ {job.lastError && (
143
+ <div className="mt-2 text-xs text-red-400 bg-red-500/10 rounded px-2 py-1">
144
+ Error: {job.lastError.substring(0, 100)}
145
+ {job.lastError.length > 100 && '...'}
146
+ </div>
147
+ )}
148
+
149
+ <div className="mt-2 flex gap-4 text-xs text-gray-500">
150
+ {job.nextRunAt && (
151
+ <span className="font-medium text-blue-300">
152
+ Run {formatRelativeTime(job.nextRunAt)}
153
+ </span>
154
+ )}
155
+ {job.nextRunAt && (
156
+ <span className="text-gray-600">
157
+ ({formatDate(job.nextRunAt)})
158
+ </span>
159
+ )}
160
+ {job.attempts > 0 && (
161
+ <span>Attempts: {job.attempts}</span>
162
+ )}
163
+ {job.repeat && (
164
+ <span className="text-purple-400">🔁 Repeating</span>
165
+ )}
166
+ </div>
167
+ </div>
168
+ ))}
169
+ </div>
170
+ );
171
+ }
@@ -0,0 +1,107 @@
1
+ import { ChevronLeft, ChevronRight } from 'lucide-react';
2
+
3
+ interface PaginationProps {
4
+ currentPage: number;
5
+ totalPages: number;
6
+ onPageChange: (page: number) => void;
7
+ itemsPerPage: number;
8
+ onItemsPerPageChange: (items: number) => void;
9
+ totalItems: number;
10
+ className?: string;
11
+ }
12
+
13
+ export default function Pagination({
14
+ currentPage,
15
+ totalPages,
16
+ onPageChange,
17
+ itemsPerPage,
18
+ onItemsPerPageChange,
19
+ totalItems,
20
+ className = "",
21
+ }: PaginationProps) {
22
+ const startItem = (currentPage - 1) * itemsPerPage + 1;
23
+ const endItem = Math.min(currentPage * itemsPerPage, totalItems);
24
+
25
+ if (totalItems === 0) return null;
26
+
27
+ return (
28
+ <div className={`flex flex-col sm:flex-row items-center justify-between gap-4 ${className}`}>
29
+ {/* Stats */}
30
+ <div className="text-sm text-gray-400">
31
+ Showing <span className="font-medium text-white">{startItem}</span> to{' '}
32
+ <span className="font-medium text-white">{endItem}</span> of{' '}
33
+ <span className="font-medium text-white">{totalItems}</span> results
34
+ </div>
35
+
36
+ <div className="flex items-center gap-4">
37
+ {/* Rows per page */}
38
+ <div className="flex items-center gap-2">
39
+ <span className="text-sm text-gray-400">Rows per page:</span>
40
+ <select
41
+ value={itemsPerPage}
42
+ onChange={(e) => {
43
+ onItemsPerPageChange(Number(e.target.value));
44
+ onPageChange(1); // Reset to page 1 when changing items per page
45
+ }}
46
+ className="bg-gray-800 border border-gray-700 text-white text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-1.5 outline-none"
47
+ >
48
+ <option value={5}>5</option>
49
+ <option value={10}>10</option>
50
+ <option value={20}>20</option>
51
+ <option value={50}>50</option>
52
+ </select>
53
+ </div>
54
+
55
+ {/* Navigation */}
56
+ <div className="flex items-center gap-1">
57
+ <button
58
+ onClick={() => onPageChange(currentPage - 1)}
59
+ disabled={currentPage === 1}
60
+ className="p-2 rounded-lg bg-gray-800/50 border border-gray-700/50 text-gray-400 hover:text-white hover:bg-gray-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
61
+ aria-label="Previous page"
62
+ >
63
+ <ChevronLeft className="w-4 h-4" />
64
+ </button>
65
+
66
+ <div className="flex items-center gap-1 px-2">
67
+ {Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
68
+ // Logic to show a window of pages around current page
69
+ let pageNum = i + 1;
70
+ if (totalPages > 5) {
71
+ if (currentPage > 3) {
72
+ pageNum = currentPage - 3 + i + 1;
73
+ }
74
+ if (pageNum > totalPages) {
75
+ pageNum = totalPages - (4 - i);
76
+ }
77
+ }
78
+
79
+ return (
80
+ <button
81
+ key={pageNum}
82
+ onClick={() => onPageChange(pageNum)}
83
+ className={`w-8 h-8 rounded-lg text-sm font-medium transition-all ${
84
+ currentPage === pageNum
85
+ ? 'bg-blue-600 text-white shadow-lg shadow-blue-500/20'
86
+ : 'bg-gray-800/50 border border-gray-700/50 text-gray-400 hover:text-white hover:bg-gray-700/50'
87
+ }`}
88
+ >
89
+ {pageNum}
90
+ </button>
91
+ );
92
+ })}
93
+ </div>
94
+
95
+ <button
96
+ onClick={() => onPageChange(currentPage + 1)}
97
+ disabled={currentPage === totalPages}
98
+ className="p-2 rounded-lg bg-gray-800/50 border border-gray-700/50 text-gray-400 hover:text-white hover:bg-gray-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
99
+ aria-label="Next page"
100
+ >
101
+ <ChevronRight className="w-4 h-4" />
102
+ </button>
103
+ </div>
104
+ </div>
105
+ </div>
106
+ );
107
+ }