strapi-content-sync-pro 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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +206 -0
  3. package/admin/src/components/ConfigTab.jsx +1038 -0
  4. package/admin/src/components/ContentTypesTab.jsx +160 -0
  5. package/admin/src/components/HelpTab.jsx +945 -0
  6. package/admin/src/components/LogsTab.jsx +136 -0
  7. package/admin/src/components/MediaTab.jsx +557 -0
  8. package/admin/src/components/SyncProfilesTab.jsx +715 -0
  9. package/admin/src/components/SyncTab.jsx +988 -0
  10. package/admin/src/index.js +31 -0
  11. package/admin/src/pages/App/index.jsx +129 -0
  12. package/admin/src/pluginId.js +3 -0
  13. package/package.json +84 -0
  14. package/server/src/bootstrap.js +151 -0
  15. package/server/src/config/index.js +5 -0
  16. package/server/src/content-types/index.js +7 -0
  17. package/server/src/content-types/sync-log/schema.json +24 -0
  18. package/server/src/controllers/alerts.js +59 -0
  19. package/server/src/controllers/config.js +292 -0
  20. package/server/src/controllers/content-type-discovery.js +9 -0
  21. package/server/src/controllers/dependencies.js +109 -0
  22. package/server/src/controllers/index.js +29 -0
  23. package/server/src/controllers/ping.js +7 -0
  24. package/server/src/controllers/sync-config.js +26 -0
  25. package/server/src/controllers/sync-enforcement.js +323 -0
  26. package/server/src/controllers/sync-execution.js +134 -0
  27. package/server/src/controllers/sync-log.js +18 -0
  28. package/server/src/controllers/sync-media.js +158 -0
  29. package/server/src/controllers/sync-profiles.js +182 -0
  30. package/server/src/controllers/sync.js +31 -0
  31. package/server/src/destroy.js +7 -0
  32. package/server/src/index.js +21 -0
  33. package/server/src/middlewares/verify-signature.js +32 -0
  34. package/server/src/register.js +7 -0
  35. package/server/src/routes/index.js +111 -0
  36. package/server/src/services/alerts.js +437 -0
  37. package/server/src/services/config.js +68 -0
  38. package/server/src/services/content-type-discovery.js +41 -0
  39. package/server/src/services/dependency-resolver.js +284 -0
  40. package/server/src/services/index.js +30 -0
  41. package/server/src/services/ping.js +7 -0
  42. package/server/src/services/sync-config.js +45 -0
  43. package/server/src/services/sync-enforcement.js +362 -0
  44. package/server/src/services/sync-execution.js +541 -0
  45. package/server/src/services/sync-log.js +56 -0
  46. package/server/src/services/sync-media.js +963 -0
  47. package/server/src/services/sync-profiles.js +380 -0
  48. package/server/src/services/sync.js +248 -0
  49. package/server/src/utils/applier.js +89 -0
  50. package/server/src/utils/comparator.js +83 -0
  51. package/server/src/utils/fetcher.js +142 -0
  52. package/server/src/utils/hmac.js +37 -0
  53. package/server/src/utils/pagination.js +51 -0
  54. package/server/src/utils/sync-guard.js +29 -0
  55. package/server/src/utils/sync-id.js +16 -0
