vasuzex 2.3.13 → 2.3.15
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/CHANGELOG.md +129 -0
- package/framework/Database/Model.js +18 -5
- package/framework/Services/Media/MediaManager.js +213 -118
- package/frontend/react-ui/components/BreadCrumb/BreadCrumb.jsx +3 -3
- package/frontend/react-ui/components/DataTable/ActionDefaults.jsx +116 -2
- package/frontend/react-ui/components/DataTable/CellComponents/RowActionsCell.jsx +26 -5
- package/frontend/react-ui/components/DataTable/DataTable.jsx +168 -26
- package/frontend/react-ui/components/DataTable/Filters.jsx +80 -41
- package/frontend/react-ui/components/DataTable/MobileCardList.jsx +226 -0
- package/frontend/react-ui/components/DataTable/Pagination.jsx +120 -57
- package/frontend/react-ui/components/DataTable/TableBody.jsx +85 -24
- package/frontend/react-ui/components/DataTable/TableState.jsx +42 -13
- package/frontend/react-ui/hooks/index.js +1 -0
- package/frontend/react-ui/hooks/useMobileDetect.js +30 -0
- package/package.json +1 -1
|
@@ -44,15 +44,31 @@ export function RowActionsCell({
|
|
|
44
44
|
}) {
|
|
45
45
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
46
46
|
const menuRef = useRef(null);
|
|
47
|
-
|
|
47
|
+
const btnRef = useRef(null);
|
|
48
|
+
const dropdownRef = useRef(null);
|
|
49
|
+
const [menuPos, setMenuPos] = useState({ top: 0, right: 0 });
|
|
50
|
+
|
|
48
51
|
// Determine which approval actions to show based on current status
|
|
49
52
|
const showApprove = hasApproval && (row.approval_status === 'pending' || row.approval_status === 'rejected');
|
|
50
53
|
const showReject = hasApproval && (row.approval_status === 'pending' || row.approval_status === 'approved');
|
|
51
54
|
|
|
55
|
+
const handleToggleMenu = () => {
|
|
56
|
+
if (!isMenuOpen && btnRef.current) {
|
|
57
|
+
const rect = btnRef.current.getBoundingClientRect();
|
|
58
|
+
setMenuPos({
|
|
59
|
+
top: rect.bottom + 4,
|
|
60
|
+
right: window.innerWidth - rect.right,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
setIsMenuOpen((prev) => !prev);
|
|
64
|
+
};
|
|
65
|
+
|
|
52
66
|
// Close menu when clicking outside
|
|
53
67
|
useEffect(() => {
|
|
54
68
|
const handleClickOutside = (event) => {
|
|
55
|
-
|
|
69
|
+
const clickedBtn = btnRef.current && btnRef.current.contains(event.target);
|
|
70
|
+
const clickedMenu = dropdownRef.current && dropdownRef.current.contains(event.target);
|
|
71
|
+
if (!clickedBtn && !clickedMenu) {
|
|
56
72
|
setIsMenuOpen(false);
|
|
57
73
|
}
|
|
58
74
|
};
|
|
@@ -98,9 +114,10 @@ export function RowActionsCell({
|
|
|
98
114
|
)}
|
|
99
115
|
|
|
100
116
|
{/* Overflow Menu - Secondary & Destructive Actions */}
|
|
101
|
-
<div className="relative"
|
|
117
|
+
<div className="relative">
|
|
102
118
|
<button
|
|
103
|
-
|
|
119
|
+
ref={btnRef}
|
|
120
|
+
onClick={handleToggleMenu}
|
|
104
121
|
className="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-md transition-colors dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-700"
|
|
105
122
|
title="More Actions"
|
|
106
123
|
aria-label="More Actions"
|
|
@@ -110,7 +127,11 @@ export function RowActionsCell({
|
|
|
110
127
|
</button>
|
|
111
128
|
|
|
112
129
|
{isMenuOpen && (
|
|
113
|
-
<div
|
|
130
|
+
<div
|
|
131
|
+
ref={dropdownRef}
|
|
132
|
+
style={{ position: 'fixed', top: menuPos.top, right: menuPos.right }}
|
|
133
|
+
className="w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-[9999] py-1"
|
|
134
|
+
>
|
|
114
135
|
{/* Approval Actions - Show based on current status */}
|
|
115
136
|
{(showApprove || showReject) && (
|
|
116
137
|
<>
|
|
@@ -17,10 +17,12 @@
|
|
|
17
17
|
|
|
18
18
|
import React from "react";
|
|
19
19
|
import { TableBody } from "./TableBody.jsx";
|
|
20
|
+
import { MobileCardList } from "./MobileCardList.jsx";
|
|
20
21
|
import { Filters } from "./Filters.jsx";
|
|
21
22
|
import { TableHeader } from "./TableHeader.jsx";
|
|
22
23
|
import { TableState } from "./TableState.jsx";
|
|
23
24
|
import { Pagination } from "./Pagination.jsx";
|
|
25
|
+
import { useMobileDetect } from "../../hooks/useMobileDetect.js";
|
|
24
26
|
|
|
25
27
|
// Conditional import for React Router (optional dependency)
|
|
26
28
|
let useSearchParamsHook = null;
|
|
@@ -58,6 +60,7 @@ try {
|
|
|
58
60
|
* @param {number} props.initialLimit - Initial rows per page
|
|
59
61
|
* @param {string} props.emptyText - Text to show when no data
|
|
60
62
|
* @param {boolean} props.persistState - Enable URL state persistence (default: true)
|
|
63
|
+
* @param {boolean} props.mobileCardView - Render as stacked cards on mobile < 640px (default: false)
|
|
61
64
|
*/
|
|
62
65
|
|
|
63
66
|
// URL params that DataTable owns — all other params (e.g. trashed) are preserved as-is
|
|
@@ -85,8 +88,14 @@ export function DataTable(props) {
|
|
|
85
88
|
onToggle,
|
|
86
89
|
api, // API client instance passed as prop
|
|
87
90
|
persistState = true, // Enable URL-based state persistence by default
|
|
91
|
+
trashable = false, // Enable trash/restore UI (Live / With Trash / Trash tabs)
|
|
92
|
+
restoreUrl, // URL template for restore, e.g. "/products/:id/restore"
|
|
93
|
+
initialTrashed = 'without', // Initial trash mode: 'without' | 'with' | 'only'
|
|
94
|
+
mobileCardView = false, // Render as stacked cards on mobile (< 640px)
|
|
88
95
|
} = props;
|
|
89
96
|
|
|
97
|
+
const isMobile = useMobileDetect(640);
|
|
98
|
+
|
|
90
99
|
// Validate that api client is provided
|
|
91
100
|
if (!api) {
|
|
92
101
|
throw new Error('DataTable requires "api" prop - pass your API client instance');
|
|
@@ -176,8 +185,11 @@ export function DataTable(props) {
|
|
|
176
185
|
// Abort controller for in-flight requests — cancelled whenever new fetch params arrive
|
|
177
186
|
const abortControllerRef = React.useRef(null);
|
|
178
187
|
|
|
188
|
+
const [trashed, setTrashed] = React.useState(initialTrashed);
|
|
189
|
+
|
|
179
190
|
const [data, setData] = React.useState([]);
|
|
180
|
-
|
|
191
|
+
// Start as true: skeleton shows immediately on first render before the initial fetch.
|
|
192
|
+
const [loading, setLoading] = React.useState(true);
|
|
181
193
|
const [totalPages, setTotalPages] = React.useState(1);
|
|
182
194
|
const [totalItems, setTotalItems] = React.useState(0);
|
|
183
195
|
|
|
@@ -325,6 +337,24 @@ export function DataTable(props) {
|
|
|
325
337
|
setPage(1);
|
|
326
338
|
}, [debouncedColumnSearch]);
|
|
327
339
|
|
|
340
|
+
// useLayoutEffect fires synchronously after DOM mutation, before paint.
|
|
341
|
+
// This guarantees the skeleton is committed to the DOM before ANY passive
|
|
342
|
+
// effect (useEffect) runs — which is where fetchData and the API call live.
|
|
343
|
+
// Without this, React 18 + createRoot batches setLoading(true) with the
|
|
344
|
+
// fast localhost API response into one render, so the skeleton never paints.
|
|
345
|
+
//
|
|
346
|
+
// Skip the very first run (initial mount) because loading already starts as
|
|
347
|
+
// true, and fetchData's own useEffect handles the first fetch.
|
|
348
|
+
const isFirstRender = React.useRef(true);
|
|
349
|
+
React.useLayoutEffect(() => {
|
|
350
|
+
if (isFirstRender.current) {
|
|
351
|
+
isFirstRender.current = false;
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
setLoading(true);
|
|
355
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
356
|
+
}, [page, sortBy, sortOrder, statusFilter, limit, search, debouncedColumnSearch, trashable, trashed, refreshKey, refreshSignal]);
|
|
357
|
+
|
|
328
358
|
const fetchData = React.useCallback(async () => {
|
|
329
359
|
// Cancel any in-flight request before starting a new one
|
|
330
360
|
if (abortControllerRef.current) {
|
|
@@ -333,7 +363,9 @@ export function DataTable(props) {
|
|
|
333
363
|
abortControllerRef.current = new AbortController();
|
|
334
364
|
const signal = abortControllerRef.current.signal;
|
|
335
365
|
|
|
336
|
-
|
|
366
|
+
// loading=true is already set synchronously by useLayoutEffect before this
|
|
367
|
+
// effect runs. Do not set it here — that caused React 18 batching issues.
|
|
368
|
+
let wasAborted = false;
|
|
337
369
|
try {
|
|
338
370
|
const params = new URLSearchParams({
|
|
339
371
|
page: page.toString(),
|
|
@@ -348,6 +380,9 @@ export function DataTable(props) {
|
|
|
348
380
|
if (value) params.append(`columnSearch[${field}]`, value);
|
|
349
381
|
});
|
|
350
382
|
|
|
383
|
+
// Append trashed param when trashable mode is active
|
|
384
|
+
if (trashable) params.append('trashed', trashed);
|
|
385
|
+
|
|
351
386
|
// Properly append params to apiUrl (check if apiUrl already has query params)
|
|
352
387
|
const separator = apiUrl.includes('?') ? '&' : '?';
|
|
353
388
|
const result = await api.get(`${apiUrl}${separator}${params}`, { signal });
|
|
@@ -362,35 +397,94 @@ export function DataTable(props) {
|
|
|
362
397
|
setTotalPages(pagination?.totalPages || 1);
|
|
363
398
|
setTotalItems(pagination?.total || 0);
|
|
364
399
|
} catch (err) {
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
if (err && err.code === 'ERR_CANCELED')
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
400
|
+
// Abort errors are expected when a newer request supersedes this one — do not
|
|
401
|
+
// reset loading state because the new request is already in flight with loading=true.
|
|
402
|
+
if (err && (err.name === 'AbortError' || err.code === 'ERR_CANCELED')) {
|
|
403
|
+
wasAborted = true;
|
|
404
|
+
} else {
|
|
405
|
+
setData([]);
|
|
406
|
+
setTotalPages(1);
|
|
407
|
+
setTotalItems(0);
|
|
408
|
+
}
|
|
371
409
|
} finally {
|
|
372
|
-
|
|
410
|
+
// Only clear loading when this request completed normally (not aborted).
|
|
411
|
+
// An aborted request means a newer fetch is already running with loading=true.
|
|
412
|
+
if (!wasAborted) {
|
|
413
|
+
setLoading(false);
|
|
414
|
+
}
|
|
373
415
|
}
|
|
374
|
-
}, [api, apiUrl, page, sortBy, sortOrder, statusFilter, limit, search, debouncedColumnSearch]);
|
|
375
|
-
|
|
376
|
-
//
|
|
416
|
+
}, [api, apiUrl, page, sortBy, sortOrder, statusFilter, limit, search, debouncedColumnSearch, trashable, trashed]);
|
|
417
|
+
|
|
418
|
+
// Always-current ref to fetchData — lets refreshSignal and refreshKey effects
|
|
419
|
+
// call the latest fetchData without including fetchData in their dep arrays.
|
|
420
|
+
// This prevents double-fetches: if fetchData were in refreshSignal's deps, the
|
|
421
|
+
// effect would fire on EVERY sort/search (because fetchData recreates whenever
|
|
422
|
+
// its own deps change), causing two concurrent requests.
|
|
423
|
+
const fetchDataRef = React.useRef(fetchData);
|
|
424
|
+
fetchDataRef.current = fetchData;
|
|
425
|
+
|
|
426
|
+
// Trigger fetchData for main params.
|
|
427
|
+
//
|
|
428
|
+
// Why rAF + setTimeout(0):
|
|
429
|
+
// Browser frame sequence: macro-task → microtasks → rAF callbacks → layout+PAINT → next macro-task.
|
|
430
|
+
// useLayoutEffect commits loading=true (skeleton) synchronously during the commit phase.
|
|
431
|
+
// Passive effects (useEffect) run as a MessageChannel macro-task.
|
|
432
|
+
// Inside that macro-task we schedule rAF, which fires PRE-paint of the current frame.
|
|
433
|
+
// Inside rAF we schedule setTimeout(0), which queues as a MACRO-TASK — meaning it fires
|
|
434
|
+
// AFTER the browser completes layout+paint for the current frame.
|
|
435
|
+
// Result: skeleton is guaranteed to be painted to screen before fetchData() opens the XHR.
|
|
377
436
|
React.useEffect(() => {
|
|
378
|
-
|
|
437
|
+
let rafId;
|
|
438
|
+
let timerId;
|
|
439
|
+
rafId = requestAnimationFrame(() => {
|
|
440
|
+
timerId = setTimeout(() => {
|
|
441
|
+
fetchDataRef.current();
|
|
442
|
+
}, 0);
|
|
443
|
+
});
|
|
444
|
+
return () => {
|
|
445
|
+
cancelAnimationFrame(rafId);
|
|
446
|
+
clearTimeout(timerId);
|
|
447
|
+
};
|
|
379
448
|
}, [fetchData]);
|
|
380
449
|
|
|
381
|
-
// Trigger fetchData when refreshSignal changes
|
|
450
|
+
// Trigger fetchData when refreshSignal changes.
|
|
451
|
+
// fetchData is intentionally NOT in deps — we use fetchDataRef to avoid re-firing
|
|
452
|
+
// this effect on every sort/search (which would cause a double-fetch).
|
|
453
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
382
454
|
React.useEffect(() => {
|
|
383
455
|
if (typeof refreshSignal !== 'undefined') {
|
|
384
|
-
|
|
456
|
+
let rafId;
|
|
457
|
+
let timerId;
|
|
458
|
+
rafId = requestAnimationFrame(() => {
|
|
459
|
+
timerId = setTimeout(() => {
|
|
460
|
+
fetchDataRef.current();
|
|
461
|
+
}, 0);
|
|
462
|
+
});
|
|
463
|
+
return () => {
|
|
464
|
+
cancelAnimationFrame(rafId);
|
|
465
|
+
clearTimeout(timerId);
|
|
466
|
+
};
|
|
385
467
|
}
|
|
386
|
-
}, [refreshSignal
|
|
468
|
+
}, [refreshSignal]);
|
|
387
469
|
|
|
388
|
-
// Internal refresh after status toggle
|
|
470
|
+
// Internal refresh after status toggle.
|
|
471
|
+
// fetchData is intentionally NOT in deps — same reason as refreshSignal above.
|
|
472
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
389
473
|
React.useEffect(() => {
|
|
390
474
|
if (refreshKey > 0) {
|
|
391
|
-
|
|
475
|
+
let rafId;
|
|
476
|
+
let timerId;
|
|
477
|
+
rafId = requestAnimationFrame(() => {
|
|
478
|
+
timerId = setTimeout(() => {
|
|
479
|
+
fetchDataRef.current();
|
|
480
|
+
}, 0);
|
|
481
|
+
});
|
|
482
|
+
return () => {
|
|
483
|
+
cancelAnimationFrame(rafId);
|
|
484
|
+
clearTimeout(timerId);
|
|
485
|
+
};
|
|
392
486
|
}
|
|
393
|
-
}, [refreshKey
|
|
487
|
+
}, [refreshKey]);
|
|
394
488
|
|
|
395
489
|
// Abort any in-flight request when the component unmounts
|
|
396
490
|
React.useEffect(() => {
|
|
@@ -415,10 +509,12 @@ export function DataTable(props) {
|
|
|
415
509
|
setPage(newPage);
|
|
416
510
|
};
|
|
417
511
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
512
|
+
// Mobile card view — rendered when mobileCardView=true and on mobile
|
|
513
|
+
// Renders flat (no inner card) so the page's outer card is the visual container
|
|
514
|
+
if (mobileCardView && isMobile) {
|
|
515
|
+
return (
|
|
516
|
+
<div className="w-full text-sm">
|
|
517
|
+
{/* Filters row — sits at top of the page's outer card */}
|
|
422
518
|
<div className="border-b border-gray-100 p-3 dark:border-white/[0.05]">
|
|
423
519
|
<Filters
|
|
424
520
|
statusFilter={statusFilter}
|
|
@@ -429,9 +525,54 @@ export function DataTable(props) {
|
|
|
429
525
|
setLimit={setLimit}
|
|
430
526
|
dataLength={data.length}
|
|
431
527
|
totalItems={totalItems}
|
|
528
|
+
onRefresh={() => setRefreshKey((k) => k + 1)}
|
|
529
|
+
trashable={trashable}
|
|
530
|
+
trashed={trashed}
|
|
531
|
+
setTrashed={(val) => { setTrashed(val); setPage(1); }}
|
|
432
532
|
/>
|
|
433
533
|
</div>
|
|
434
|
-
<
|
|
534
|
+
<MobileCardList
|
|
535
|
+
api={api}
|
|
536
|
+
data={data}
|
|
537
|
+
columns={columns}
|
|
538
|
+
actions={actions}
|
|
539
|
+
loading={loading}
|
|
540
|
+
emptyText={emptyText}
|
|
541
|
+
resourceName={resourceName}
|
|
542
|
+
resourceIdField={resourceIdField}
|
|
543
|
+
onRefresh={() => setRefreshKey((k) => k + 1)}
|
|
544
|
+
trashMode={trashed}
|
|
545
|
+
restoreUrl={restoreUrl}
|
|
546
|
+
/>
|
|
547
|
+
{/* Pagination inside the page card — connected visually */}
|
|
548
|
+
{totalPages > 1 && (
|
|
549
|
+
<Pagination page={page} totalPages={totalPages} onPageChange={handlePageChange} />
|
|
550
|
+
)}
|
|
551
|
+
</div>
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return (
|
|
556
|
+
<div className="w-full text-sm">
|
|
557
|
+
<div className="border-b border-gray-100 p-3 dark:border-white/[0.05]">
|
|
558
|
+
<Filters
|
|
559
|
+
statusFilter={statusFilter}
|
|
560
|
+
setStatusFilter={setStatusFilter}
|
|
561
|
+
setPage={setPage}
|
|
562
|
+
page={page}
|
|
563
|
+
limit={limit}
|
|
564
|
+
setLimit={setLimit}
|
|
565
|
+
dataLength={data.length}
|
|
566
|
+
totalItems={totalItems}
|
|
567
|
+
onRefresh={() => setRefreshKey((k) => k + 1)}
|
|
568
|
+
trashable={trashable}
|
|
569
|
+
trashed={trashed}
|
|
570
|
+
setTrashed={(val) => { setTrashed(val); setPage(1); }}
|
|
571
|
+
/>
|
|
572
|
+
</div>
|
|
573
|
+
{/* Table — only this portion scrolls horizontally */}
|
|
574
|
+
<div className="overflow-x-auto">
|
|
575
|
+
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
|
435
576
|
<TableHeader
|
|
436
577
|
columns={columns}
|
|
437
578
|
actions={actions}
|
|
@@ -460,13 +601,14 @@ export function DataTable(props) {
|
|
|
460
601
|
resourceName={resourceName}
|
|
461
602
|
resourceIdField={resourceIdField}
|
|
462
603
|
onRefresh={() => setRefreshKey((k) => k + 1)}
|
|
604
|
+
trashMode={trashed}
|
|
605
|
+
restoreUrl={restoreUrl}
|
|
463
606
|
/>
|
|
464
607
|
)}
|
|
465
608
|
</tbody>
|
|
466
609
|
</table>
|
|
467
610
|
</div>
|
|
468
|
-
|
|
469
|
-
{/* Pagination */}
|
|
611
|
+
{/* Pagination — inside the page's outer card, connected to the table visually */}
|
|
470
612
|
{totalPages > 1 && (
|
|
471
613
|
<Pagination page={page} totalPages={totalPages} onPageChange={handlePageChange} />
|
|
472
614
|
)}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
+
import { RefreshCw, Trash2 } from "lucide-react";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Filters Component - Production Ready
|
|
5
|
-
*
|
|
6
|
-
* Status filters (All/Active/Inactive), rows per page selector,
|
|
7
|
-
*
|
|
6
|
+
*
|
|
7
|
+
* Status filters (All/Active/Inactive), rows per page selector, trash tabs,
|
|
8
|
+
* and a refresh button.
|
|
9
|
+
*
|
|
8
10
|
* @module components/DataTable/Filters
|
|
9
11
|
*/
|
|
10
12
|
export const Filters = ({
|
|
@@ -16,12 +18,17 @@ export const Filters = ({
|
|
|
16
18
|
setLimit,
|
|
17
19
|
dataLength,
|
|
18
20
|
totalItems,
|
|
21
|
+
onRefresh,
|
|
22
|
+
// Trash support
|
|
23
|
+
trashable,
|
|
24
|
+
trashed,
|
|
25
|
+
setTrashed,
|
|
19
26
|
}) => (
|
|
20
|
-
<div className="flex
|
|
21
|
-
{/*
|
|
22
|
-
<div className="flex
|
|
23
|
-
<div className="flex items-center gap-1
|
|
24
|
-
<label className="text-xs">Rows
|
|
27
|
+
<div className="flex flex-col gap-2 px-2 py-2 text-xs text-gray-600 w-full">
|
|
28
|
+
{/* Row 1: Rows-per-page selector + showing info + refresh (always fits on one line) */}
|
|
29
|
+
<div className="flex items-center gap-3 flex-wrap">
|
|
30
|
+
<div className="flex items-center gap-1.5 shrink-0">
|
|
31
|
+
<label className="text-xs text-gray-500">Rows:</label>
|
|
25
32
|
<select
|
|
26
33
|
value={limit}
|
|
27
34
|
onChange={(e) => {
|
|
@@ -37,40 +44,72 @@ export const Filters = ({
|
|
|
37
44
|
<option value="100">100</option>
|
|
38
45
|
</select>
|
|
39
46
|
</div>
|
|
40
|
-
<span className="
|
|
41
|
-
|
|
42
|
-
{Math.min(page * limit, totalItems)} of {totalItems} items
|
|
47
|
+
<span className="text-xs text-gray-500 shrink-0">
|
|
48
|
+
{totalItems === 0 ? "0 items" : `${(page - 1) * limit + 1}–${Math.min(page * limit, totalItems)} of ${totalItems}`}
|
|
43
49
|
</span>
|
|
50
|
+
{onRefresh && (
|
|
51
|
+
<button
|
|
52
|
+
onClick={onRefresh}
|
|
53
|
+
title="Refresh"
|
|
54
|
+
className="ml-auto p-1.5 rounded-md text-gray-500 hover:text-brand-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
55
|
+
>
|
|
56
|
+
<RefreshCw className="h-4 w-4" />
|
|
57
|
+
</button>
|
|
58
|
+
)}
|
|
44
59
|
</div>
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
60
|
+
|
|
61
|
+
{/* Row 2: Status filter + Trash tabs (wraps cleanly on small screens) */}
|
|
62
|
+
{((!trashable || trashed !== 'only') || trashable) && (
|
|
63
|
+
<div className="flex items-center flex-wrap gap-2">
|
|
64
|
+
{/* Status filter buttons — hidden when viewing only-trash */}
|
|
65
|
+
{(!trashable || trashed !== 'only') && (
|
|
66
|
+
<div className="flex items-center gap-1">
|
|
67
|
+
<button
|
|
68
|
+
onClick={() => { setStatusFilter("all"); setPage(1); }}
|
|
69
|
+
className={`rounded-lg px-3 py-1.5 text-sm font-medium transition ${statusFilter === "all" ? "bg-brand-600 text-white shadow-theme-xs" : "bg-white text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-700"}`}
|
|
70
|
+
>
|
|
71
|
+
All
|
|
72
|
+
</button>
|
|
73
|
+
<button
|
|
74
|
+
onClick={() => { setStatusFilter("true"); setPage(1); }}
|
|
75
|
+
className={`rounded-lg px-3 py-1.5 text-sm font-medium transition ${statusFilter === "true" ? "bg-brand-600 text-white shadow-theme-xs" : "bg-white text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-700"}`}
|
|
76
|
+
>
|
|
77
|
+
Active
|
|
78
|
+
</button>
|
|
79
|
+
<button
|
|
80
|
+
onClick={() => { setStatusFilter("false"); setPage(1); }}
|
|
81
|
+
className={`rounded-lg px-3 py-1.5 text-sm font-medium transition ${statusFilter === "false" ? "bg-brand-600 text-white shadow-theme-xs" : "bg-white text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-700"}`}
|
|
82
|
+
>
|
|
83
|
+
Inactive
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
86
|
+
)}
|
|
87
|
+
|
|
88
|
+
{/* Trash tabs (only shown when trashable=true) */}
|
|
89
|
+
{trashable && (
|
|
90
|
+
<div className="flex items-center gap-1">
|
|
91
|
+
<Trash2 className="h-3 w-3 text-gray-400 mr-0.5" />
|
|
92
|
+
<button
|
|
93
|
+
onClick={() => { setTrashed('without'); setPage(1); }}
|
|
94
|
+
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${trashed === 'without' ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}`}
|
|
95
|
+
>
|
|
96
|
+
Live
|
|
97
|
+
</button>
|
|
98
|
+
<button
|
|
99
|
+
onClick={() => { setTrashed('with'); setPage(1); }}
|
|
100
|
+
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${trashed === 'with' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}`}
|
|
101
|
+
>
|
|
102
|
+
With Trash
|
|
103
|
+
</button>
|
|
104
|
+
<button
|
|
105
|
+
onClick={() => { setTrashed('only'); setPage(1); }}
|
|
106
|
+
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${trashed === 'only' ? 'bg-red-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}`}
|
|
107
|
+
>
|
|
108
|
+
Trash
|
|
109
|
+
</button>
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
75
114
|
</div>
|
|
76
115
|
);
|