strapi-content-sync-pro 1.0.5 → 1.0.7

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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2024 Ejaz Husain Arain
3
+ Copyright (c) 2024–2025 Ejaz Hussain Arain. All rights reserved.
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -320,4 +320,5 @@ MIT License - see [LICENSE](LICENSE) for details.
320
320
 
321
321
  **Ejaz Husain Arain**
322
322
  - GitHub: [@eharain](https://github.com/eharain)
323
+ - LinkedIn: [Ejaz Husain Arain](https://www.linkedin.com/in/eharain/)
323
324
  - Email: eharain@yahoo.com
@@ -8,6 +8,7 @@ import {
8
8
  Checkbox,
9
9
  SingleSelect,
10
10
  SingleSelectOption,
11
+ TextInput,
11
12
  Field,
12
13
  Table,
13
14
  Thead,
@@ -18,6 +19,7 @@ import {
18
19
  Badge,
19
20
  Loader,
20
21
  } from '@strapi/design-system';
22
+ import { CaretUp, CaretDown } from '@strapi/icons';
21
23
  import { useFetchClient } from '@strapi/strapi/admin';
22
24
 
23
25
  const PLUGIN_ID = 'strapi-content-sync-pro';
@@ -66,6 +68,18 @@ const BulkTransferTab = ({ syncMode = 'paired' }) => {
66
68
  // 'history' shows the persisted previous-runs table.
67
69
  const [subTab, setSubTab] = useState('run');
68
70
 
71
+ // Chunk table filters + sort
72
+ const [chunkSearch, setChunkSearch] = useState('');
73
+ const [chunkKindFilter, setChunkKindFilter] = useState('');
74
+ const [chunkStatusFilter, setChunkStatusFilter] = useState('');
75
+ const [chunkSortField, setChunkSortField] = useState('');
76
+ const [chunkSortDir, setChunkSortDir] = useState('asc');
77
+
78
+ // History table filters + sort
79
+ const [historySearch, setHistorySearch] = useState('');
80
+ const [historySortField, setHistorySortField] = useState('');
81
+ const [historySortDir, setHistorySortDir] = useState('asc');
82
+
69
83
  const pollRef = useRef(null);
70
84
 
71
85
  const scopeCount = Object.values(scopes).filter(Boolean).length;
@@ -354,6 +368,77 @@ const BulkTransferTab = ({ syncMode = 'paired' }) => {
354
368
  const isActive = isRunning || isPaused;
355
369
  const isTerminal = job && ['success', 'partial', 'cancelled', 'error'].includes(job.status);
356
370
 
371
+ // Derived chunk list (filter + sort)
372
+ const displayedChunks = useMemo(() => {
373
+ let result = [...chunkRows];
374
+ if (chunkSearch.trim()) {
375
+ const q = chunkSearch.trim().toLowerCase();
376
+ result = result.filter(
377
+ (c) => (c.label || '').toLowerCase().includes(q) || (c.kind || '').toLowerCase().includes(q)
378
+ );
379
+ }
380
+ if (chunkKindFilter) {
381
+ result = result.filter((c) => (c.kind || '') === chunkKindFilter);
382
+ }
383
+ if (chunkStatusFilter) {
384
+ result = result.filter((c) => (c.status || '') === chunkStatusFilter);
385
+ }
386
+ if (chunkSortField) {
387
+ result.sort((a, b) => {
388
+ const aVal = a[chunkSortField] ?? '';
389
+ const bVal = b[chunkSortField] ?? '';
390
+ if (typeof aVal === 'string') {
391
+ return chunkSortDir === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
392
+ }
393
+ return chunkSortDir === 'asc' ? aVal - bVal : bVal - aVal;
394
+ });
395
+ }
396
+ return result;
397
+ }, [chunkRows, chunkSearch, chunkKindFilter, chunkStatusFilter, chunkSortField, chunkSortDir]);
398
+
399
+ // Derived history list (filter + sort)
400
+ const displayedHistory = useMemo(() => {
401
+ let result = [...history];
402
+ if (historySearch.trim()) {
403
+ const q = historySearch.trim().toLowerCase();
404
+ result = result.filter((h) => (h.direction || '').toLowerCase().includes(q) || (h.status || '').toLowerCase().includes(q));
405
+ }
406
+ if (historySortField) {
407
+ result.sort((a, b) => {
408
+ const aVal = a[historySortField] ?? '';
409
+ const bVal = b[historySortField] ?? '';
410
+ if (typeof aVal === 'string') {
411
+ return historySortDir === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
412
+ }
413
+ return historySortDir === 'asc' ? aVal - bVal : bVal - aVal;
414
+ });
415
+ }
416
+ return result;
417
+ }, [history, historySearch, historySortField, historySortDir]);
418
+
419
+ const handleChunkSort = (field) => {
420
+ if (chunkSortField === field) setChunkSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
421
+ else { setChunkSortField(field); setChunkSortDir('asc'); }
422
+ };
423
+
424
+ const handleHistorySort = (field) => {
425
+ if (historySortField === field) setHistorySortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
426
+ else { setHistorySortField(field); setHistorySortDir('asc'); }
427
+ };
428
+
429
+ const SortableTh = ({ field, onSort, sortF, sortD, style, children }) => (
430
+ <Th onClick={() => onSort(field)} style={{ cursor: 'pointer', userSelect: 'none', ...style }}>
431
+ <Flex alignItems="center" gap={1}>
432
+ <Typography variant="sigma">{children}</Typography>
433
+ {sortF === field && (sortD === 'asc' ? <CaretUp /> : <CaretDown />)}
434
+ </Flex>
435
+ </Th>
436
+ );
437
+
438
+ // Chunk kind options from current rows
439
+ const chunkKindOptions = useMemo(() => [...new Set(chunkRows.map((c) => c.kind).filter(Boolean))], [chunkRows]);
440
+ const chunkStatusOptions = useMemo(() => [...new Set(chunkRows.map((c) => c.status).filter(Boolean))], [chunkRows]);
441
+
357
442
  const jobStats = useMemo(() => {
358
443
  if (!job) return null;
359
444
  const totals = (job.chunks || []).reduce(
@@ -605,6 +690,56 @@ const BulkTransferTab = ({ syncMode = 'paired' }) => {
605
690
  {job ? `Chunks (${job.cursor}/${job.total})` : `Planned Chunks (${preview?.total || chunkRows.length})`}
606
691
  </Typography>
607
692
  <Box paddingTop={2}>
693
+ {/* Chunk filter bar */}
694
+ <Flex gap={2} wrap="wrap" marginBottom={2} alignItems="flex-end">
695
+ <Box style={{ flex: '1 1 180px', minWidth: 150 }}>
696
+ <TextInput
697
+ placeholder="Search target or kind…"
698
+ value={chunkSearch}
699
+ onChange={(e) => setChunkSearch(e.target.value)}
700
+ label="Search"
701
+ size="S"
702
+ />
703
+ </Box>
704
+ {chunkKindOptions.length > 1 && (
705
+ <Box style={{ minWidth: 130 }}>
706
+ <SingleSelect
707
+ placeholder="All kinds"
708
+ value={chunkKindFilter}
709
+ onChange={setChunkKindFilter}
710
+ onClear={() => setChunkKindFilter('')}
711
+ size="S"
712
+ label="Kind"
713
+ >
714
+ {chunkKindOptions.map((k) => (
715
+ <SingleSelectOption key={k} value={k}>{k}</SingleSelectOption>
716
+ ))}
717
+ </SingleSelect>
718
+ </Box>
719
+ )}
720
+ {job && chunkStatusOptions.length > 1 && (
721
+ <Box style={{ minWidth: 130 }}>
722
+ <SingleSelect
723
+ placeholder="All statuses"
724
+ value={chunkStatusFilter}
725
+ onChange={setChunkStatusFilter}
726
+ onClear={() => setChunkStatusFilter('')}
727
+ size="S"
728
+ label="Status"
729
+ >
730
+ {chunkStatusOptions.map((s) => (
731
+ <SingleSelectOption key={s} value={s}>{s}</SingleSelectOption>
732
+ ))}
733
+ </SingleSelect>
734
+ </Box>
735
+ )}
736
+ {(chunkSearch || chunkKindFilter || chunkStatusFilter) && (
737
+ <Button variant="tertiary" size="S" onClick={() => { setChunkSearch(''); setChunkKindFilter(''); setChunkStatusFilter(''); }}>
738
+ Clear
739
+ </Button>
740
+ )}
741
+ </Flex>
742
+
608
743
  <Table>
609
744
  <Thead>
610
745
  <Tr>
@@ -618,17 +753,23 @@ const BulkTransferTab = ({ syncMode = 'paired' }) => {
618
753
  <Typography variant="sigma">Run</Typography>
619
754
  )}
620
755
  </Th>
621
- <Th style={{ width: 60 }}><Typography variant="sigma">#</Typography></Th>
622
- <Th><Typography variant="sigma">Kind</Typography></Th>
623
- <Th><Typography variant="sigma">Target</Typography></Th>
624
- <Th><Typography variant="sigma">Status</Typography></Th>
756
+ <SortableTh field="index" onSort={handleChunkSort} sortF={chunkSortField} sortD={chunkSortDir} style={{ width: 60 }}>#</SortableTh>
757
+ <SortableTh field="kind" onSort={handleChunkSort} sortF={chunkSortField} sortD={chunkSortDir}>Kind</SortableTh>
758
+ <SortableTh field="label" onSort={handleChunkSort} sortF={chunkSortField} sortD={chunkSortDir}>Target</SortableTh>
759
+ <SortableTh field="status" onSort={handleChunkSort} sortF={chunkSortField} sortD={chunkSortDir}>Status</SortableTh>
625
760
  <Th><Typography variant="sigma">Page</Typography></Th>
626
761
  <Th><Typography variant="sigma">Pushed / Pulled</Typography></Th>
627
762
  <Th><Typography variant="sigma">Notes</Typography></Th>
628
763
  </Tr>
629
764
  </Thead>
630
765
  <Tbody>
631
- {chunkRows.map((c) => {
766
+ {displayedChunks.length === 0 ? (
767
+ <Tr>
768
+ <Td colSpan={8}>
769
+ <Typography textColor="neutral500">No chunks match the current filters.</Typography>
770
+ </Td>
771
+ </Tr>
772
+ ) : displayedChunks.map((c) => {
632
773
  const pageLabel = c.pagesTotal
633
774
  ? `${c.page || 0}/${c.pagesTotal}`
634
775
  : c.page
@@ -666,17 +807,17 @@ const BulkTransferTab = ({ syncMode = 'paired' }) => {
666
807
  <Td><Typography variant="pi">{pageLabel}</Typography></Td>
667
808
  <Td><Typography variant="pi">{pushPullLabel}</Typography></Td>
668
809
  <Td>
669
- {c.error && <Typography textColor="danger600" variant="pi">{c.error}</Typography>}
670
- {!c.error && c.warning && <Typography textColor="warning600" variant="pi">{c.warning}</Typography>}
671
- </Td>
672
- </Tr>
673
- );
674
- })}
675
- </Tbody>
676
- </Table>
677
- </Box>
678
- </Box>
679
- )}
810
+ {c.error && <Typography textColor="danger600" variant="pi">{c.error}</Typography>}
811
+ {!c.error && c.warning && <Typography textColor="warning600" variant="pi">{c.warning}</Typography>}
812
+ </Td>
813
+ </Tr>
814
+ );
815
+ })}
816
+ </Tbody>
817
+ </Table>
818
+ </Box>
819
+ </Box>
820
+ )}
680
821
 
681
822
  {job && job.status && job.status !== 'running' && (
682
823
  <Box paddingTop={4}>
@@ -707,6 +848,24 @@ const BulkTransferTab = ({ syncMode = 'paired' }) => {
707
848
  Paused, cancelled, and completed runs are preserved here. Restart a run from scratch
708
849
  using the same chunk selection, or load its selection into the Run Transfer tab to tweak.
709
850
  </Typography>
851
+
852
+ {history.length > 0 && (
853
+ <Flex gap={2} wrap="wrap" marginTop={3} marginBottom={2} alignItems="flex-end">
854
+ <Box style={{ flex: '1 1 180px', minWidth: 150 }}>
855
+ <TextInput
856
+ placeholder="Search direction or status…"
857
+ value={historySearch}
858
+ onChange={(e) => setHistorySearch(e.target.value)}
859
+ label="Search"
860
+ size="S"
861
+ />
862
+ </Box>
863
+ {historySearch && (
864
+ <Button variant="tertiary" size="S" onClick={() => setHistorySearch('')}>Clear</Button>
865
+ )}
866
+ </Flex>
867
+ )}
868
+
710
869
  <Box paddingTop={2}>
711
870
  {history.length === 0 ? (
712
871
  <Typography variant="pi" textColor="neutral500">No previous runs yet.</Typography>
@@ -714,15 +873,21 @@ const BulkTransferTab = ({ syncMode = 'paired' }) => {
714
873
  <Table>
715
874
  <Thead>
716
875
  <Tr>
717
- <Th><Typography variant="sigma">When</Typography></Th>
718
- <Th><Typography variant="sigma">Direction</Typography></Th>
719
- <Th><Typography variant="sigma">Status</Typography></Th>
876
+ <SortableTh field="startedAt" onSort={handleHistorySort} sortF={historySortField} sortD={historySortDir}>When</SortableTh>
877
+ <SortableTh field="direction" onSort={handleHistorySort} sortF={historySortField} sortD={historySortDir}>Direction</SortableTh>
878
+ <SortableTh field="status" onSort={handleHistorySort} sortF={historySortField} sortD={historySortDir}>Status</SortableTh>
720
879
  <Th><Typography variant="sigma">Chunks</Typography></Th>
721
880
  <Th><Typography variant="sigma">Actions</Typography></Th>
722
881
  </Tr>
723
882
  </Thead>
724
883
  <Tbody>
725
- {history.map((h) => {
884
+ {displayedHistory.length === 0 ? (
885
+ <Tr>
886
+ <Td colSpan={5}>
887
+ <Typography textColor="neutral500">No runs match the search.</Typography>
888
+ </Td>
889
+ </Tr>
890
+ ) : displayedHistory.map((h) => {
726
891
  const selCount = (h.chunks || []).filter((c) => c.selected !== false).length;
727
892
  const doneCount = (h.chunks || []).filter(
728
893
  (c) => c.status === 'success' || c.status === 'partial'
@@ -7,6 +7,7 @@ import {
7
7
  Alert,
8
8
  Switch,
9
9
  Badge,
10
+ TextInput,
10
11
  } from '@strapi/design-system';
11
12
  import { useFetchClient } from '@strapi/strapi/admin';
12
13
 
@@ -20,6 +21,7 @@ const ContentTypesTab = () => {
20
21
  const [profiles, setProfiles] = useState([]);
21
22
  const [loading, setLoading] = useState(true);
22
23
  const [message, setMessage] = useState(null);
24
+ const [search, setSearch] = useState('');
23
25
 
24
26
  useEffect(() => {
25
27
  loadData();
@@ -110,8 +112,25 @@ const ContentTypesTab = () => {
110
112
  </Box>
111
113
  )}
112
114
 
115
+ <Box paddingBottom={4}>
116
+ <TextInput
117
+ placeholder="Search by name or UID…"
118
+ value={search}
119
+ onChange={(e) => setSearch(e.target.value)}
120
+ label="Search"
121
+ size="S"
122
+ style={{ maxWidth: 320 }}
123
+ />
124
+ </Box>
125
+
113
126
  <Box>
114
- {contentTypes.map((ct) => {
127
+ {contentTypes
128
+ .filter((ct) => {
129
+ if (!search.trim()) return true;
130
+ const q = search.trim().toLowerCase();
131
+ return (ct.displayName || '').toLowerCase().includes(q) || ct.uid.toLowerCase().includes(q);
132
+ })
133
+ .map((ct) => {
115
134
  const enabled = isEnabled(ct.uid);
116
135
  const activeProfile = getActiveProfile(ct.uid);
117
136
  const profileCount = getProfileCount(ct.uid);
@@ -151,6 +170,14 @@ const ContentTypesTab = () => {
151
170
  </Box>
152
171
  );
153
172
  })}
173
+ {contentTypes.length > 0 && search.trim() && contentTypes.filter((ct) => {
174
+ const q = search.trim().toLowerCase();
175
+ return (ct.displayName || '').toLowerCase().includes(q) || ct.uid.toLowerCase().includes(q);
176
+ }).length === 0 && (
177
+ <Box padding={4} background="neutral0" hasRadius>
178
+ <Typography textColor="neutral500">No content types match the search.</Typography>
179
+ </Box>
180
+ )}
154
181
  </Box>
155
182
  </Box>
156
183
  );
@@ -6,6 +6,7 @@ import {
6
6
  Button,
7
7
  SingleSelect,
8
8
  SingleSelectOption,
9
+ TextInput,
9
10
  Table,
10
11
  Thead,
11
12
  Tbody,
@@ -13,6 +14,7 @@ import {
13
14
  Td,
14
15
  Th,
15
16
  } from '@strapi/design-system';
17
+ import { CaretUp, CaretDown } from '@strapi/icons';
16
18
  import { useFetchClient } from '@strapi/strapi/admin';
17
19
 
18
20
  const PLUGIN_ID = 'strapi-content-sync-pro';
@@ -24,6 +26,9 @@ const LogsTab = () => {
24
26
  const [meta, setMeta] = useState(null);
25
27
  const [page, setPage] = useState(1);
26
28
  const [statusFilter, setStatusFilter] = useState('');
29
+ const [search, setSearch] = useState('');
30
+ const [sortField, setSortField] = useState('');
31
+ const [sortDir, setSortDir] = useState('asc');
27
32
  const [loading, setLoading] = useState(true);
28
33
 
29
34
  const fetchLogs = useCallback(async () => {
@@ -46,6 +51,48 @@ const LogsTab = () => {
46
51
  fetchLogs();
47
52
  }, [fetchLogs]);
48
53
 
54
+ const handleSort = (field) => {
55
+ if (sortField === field) {
56
+ setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
57
+ } else {
58
+ setSortField(field);
59
+ setSortDir('asc');
60
+ }
61
+ };
62
+
63
+ const displayedLogs = (() => {
64
+ let result = [...logs];
65
+ if (search.trim()) {
66
+ const q = search.trim().toLowerCase();
67
+ result = result.filter(
68
+ (l) =>
69
+ (l.action || '').toLowerCase().includes(q) ||
70
+ (l.contentType || '').toLowerCase().includes(q) ||
71
+ (l.message || '').toLowerCase().includes(q)
72
+ );
73
+ }
74
+ if (sortField) {
75
+ result.sort((a, b) => {
76
+ const aVal = a[sortField] ?? '';
77
+ const bVal = b[sortField] ?? '';
78
+ if (typeof aVal === 'string') {
79
+ return sortDir === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
80
+ }
81
+ return sortDir === 'asc' ? aVal - bVal : bVal - aVal;
82
+ });
83
+ }
84
+ return result;
85
+ })();
86
+
87
+ const SortableTh = ({ field, children }) => (
88
+ <Th onClick={() => handleSort(field)} style={{ cursor: 'pointer', userSelect: 'none' }}>
89
+ <Flex alignItems="center" gap={1}>
90
+ <Typography variant="sigma">{children}</Typography>
91
+ {sortField === field && (sortDir === 'asc' ? <CaretUp /> : <CaretDown />)}
92
+ </Flex>
93
+ </Th>
94
+ );
95
+
49
96
  return (
50
97
  <Box>
51
98
  <Flex justifyContent="space-between" alignItems="center">
@@ -65,19 +112,30 @@ const LogsTab = () => {
65
112
  </Flex>
66
113
  </Flex>
67
114
 
68
- <Box paddingTop={4}>
115
+ <Box paddingTop={3} paddingBottom={2}>
116
+ <TextInput
117
+ placeholder="Search action, content type, message…"
118
+ value={search}
119
+ onChange={(e) => setSearch(e.target.value)}
120
+ label="Search"
121
+ size="S"
122
+ style={{ maxWidth: 340 }}
123
+ />
124
+ </Box>
125
+
126
+ <Box paddingTop={2}>
69
127
  <Table>
70
128
  <Thead>
71
129
  <Tr>
72
- <Th><Typography variant="sigma">Time</Typography></Th>
73
- <Th><Typography variant="sigma">Action</Typography></Th>
74
- <Th><Typography variant="sigma">Content Type</Typography></Th>
75
- <Th><Typography variant="sigma">Status</Typography></Th>
130
+ <SortableTh field="createdAt">Time</SortableTh>
131
+ <SortableTh field="action">Action</SortableTh>
132
+ <SortableTh field="contentType">Content Type</SortableTh>
133
+ <SortableTh field="status">Status</SortableTh>
76
134
  <Th><Typography variant="sigma">Message</Typography></Th>
77
135
  </Tr>
78
136
  </Thead>
79
137
  <Tbody>
80
- {logs.map((log, i) => (
138
+ {displayedLogs.map((log, i) => (
81
139
  <Tr key={log.id || i}>
82
140
  <Td><Typography>{new Date(log.createdAt).toLocaleString()}</Typography></Td>
83
141
  <Td><Typography>{log.action}</Typography></Td>
@@ -96,11 +154,11 @@ const LogsTab = () => {
96
154
  <Td><Typography>{log.message}</Typography></Td>
97
155
  </Tr>
98
156
  ))}
99
- {logs.length === 0 && (
157
+ {displayedLogs.length === 0 && (
100
158
  <Tr>
101
159
  <Td colSpan={5}>
102
160
  <Typography textColor="neutral500">
103
- {loading ? 'Loading…' : 'No logs found'}
161
+ {loading ? 'Loading…' : search ? 'No logs match the search.' : 'No logs found'}
104
162
  </Typography>
105
163
  </Td>
106
164
  </Tr>
@@ -19,7 +19,7 @@ import {
19
19
  Dialog,
20
20
  IconButton,
21
21
  } from '@strapi/design-system';
22
- import { Pencil, Trash, Play, Check, Stop } from '@strapi/icons';
22
+ import { Pencil, Trash, Play, Check, Stop, CaretUp, CaretDown } from '@strapi/icons';
23
23
  import { useFetchClient } from '@strapi/strapi/admin';
24
24
 
25
25
  const PLUGIN_ID = 'strapi-content-sync-pro';
@@ -103,6 +103,13 @@ const MediaTab = () => {
103
103
  const [editProfile, setEditProfile] = useState(null);
104
104
  const [editMode, setEditMode] = useState(null); // 'create' | 'edit'
105
105
 
106
+ // Profile list filter + sort
107
+ const [profileSearch, setProfileSearch] = useState('');
108
+ const [profileStrategyFilter, setProfileStrategyFilter] = useState('');
109
+ const [profileDirectionFilter, setProfileDirectionFilter] = useState('');
110
+ const [profileSortField, setProfileSortField] = useState('name');
111
+ const [profileSortDir, setProfileSortDir] = useState('asc');
112
+
106
113
  const reload = async () => {
107
114
  try {
108
115
  const [pRes, gRes, sRes, dRes] = await Promise.all([
@@ -275,6 +282,51 @@ const MediaTab = () => {
275
282
  const ep = editProfile || {};
276
283
  const updateEp = (patch) => setEditProfile((p) => ({ ...p, ...patch }));
277
284
 
285
+ const handleProfileSort = (field) => {
286
+ if (profileSortField === field) {
287
+ setProfileSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
288
+ } else {
289
+ setProfileSortField(field);
290
+ setProfileSortDir('asc');
291
+ }
292
+ };
293
+
294
+ const displayedProfiles = (() => {
295
+ let result = [...profiles];
296
+ if (profileSearch.trim()) {
297
+ const q = profileSearch.trim().toLowerCase();
298
+ result = result.filter((p) => (p.name || '').toLowerCase().includes(q));
299
+ }
300
+ if (profileStrategyFilter) {
301
+ result = result.filter((p) => p.strategy === profileStrategyFilter);
302
+ }
303
+ if (profileDirectionFilter) {
304
+ result = result.filter((p) => p.direction === profileDirectionFilter);
305
+ }
306
+ result.sort((a, b) => {
307
+ let aVal = a[profileSortField] ?? '';
308
+ let bVal = b[profileSortField] ?? '';
309
+ if (typeof aVal === 'boolean') { aVal = aVal ? 1 : 0; bVal = bVal ? 1 : 0; }
310
+ if (typeof aVal === 'string') {
311
+ return profileSortDir === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
312
+ }
313
+ return profileSortDir === 'asc' ? aVal - bVal : bVal - aVal;
314
+ });
315
+ return result;
316
+ })();
317
+
318
+ const SortableCol = ({ field, style, children }) => (
319
+ <Box
320
+ style={{ ...style, cursor: 'pointer', userSelect: 'none' }}
321
+ onClick={() => handleProfileSort(field)}
322
+ >
323
+ <Flex alignItems="center" gap={1}>
324
+ <Typography variant="sigma">{children}</Typography>
325
+ {profileSortField === field && (profileSortDir === 'asc' ? <CaretUp /> : <CaretDown />)}
326
+ </Flex>
327
+ </Box>
328
+ );
329
+
278
330
  return (
279
331
  <Box padding={4}>
280
332
  <Box paddingBottom={4}>
@@ -317,17 +369,71 @@ const MediaTab = () => {
317
369
  </Box>
318
370
  ) : (
319
371
  <Box>
372
+ {/* Filter bar */}
373
+ <Flex gap={3} wrap="wrap" marginBottom={3} alignItems="flex-end">
374
+ <Box style={{ flex: '1 1 180px', minWidth: 160 }}>
375
+ <TextInput
376
+ placeholder="Search by name…"
377
+ value={profileSearch}
378
+ onChange={(e) => setProfileSearch(e.target.value)}
379
+ label="Search"
380
+ size="S"
381
+ />
382
+ </Box>
383
+ <Box style={{ minWidth: 160 }}>
384
+ <SingleSelect
385
+ placeholder="All strategies"
386
+ value={profileStrategyFilter}
387
+ onChange={setProfileStrategyFilter}
388
+ onClear={() => setProfileStrategyFilter('')}
389
+ size="S"
390
+ label="Strategy"
391
+ >
392
+ <SingleSelectOption value="url">URL</SingleSelectOption>
393
+ <SingleSelectOption value="rsync">rsync</SingleSelectOption>
394
+ <SingleSelectOption value="disabled">Disabled</SingleSelectOption>
395
+ </SingleSelect>
396
+ </Box>
397
+ <Box style={{ minWidth: 160 }}>
398
+ <SingleSelect
399
+ placeholder="All directions"
400
+ value={profileDirectionFilter}
401
+ onChange={setProfileDirectionFilter}
402
+ onClear={() => setProfileDirectionFilter('')}
403
+ size="S"
404
+ label="Direction"
405
+ >
406
+ <SingleSelectOption value="push">Push</SingleSelectOption>
407
+ <SingleSelectOption value="pull">Pull</SingleSelectOption>
408
+ <SingleSelectOption value="both">Both</SingleSelectOption>
409
+ </SingleSelect>
410
+ </Box>
411
+ {(profileSearch || profileStrategyFilter || profileDirectionFilter) && (
412
+ <Button
413
+ variant="tertiary"
414
+ size="S"
415
+ onClick={() => { setProfileSearch(''); setProfileStrategyFilter(''); setProfileDirectionFilter(''); }}
416
+ >
417
+ Clear filters
418
+ </Button>
419
+ )}
420
+ </Flex>
421
+
320
422
  {/* Header */}
321
423
  <Flex background="neutral100" padding={3} hasRadius style={{ fontWeight: 600 }}>
322
- <Box style={{ flex: 2 }}><Typography variant="sigma">Name</Typography></Box>
323
- <Box style={{ flex: 1 }}><Typography variant="sigma">Strategy</Typography></Box>
324
- <Box style={{ flex: 1 }}><Typography variant="sigma">Direction</Typography></Box>
325
- <Box style={{ flex: 1 }}><Typography variant="sigma">Conflict</Typography></Box>
326
- <Box style={{ flex: 1 }}><Typography variant="sigma">Execution</Typography></Box>
424
+ <SortableCol field="name" style={{ flex: 2 }}>Name</SortableCol>
425
+ <SortableCol field="strategy" style={{ flex: 1 }}>Strategy</SortableCol>
426
+ <SortableCol field="direction" style={{ flex: 1 }}>Direction</SortableCol>
427
+ <SortableCol field="conflictStrategy" style={{ flex: 1 }}>Conflict</SortableCol>
428
+ <SortableCol field="executionMode" style={{ flex: 1 }}>Execution</SortableCol>
327
429
  <Box style={{ flex: 1 }}><Typography variant="sigma">Sync Scope</Typography></Box>
328
430
  <Box style={{ width: 180 }}><Typography variant="sigma">Actions</Typography></Box>
329
431
  </Flex>
330
- {profiles.map((p) => (
432
+ {displayedProfiles.length === 0 ? (
433
+ <Box padding={4}>
434
+ <Typography textColor="neutral500">No profiles match the current filters.</Typography>
435
+ </Box>
436
+ ) : displayedProfiles.map((p) => (
331
437
  <Flex key={p.id} padding={3} borderColor="neutral150" style={{ borderBottom: '1px solid #eee' }} alignItems="center">
332
438
  <Box style={{ flex: 2 }}>
333
439
  <Flex gap={2} alignItems="center">