@@ -0,0 +1,557 @@
1
+ import { useState, useEffect } from 'react';
2
+ import {
3
+ Box,
4
+ Flex,
5
+ Typography,
6
+ TextInput,
7
+ Textarea,
8
+ Button,
9
+ Alert,
10
+ Field,
11
+ SingleSelect,
12
+ SingleSelectOption,
13
+ Switch,
14
+ NumberInput,
15
+ Badge,
16
+ Loader,
17
+ Divider,
18
+ Tabs,
19
+ Dialog,
20
+ IconButton,
21
+ } from '@strapi/design-system';
22
+ import { Pencil, Trash, Play, Check } from '@strapi/icons';
23
+ import { useFetchClient } from '@strapi/strapi/admin';
24
+
25
+ const PLUGIN_ID = 'strapi-content-sync-pro';
26
+
27
+ const STRATEGY_OPTIONS = [
28
+ { value: 'disabled', label: 'Disabled' },
29
+ { value: 'url', label: 'URL (HTTP upload/download)' },
30
+ { value: 'rsync', label: 'rsync (file-level copy)' },
31
+ ];
32
+
33
+ const DIRECTION_OPTIONS = [
34
+ { value: 'push', label: 'Push (local → remote)' },
35
+ { value: 'pull', label: 'Pull (remote → local)' },
36
+ { value: 'both', label: 'Both directions' },
37
+ ];
38
+
39
+ const CONFLICT_OPTIONS = [
40
+ { value: 'latest_wins', label: 'Latest Wins' },
41
+ { value: 'local_wins', label: 'Local Wins' },
42
+ { value: 'remote_wins', label: 'Remote Wins' },
43
+ ];
44
+
45
+ const EXECUTION_MODE_OPTIONS = [
46
+ { value: 'on_demand', label: 'On Demand (Manual)' },
47
+ { value: 'scheduled', label: 'Scheduled' },
48
+ { value: 'live', label: 'Live (Real-time)' },
49
+ ];
50
+
51
+ const SCHEDULE_TYPE_OPTIONS = [
52
+ { value: 'interval', label: 'Interval (setInterval)' },
53
+ { value: 'timeout', label: 'Timeout (chained setTimeout)' },
54
+ { value: 'cron', label: 'Cron (wall-clock)' },
55
+ { value: 'external', label: 'External scheduler' },
56
+ ];
57
+
58
+ function patternsToText(arr) {
59
+ return (arr || []).join('\n');
60
+ }
61
+ function textToPatterns(text) {
62
+ return (text || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
63
+ }
64
+ function mimeToText(arr) {
65
+ return (arr || []).join(', ');
66
+ }
67
+ function textToMime(text) {
68
+ return (text || '').split(/[,\n]/).map((s) => s.trim()).filter(Boolean);
69
+ }
70
+
71
+ const EMPTY_PROFILE = {
72
+ name: '',
73
+ strategy: 'url',
74
+ direction: 'both',
75
+ conflictStrategy: 'latest_wins',
76
+ syncDbRows: true,
77
+ syncFileBytes: true,
78
+ includeMime: [],
79
+ excludeMime: [],
80
+ includePatterns: [],
81
+ excludePatterns: [],
82
+ dryRun: false,
83
+ executionMode: 'on_demand',
84
+ scheduleType: 'interval',
85
+ scheduleInterval: 60,
86
+ cronExpression: '',
87
+ enabled: true,
88
+ };
89
+
90
+ const MediaTab = () => {
91
+ const { get, put, post, del } = useFetchClient();
92
+ const [profiles, setProfiles] = useState([]);
93
+ const [globalSettings, setGlobalSettings] = useState({});
94
+ const [status, setStatus] = useState(null);
95
+ const [defaults, setDefaults] = useState(null);
96
+ const [loading, setLoading] = useState(true);
97
+ const [message, setMessage] = useState(null);
98
+ const [saving, setSaving] = useState(false);
99
+ const [running, setRunning] = useState(false);
100
+ const [testing, setTesting] = useState(false);
101
+
102
+ // Edit modal state
103
+ const [editProfile, setEditProfile] = useState(null);
104
+ const [editMode, setEditMode] = useState(null); // 'create' | 'edit'
105
+
106
+ const reload = async () => {
107
+ try {
108
+ const [pRes, gRes, sRes, dRes] = await Promise.all([
109
+ get(`/${PLUGIN_ID}/media-sync/profiles`),
110
+ get(`/${PLUGIN_ID}/media-sync/global-settings`),
111
+ get(`/${PLUGIN_ID}/media-sync/status`),
112
+ get(`/${PLUGIN_ID}/media-sync/defaults`),
113
+ ]);
114
+ setProfiles(pRes.data.data || []);
115
+ setGlobalSettings(gRes.data.data || {});
116
+ setStatus(sRes.data.data || {});
117
+ setDefaults(dRes.data.data || {});
118
+ } catch (err) {
119
+ setMessage({ type: 'danger', text: `Failed to load: ${err?.response?.data?.error?.message || err.message}` });
120
+ } finally {
121
+ setLoading(false);
122
+ }
123
+ };
124
+
125
+ useEffect(() => { reload(); }, []);
126
+
127
+ const handleSaveGlobal = async () => {
128
+ setSaving(true); setMessage(null);
129
+ try {
130
+ const res = await put(`/${PLUGIN_ID}/media-sync/global-settings`, globalSettings);
131
+ setGlobalSettings(res.data.data || {});
132
+ setMessage({ type: 'success', text: 'Global settings saved.' });
133
+ } catch (err) {
134
+ setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message });
135
+ } finally { setSaving(false); }
136
+ };
137
+
138
+ const handleSaveProfile = async () => {
139
+ setSaving(true); setMessage(null);
140
+ try {
141
+ if (editMode === 'create') {
142
+ await post(`/${PLUGIN_ID}/media-sync/profiles`, editProfile);
143
+ setMessage({ type: 'success', text: 'Profile created.' });
144
+ } else {
145
+ await put(`/${PLUGIN_ID}/media-sync/profiles/${editProfile.id}`, editProfile);
146
+ setMessage({ type: 'success', text: 'Profile updated.' });
147
+ }
148
+ setEditProfile(null); setEditMode(null);
149
+ await reload();
150
+ } catch (err) {
151
+ setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message });
152
+ } finally { setSaving(false); }
153
+ };
154
+
155
+ const handleDelete = async (id) => {
156
+ if (!confirm('Delete this media profile?')) return;
157
+ try {
158
+ await del(`/${PLUGIN_ID}/media-sync/profiles/${id}`);
159
+ setMessage({ type: 'success', text: 'Profile deleted.' });
160
+ await reload();
161
+ } catch (err) {
162
+ setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message });
163
+ }
164
+ };
165
+
166
+ const handleActivate = async (id) => {
167
+ try {
168
+ await post(`/${PLUGIN_ID}/media-sync/profiles/${id}/activate`, {});
169
+ setMessage({ type: 'success', text: 'Profile activated.' });
170
+ await reload();
171
+ } catch (err) {
172
+ setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message });
173
+ }
174
+ };
175
+
176
+ const handleRunProfile = async (id) => {
177
+ setRunning(true); setMessage(null);
178
+ try {
179
+ await post(`/${PLUGIN_ID}/media-sync/profiles/${id}/run`, {});
180
+ setMessage({ type: 'success', text: 'Media sync complete.' });
181
+ await reload();
182
+ } catch (err) {
183
+ setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message });
184
+ } finally { setRunning(false); }
185
+ };
186
+
187
+ const handleRunAll = async () => {
188
+ setRunning(true); setMessage(null);
189
+ try {
190
+ await post(`/${PLUGIN_ID}/media-sync/run-active`, {});
191
+ setMessage({ type: 'success', text: 'All active media profiles synced.' });
192
+ await reload();
193
+ } catch (err) {
194
+ setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message });
195
+ } finally { setRunning(false); }
196
+ };
197
+
198
+ const handleTest = async () => {
199
+ setTesting(true); setMessage(null);
200
+ try {
201
+ const res = await post(`/${PLUGIN_ID}/media-sync/test`, {});
202
+ const data = res.data.data;
203
+ setMessage({
204
+ type: data.ok ? 'success' : 'danger',
205
+ text: data.ok ? `Connection OK${data.version ? ` (${data.version})` : ''}` : `Test failed: ${data.error}`,
206
+ });
207
+ } catch (err) {
208
+ setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message });
209
+ } finally { setTesting(false); }
210
+ };
211
+
212
+ if (loading) {
213
+ return <Flex justifyContent="center" padding={8}><Loader /></Flex>;
214
+ }
215
+
216
+ const ep = editProfile || {};
217
+ const updateEp = (patch) => setEditProfile((p) => ({ ...p, ...patch }));
218
+
219
+ return (
220
+ <Box padding={4}>
221
+ <Box paddingBottom={4}>
222
+ <Typography variant="alpha">Media Sync</Typography>
223
+ <Typography variant="epsilon" textColor="neutral600" paddingTop={1}>
224
+ Profile-based media synchronization for <code>plugin::upload.file</code>. Sync both file metadata (DB rows) and actual file bytes.
225
+ </Typography>
226
+ </Box>
227
+
228
+ {message && (
229
+ <Box paddingBottom={4}>
230
+ <Alert variant={message.type} onClose={() => setMessage(null)} closeLabel="Close">{message.text}</Alert>
231
+ </Box>
232
+ )}
233
+
234
+ <Tabs.Root defaultValue="profiles">
235
+ <Tabs.List>
236
+ <Tabs.Trigger value="profiles">Profiles</Tabs.Trigger>
237
+ <Tabs.Trigger value="global">Global Settings</Tabs.Trigger>
238
+ <Tabs.Trigger value="status">Status</Tabs.Trigger>
239
+ </Tabs.List>
240
+
241
+ {/* ── Profiles Tab ────────────────────────────────────────────── */}
242
+ <Tabs.Content value="profiles">
243
+ <Box paddingTop={4}>
244
+ <Flex justifyContent="space-between" alignItems="center" paddingBottom={3}>
245
+ <Typography variant="delta">Media Sync Profiles</Typography>
246
+ <Flex gap={2}>
247
+ <Button variant="secondary" onClick={handleTest} loading={testing} disabled={testing}>Test connection</Button>
248
+ <Button variant="secondary" onClick={handleRunAll} loading={running} disabled={running}>Sync All Active</Button>
249
+ <Button onClick={() => { setEditProfile({ ...EMPTY_PROFILE, includeMime: defaults?.mimeAll || [] }); setEditMode('create'); }}>
250
+ Create Profile
251
+ </Button>
252
+ </Flex>
253
+ </Flex>
254
+
255
+ {profiles.length === 0 ? (
256
+ <Box background="neutral100" padding={6} hasRadius>
257
+ <Typography variant="omega" textColor="neutral600">No media profiles yet. Click "Create Profile" to get started.</Typography>
258
+ </Box>
259
+ ) : (
260
+ <Box>
261
+ {/* Header */}
262
+ <Flex background="neutral100" padding={3} hasRadius style={{ fontWeight: 600 }}>
263
+ <Box style={{ flex: 2 }}><Typography variant="sigma">Name</Typography></Box>
264
+ <Box style={{ flex: 1 }}><Typography variant="sigma">Strategy</Typography></Box>
265
+ <Box style={{ flex: 1 }}><Typography variant="sigma">Direction</Typography></Box>
266
+ <Box style={{ flex: 1 }}><Typography variant="sigma">Conflict</Typography></Box>
267
+ <Box style={{ flex: 1 }}><Typography variant="sigma">Execution</Typography></Box>
268
+ <Box style={{ flex: 1 }}><Typography variant="sigma">Sync Scope</Typography></Box>
269
+ <Box style={{ width: 180 }}><Typography variant="sigma">Actions</Typography></Box>
270
+ </Flex>
271
+ {profiles.map((p) => (
272
+ <Flex key={p.id} padding={3} borderColor="neutral150" style={{ borderBottom: '1px solid #eee' }} alignItems="center">
273
+ <Box style={{ flex: 2 }}>
274
+ <Flex gap={2} alignItems="center">
275
+ <Typography variant="omega" fontWeight={p.active ? 'bold' : 'regular'}>{p.name}</Typography>
276
+ {p.active && <Badge active>Active</Badge>}
277
+ </Flex>
278
+ </Box>
279
+ <Box style={{ flex: 1 }}><Typography variant="pi">{p.strategy}</Typography></Box>
280
+ <Box style={{ flex: 1 }}><Typography variant="pi">{p.direction}</Typography></Box>
281
+ <Box style={{ flex: 1 }}><Typography variant="pi">{(p.conflictStrategy || '').replace('_', ' ')}</Typography></Box>
282
+ <Box style={{ flex: 1 }}><Typography variant="pi">{(p.executionMode || '').replace('_', ' ')}</Typography></Box>
283
+ <Box style={{ flex: 1 }}>
284
+ <Typography variant="pi">
285
+ {p.syncDbRows && p.syncFileBytes ? 'DB + Files' : p.syncDbRows ? 'DB rows' : p.syncFileBytes ? 'Files' : 'None'}
286
+ </Typography>
287
+ </Box>
288
+ <Flex style={{ width: 180 }} gap={1}>
289
+ {!p.active && (
290
+ <Button variant="tertiary" size="S" onClick={() => handleActivate(p.id)} startIcon={<Check />}>Activate</Button>
291
+ )}
292
+ {p.active && (
293
+ <Button variant="secondary" size="S" onClick={() => handleRunProfile(p.id)} loading={running} disabled={running} startIcon={<Play />}>Run</Button>
294
+ )}
295
+ <IconButton label="Edit" onClick={() => { setEditProfile({ ...p }); setEditMode('edit'); }}><Pencil /></IconButton>
296
+ <IconButton label="Delete" onClick={() => handleDelete(p.id)}><Trash /></IconButton>
297
+ </Flex>
298
+ </Flex>
299
+ ))}
300
+ </Box>
301
+ )}
302
+ </Box>
303
+ </Tabs.Content>
304
+
305
+ {/* ── Global Settings Tab ─────────────────────────────────────── */}
306
+ <Tabs.Content value="global">
307
+ <Box paddingTop={4}>
308
+ <Typography variant="delta" paddingBottom={3}>Global Media Settings</Typography>
309
+ <Box background="neutral0" padding={4} hasRadius shadow="tableShadow" marginBottom={4}>
310
+ <Flex gap={4} wrap="wrap">
311
+ <Box style={{ minWidth: 220 }}>
312
+ <Field.Root>
313
+ <Field.Label>Page size</Field.Label>
314
+ <NumberInput value={globalSettings.pageSize || 50} onValueChange={(v) => setGlobalSettings((s) => ({ ...s, pageSize: v }))} min={1} max={500} />
315
+ <Field.Hint>Files per page (1-500).</Field.Hint>
316
+ </Field.Root>
317
+ </Box>
318
+ <Box style={{ minWidth: 220 }}>
319
+ <Field.Root>
320
+ <Field.Label>Batch concurrency</Field.Label>
321
+ <NumberInput value={globalSettings.batchConcurrency || 2} onValueChange={(v) => setGlobalSettings((s) => ({ ...s, batchConcurrency: v }))} min={1} max={10} />
322
+ </Field.Root>
323
+ </Box>
324
+ <Box style={{ minWidth: 260, alignSelf: 'center' }}>
325
+ <Flex alignItems="center" gap={2}>
326
+ <Switch checked={!!globalSettings.skipIfSameSize} onCheckedChange={(v) => setGlobalSettings((s) => ({ ...s, skipIfSameSize: v }))} />
327
+ <Typography>Skip when hash + size match</Typography>
328
+ </Flex>
329
+ </Box>
330
+ </Flex>
331
+
332
+ <Divider style={{ margin: '16px 0' }} />
333
+ <Typography variant="sigma" paddingBottom={2}>rsync defaults</Typography>
334
+ <Flex gap={4} wrap="wrap">
335
+ <Box style={{ minWidth: 260, flex: 1 }}>
336
+ <Field.Root>
337
+ <Field.Label>rsync command</Field.Label>
338
+ <TextInput value={globalSettings.rsyncCommand || 'rsync'} onChange={(e) => setGlobalSettings((s) => ({ ...s, rsyncCommand: e.target.value }))} />
339
+ </Field.Root>
340
+ </Box>
341
+ <Box style={{ minWidth: 260, flex: 1 }}>
342
+ <Field.Root>
343
+ <Field.Label>rsync args</Field.Label>
344
+ <TextInput value={globalSettings.rsyncArgs || '-avz --delete-after'} onChange={(e) => setGlobalSettings((s) => ({ ...s, rsyncArgs: e.target.value }))} />
345
+ </Field.Root>
346
+ </Box>
347
+ </Flex>
348
+ <Flex gap={4} wrap="wrap" paddingTop={3}>
349
+ <Box style={{ minWidth: 260, flex: 1 }}>
350
+ <Field.Root>
351
+ <Field.Label>Local media path</Field.Label>
352
+ <TextInput value={globalSettings.localMediaPath || ''} onChange={(e) => setGlobalSettings((s) => ({ ...s, localMediaPath: e.target.value }))} placeholder="./public/uploads" />
353
+ </Field.Root>
354
+ </Box>
355
+ <Box style={{ minWidth: 260, flex: 1 }}>
356
+ <Field.Root>
357
+ <Field.Label>Remote media path</Field.Label>
358
+ <TextInput value={globalSettings.remoteMediaPath || ''} onChange={(e) => setGlobalSettings((s) => ({ ...s, remoteMediaPath: e.target.value }))} placeholder="user@host:/srv/strapi/public/uploads" />
359
+ </Field.Root>
360
+ </Box>
361
+ <Box style={{ minWidth: 120 }}>
362
+ <Field.Root>
363
+ <Field.Label>SSH port</Field.Label>
364
+ <NumberInput value={globalSettings.sshPort || 22} onValueChange={(v) => setGlobalSettings((s) => ({ ...s, sshPort: v }))} min={1} max={65535} />
365
+ </Field.Root>
366
+ </Box>
367
+ <Box style={{ minWidth: 220 }}>
368
+ <Field.Root>
369
+ <Field.Label>SSH identity file</Field.Label>
370
+ <TextInput value={globalSettings.sshIdentityFile || ''} onChange={(e) => setGlobalSettings((s) => ({ ...s, sshIdentityFile: e.target.value }))} placeholder="~/.ssh/id_ed25519" />
371
+ </Field.Root>
372
+ </Box>
373
+ </Flex>
374
+
375
+ <Box paddingTop={4}>
376
+ <Button onClick={handleSaveGlobal} loading={saving} disabled={saving}>Save Global Settings</Button>
377
+ </Box>
378
+ </Box>
379
+ </Box>
380
+ </Tabs.Content>
381
+
382
+ {/* ── Status Tab ──────────────────────────────────────────────── */}
383
+ <Tabs.Content value="status">
384
+ <Box paddingTop={4}>
385
+ <Typography variant="delta" paddingBottom={3}>Media Sync Status</Typography>
386
+ {status?.profiles?.map((sp) => (
387
+ <Box key={sp.id} background="neutral0" padding={3} hasRadius shadow="tableShadow" marginBottom={2}>
388
+ <Flex justifyContent="space-between" alignItems="center">
389
+ <Flex gap={2} alignItems="center">
390
+ <Typography variant="omega" fontWeight="bold">{sp.name}</Typography>
391
+ {sp.active && <Badge active>Active</Badge>}
392
+ <Badge>{sp.running ? 'Running' : 'Idle'}</Badge>
393
+ </Flex>
394
+ <Typography variant="pi" textColor="neutral600">
395
+ Mode: {(sp.executionMode || '').replace('_', ' ')} | Last: {sp.lastExecutedAt ? new Date(sp.lastExecutedAt).toLocaleString() : 'never'}
396
+ </Typography>
397
+ </Flex>
398
+ </Box>
399
+ ))}
400
+ {status?.lastResult && (
401
+ <Box paddingTop={3} background="neutral0" padding={4} hasRadius shadow="tableShadow">
402
+ <Typography variant="sigma">Last Run Result</Typography>
403
+ <Typography variant="pi" style={{ fontFamily: 'monospace', whiteSpace: 'pre-wrap' }}>
404
+ {JSON.stringify(status.lastResult, null, 2)}
405
+ </Typography>
406
+ </Box>
407
+ )}
408
+ </Box>
409
+ </Tabs.Content>
410
+ </Tabs.Root>
411
+
412
+ {/* ── Profile Edit Dialog ────────────────────────────────────────── */}
413
+ {editProfile && (
414
+ <Dialog.Root open onOpenChange={(open) => { if (!open) { setEditProfile(null); setEditMode(null); } }}>
415
+ <Dialog.Content style={{ maxWidth: 720, maxHeight: '90vh', overflow: 'auto' }}>
416
+ <Dialog.Header>{editMode === 'create' ? 'Create Media Profile' : `Edit: ${ep.name}`}</Dialog.Header>
417
+ <Dialog.Body>
418
+ <Flex direction="column" gap={4}>
419
+ <Field.Root>
420
+ <Field.Label>Profile name</Field.Label>
421
+ <TextInput value={ep.name || ''} onChange={(e) => updateEp({ name: e.target.value })} placeholder="My Media Profile" />
422
+ </Field.Root>
423
+
424
+ <Flex gap={4} wrap="wrap">
425
+ <Box style={{ flex: 1, minWidth: 200 }}>
426
+ <Field.Root>
427
+ <Field.Label>Strategy</Field.Label>
428
+ <SingleSelect value={ep.strategy} onChange={(v) => updateEp({ strategy: v })}>
429
+ {STRATEGY_OPTIONS.map((o) => <SingleSelectOption key={o.value} value={o.value}>{o.label}</SingleSelectOption>)}
430
+ </SingleSelect>
431
+ </Field.Root>
432
+ </Box>
433
+ <Box style={{ flex: 1, minWidth: 200 }}>
434
+ <Field.Root>
435
+ <Field.Label>Direction</Field.Label>
436
+ <SingleSelect value={ep.direction} onChange={(v) => updateEp({ direction: v })}>
437
+ {DIRECTION_OPTIONS.map((o) => <SingleSelectOption key={o.value} value={o.value}>{o.label}</SingleSelectOption>)}
438
+ </SingleSelect>
439
+ </Field.Root>
440
+ </Box>
441
+ <Box style={{ flex: 1, minWidth: 200 }}>
442
+ <Field.Root>
443
+ <Field.Label>Conflict strategy</Field.Label>
444
+ <SingleSelect value={ep.conflictStrategy} onChange={(v) => updateEp({ conflictStrategy: v })}>
445
+ {CONFLICT_OPTIONS.map((o) => <SingleSelectOption key={o.value} value={o.value}>{o.label}</SingleSelectOption>)}
446
+ </SingleSelect>
447
+ </Field.Root>
448
+ </Box>
449
+ </Flex>
450
+
451
+ <Divider />
452
+ <Typography variant="sigma">Sync Scope</Typography>
453
+ <Flex gap={4}>
454
+ <Flex alignItems="center" gap={2}>
455
+ <Switch checked={!!ep.syncDbRows} onCheckedChange={(v) => updateEp({ syncDbRows: v })} />
456
+ <Typography>Sync DB rows (metadata)</Typography>
457
+ </Flex>
458
+ <Flex alignItems="center" gap={2}>
459
+ <Switch checked={!!ep.syncFileBytes} onCheckedChange={(v) => updateEp({ syncFileBytes: v })} />
460
+ <Typography>Sync file bytes</Typography>
461
+ </Flex>
462
+ <Flex alignItems="center" gap={2}>
463
+ <Switch checked={!!ep.dryRun} onCheckedChange={(v) => updateEp({ dryRun: v })} />
464
+ <Typography>Dry run</Typography>
465
+ </Flex>
466
+ </Flex>
467
+
468
+ <Divider />
469
+ <Typography variant="sigma">File Type Filters</Typography>
470
+ <Field.Root>
471
+ <Field.Label>Include MIME types (comma or line separated)</Field.Label>
472
+ <Textarea value={mimeToText(ep.includeMime)} onChange={(e) => updateEp({ includeMime: textToMime(e.target.value) })}
473
+ placeholder="image/, video/mp4, application/pdf" />
474
+ <Field.Hint>Leave empty to allow all. Common defaults: image/, video/mp4, video/webm, application/pdf, text/csv</Field.Hint>
475
+ </Field.Root>
476
+ <Field.Root>
477
+ <Field.Label>Exclude MIME types</Field.Label>
478
+ <Textarea value={mimeToText(ep.excludeMime)} onChange={(e) => updateEp({ excludeMime: textToMime(e.target.value) })} placeholder="video/x-msvideo" />
479
+ </Field.Root>
480
+ <Flex gap={4} wrap="wrap">
481
+ <Box style={{ flex: 1, minWidth: 200 }}>
482
+ <Field.Root>
483
+ <Field.Label>Include filename patterns</Field.Label>
484
+ <Textarea value={patternsToText(ep.includePatterns)} onChange={(e) => updateEp({ includePatterns: textToPatterns(e.target.value) })} placeholder={'*.jpg\n*.png'} />
485
+ </Field.Root>
486
+ </Box>
487
+ <Box style={{ flex: 1, minWidth: 200 }}>
488
+ <Field.Root>
489
+ <Field.Label>Exclude filename patterns</Field.Label>
490
+ <Textarea value={patternsToText(ep.excludePatterns)} onChange={(e) => updateEp({ excludePatterns: textToPatterns(e.target.value) })} placeholder={'*.tmp\n.DS_Store'} />
491
+ </Field.Root>
492
+ </Box>
493
+ </Flex>
494
+
495
+ <Divider />
496
+ <Typography variant="sigma">Execution Settings</Typography>
497
+ <Flex gap={4} wrap="wrap">
498
+ <Box style={{ flex: 1, minWidth: 200 }}>
499
+ <Field.Root>
500
+ <Field.Label>Execution mode</Field.Label>
501
+ <SingleSelect value={ep.executionMode} onChange={(v) => updateEp({ executionMode: v })}>
502
+ {EXECUTION_MODE_OPTIONS.map((o) => <SingleSelectOption key={o.value} value={o.value}>{o.label}</SingleSelectOption>)}
503
+ </SingleSelect>
504
+ </Field.Root>
505
+ </Box>
506
+ {ep.executionMode === 'scheduled' && (
507
+ <>
508
+ <Box style={{ flex: 1, minWidth: 200 }}>
509
+ <Field.Root>
510
+ <Field.Label>Schedule type</Field.Label>
511
+ <SingleSelect value={ep.scheduleType} onChange={(v) => updateEp({ scheduleType: v })}>
512
+ {SCHEDULE_TYPE_OPTIONS.map((o) => <SingleSelectOption key={o.value} value={o.value}>{o.label}</SingleSelectOption>)}
513
+ </SingleSelect>
514
+ </Field.Root>
515
+ </Box>
516
+ {(ep.scheduleType === 'interval' || ep.scheduleType === 'timeout') && (
517
+ <Box style={{ minWidth: 180 }}>
518
+ <Field.Root>
519
+ <Field.Label>Interval (minutes)</Field.Label>
520
+ <NumberInput value={ep.scheduleInterval || 60} onValueChange={(v) => updateEp({ scheduleInterval: v })} min={1} />
521
+ </Field.Root>
522
+ </Box>
523
+ )}
524
+ {ep.scheduleType === 'cron' && (
525
+ <Box style={{ flex: 1, minWidth: 200 }}>
526
+ <Field.Root>
527
+ <Field.Label>Cron expression</Field.Label>
528
+ <TextInput value={ep.cronExpression || ''} onChange={(e) => updateEp({ cronExpression: e.target.value })} placeholder="0 */2 * * *" />
529
+ </Field.Root>
530
+ </Box>
531
+ )}
532
+ </>
533
+ )}
534
+ </Flex>
535
+ <Flex alignItems="center" gap={2}>
536
+ <Switch checked={!!ep.enabled} onCheckedChange={(v) => updateEp({ enabled: v })} />
537
+ <Typography>Enabled</Typography>
538
+ </Flex>
539
+ </Flex>
540
+ </Dialog.Body>
541
+ <Dialog.Footer>
542
+ <Dialog.Cancel>
543
+ <Button variant="tertiary">Cancel</Button>
544
+ </Dialog.Cancel>
545
+ <Button onClick={handleSaveProfile} loading={saving} disabled={saving}>
546
+ {editMode === 'create' ? 'Create' : 'Save'}
547
+ </Button>
548
+ </Dialog.Footer>
549
+ </Dialog.Content>
550
+ </Dialog.Root>
551
+ )}
552
+ </Box>
553
+ );
554
+ };
555
+
556
+ export { MediaTab };
557
+ export default MediaTab;