strapi-content-sync-pro 1.0.4 → 1.0.5

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 (46) hide show
  1. package/README.md +31 -14
  2. package/admin/src/components/ConfigTab.jsx +81 -3
  3. package/admin/src/components/HelpTab.jsx +34 -0
  4. package/admin/src/components/MediaTab.jsx +141 -30
  5. package/docs/Screenshot 2026-04-22 183540.png +0 -0
  6. package/docs/Screenshot 2026-04-22 183552.png +0 -0
  7. package/docs/Screenshot 2026-04-23 114332.png +0 -0
  8. package/docs/Screenshot 2026-04-23 114644.png +0 -0
  9. package/docs/Screenshot 2026-04-23 114651.png +0 -0
  10. package/docs/Screenshot 2026-04-23 114737.png +0 -0
  11. package/docs/Screenshot 2026-04-23 114904.png +0 -0
  12. package/docs/Screenshot 2026-04-23 114940.png +0 -0
  13. package/docs/Screenshot 2026-04-23 115003.png +0 -0
  14. package/docs/Screenshot 2026-04-23 115024.png +0 -0
  15. package/docs/Screenshot 2026-04-23 115116.png +0 -0
  16. package/docs/Screenshot 2026-04-23 115141.png +0 -0
  17. package/docs/Screenshot 2026-04-23 115252.png +0 -0
  18. package/docs/Screenshot 2026-04-23 115448.png +0 -0
  19. package/docs/Screenshot 2026-04-23 120534.png +0 -0
  20. package/docs/Screenshot 2026-04-23 122544.png +0 -0
  21. package/docs/Screenshot 2026-04-23 122712.png +0 -0
  22. package/docs/Screenshot 2026-04-23 122730.png +0 -0
  23. package/docs/Screenshot 2026-04-23 122858.png +0 -0
  24. package/docs/Screenshot 2026-04-23 122924.png +0 -0
  25. package/docs/Screenshot 2026-04-23 122937.png +0 -0
  26. package/package.json +1 -1
  27. package/server/src/controllers/config.js +76 -3
  28. package/server/src/controllers/sync-media.js +24 -0
  29. package/server/src/routes/index.js +3 -0
  30. package/server/src/services/sync-media.js +168 -32
  31. package/docs/Screenshot 2026-04-20 160506.png +0 -0
  32. package/docs/Screenshot 2026-04-20 160558.png +0 -0
  33. package/docs/Screenshot 2026-04-20 175903.png +0 -0
  34. package/docs/Screenshot 2026-04-20 175931.png +0 -0
  35. package/docs/Screenshot 2026-04-20 180001.png +0 -0
  36. package/docs/Screenshot 2026-04-20 180041.png +0 -0
  37. package/docs/Screenshot 2026-04-20 180116.png +0 -0
  38. package/docs/Screenshot 2026-04-20 180135.png +0 -0
  39. package/docs/Screenshot 2026-04-20 180202.png +0 -0
  40. package/docs/Screenshot 2026-04-20 180228.png +0 -0
  41. package/docs/Screenshot 2026-04-20 180251.png +0 -0
  42. package/docs/Screenshot 2026-04-20 180301.png +0 -0
  43. package/docs/clipchamp-screen-recording-script.md +0 -0
  44. package/docs/production-readiness-status.md +0 -34
  45. package/docs/production-readiness-test-matrix.md +0 -151
  46. package/docs/test-environments-setup-legacy.txt +0 -60
package/README.md CHANGED
@@ -14,7 +14,7 @@ A powerful Strapi v5 plugin to copy, migrate, and live-sync content, media, and
14
14
  Plugin intro: https://youtu.be/hr3dD6dLgLQ
15
15
 
16
16
  <a href="https://youtu.be/hr3dD6dLgLQ" target="_blank" rel="noopener noreferrer">
17
- <img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-20%20160506.png" alt="Content Sync Pro — watch the intro video" width="100%" />
17
+ <img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20122937.png" alt="Content Sync Pro — watch the intro video" width="100%" />
18
18
  </a>
19
19
 
20
20
  ## Screenshots
@@ -26,24 +26,39 @@ Plugin intro: https://youtu.be/hr3dD6dLgLQ
26
26
 
27
27
  <table>
28
28
  <tr>
29
- <td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-20%20160506.png" alt="Content Sync Pro - screenshot 1" width="100%" /></td>
30
- <td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-20%20160558.png" alt="Content Sync Pro - screenshot 2" width="100%" /></td>
31
- <td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-20%20175903.png" alt="Content Sync Pro - screenshot 3" width="100%" /></td>
29
+ <td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20114332.png" alt="Content Sync Pro - screenshot 1" width="100%" /></td>
30
+ <td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20114644.png" alt="Content Sync Pro - screenshot 2" width="100%" /></td>
31
+ <td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20114651.png" alt="Content Sync Pro - screenshot 3" width="100%" /></td>
32
32
  </tr>
33
33
  <tr>
34
- <td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-20%20175931.png" alt="Content Sync Pro - screenshot 4" width="100%" /></td>
35
- <td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-20%20180001.png" alt="Content Sync Pro - screenshot 5" width="100%" /></td>
36
- <td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-20%20180041.png" alt="Content Sync Pro - screenshot 6" width="100%" /></td>
34
+ <td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20114737.png" alt="Content Sync Pro - screenshot 4" width="100%" /></td>
35
+ <td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20114904.png" alt="Content Sync Pro - screenshot 5" width="100%" /></td>
36
+ <td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20114940.png" alt="Content Sync Pro - screenshot 6" width="100%" /></td>
37
37
  </tr>
38
38
  <tr>
39
- <td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-20%20180116.png" alt="Content Sync Pro - screenshot 7" width="100%" /></td>
40
- <td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-20%20180135.png" alt="Content Sync Pro - screenshot 8" width="100%" /></td>
41
- <td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-20%20180202.png" alt="Content Sync Pro - screenshot 9" width="100%" /></td>
39
+ <td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20115003.png" alt="Content Sync Pro - screenshot 7" width="100%" /></td>
40
+ <td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20115024.png" alt="Content Sync Pro - screenshot 8" width="100%" /></td>
41
+ <td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20115116.png" alt="Content Sync Pro - screenshot 9" width="100%" /></td>
42
42
  </tr>
43
43
  <tr>
44
- <td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-20%20180228.png" alt="Content Sync Pro - screenshot 10" width="100%" /></td>
45
- <td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-20%20180251.png" alt="Content Sync Pro - screenshot 11" width="100%" /></td>
46
- <td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-20%20180301.png" alt="Content Sync Pro - screenshot 12" width="100%" /></td>
44
+ <td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20115141.png" alt="Content Sync Pro - screenshot 10" width="100%" /></td>
45
+ <td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20115252.png" alt="Content Sync Pro - screenshot 11" width="100%" /></td>
46
+ <td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20115448.png" alt="Content Sync Pro - screenshot 12" width="100%" /></td>
47
+ </tr>
48
+ <tr>
49
+ <td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20120534.png" alt="Content Sync Pro - screenshot 13" width="100%" /></td>
50
+ <td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20122544.png" alt="Content Sync Pro - screenshot 14" width="100%" /></td>
51
+ <td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20122712.png" alt="Content Sync Pro - screenshot 15" width="100%" /></td>
52
+ </tr>
53
+ <tr>
54
+ <td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20122730.png" alt="Content Sync Pro - screenshot 16" width="100%" /></td>
55
+ <td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20122858.png" alt="Content Sync Pro - screenshot 17" width="100%" /></td>
56
+ <td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20122924.png" alt="Content Sync Pro - screenshot 18" width="100%" /></td>
57
+ </tr>
58
+ <tr>
59
+ <td width="33%"><img src="https://raw.githubusercontent.com/eharain/strapi-content-sync-pro/master/docs/Screenshot%202026-04-23%20122937.png" alt="Content Sync Pro - screenshot 19" width="100%" /></td>
60
+ <td width="33%"></td>
61
+ <td width="33%"></td>
47
62
  </tr>
48
63
  </table>
49
64
  </details>
@@ -191,6 +206,7 @@ Full media synchronization between Strapi instances:
191
206
  - **Profile-based** — Create media sync profiles with direction, conflict strategy, MIME filters, filename patterns, and execution settings.
192
207
  - **DB + File Sync** — Syncs both the `plugin::upload.file` database rows and the actual file bytes.
193
208
  - **Morph Link Remapping** — Syncs `files_related_morphs` links by mapping file + related entities through documentId, then remapping to local numeric ids before insert.
209
+ - **Live Status + Pause/Resume/Stop** — The Media tab polls status every 2 s while a profile is running or paused and shows live phase and counters (`pushed`, `pulled`, `skipped`, `errors`). Long runs can be paused, resumed, or stopped cooperatively from the UI or via `POST /api/strapi-content-sync-pro/media-sync/profiles/:id/pause|resume|cancel` (URL strategy; rsync runs cannot be paused mid-process).
194
210
 
195
211
  ## Enforcement
196
212
 
@@ -293,7 +309,8 @@ Check the **Logs** tab for detailed sync history including:
293
309
 
294
310
  ## Contributing
295
311
 
296
- Contributions are welcome! Please open an issue or submit a pull request.
312
+ Contributions are welcome! Please open an issue or submit a pull request.
313
+
297
314
 
298
315
  ## License
299
316
 
@@ -143,6 +143,38 @@ const ConfigTab = () => {
143
143
  }
144
144
  };
145
145
 
146
+ // Validate + normalize a Strapi Server URL entered by the user.
147
+ // Returns { ok: true, normalized } or { ok: false, error }.
148
+ const validateBaseUrl = (raw) => {
149
+ if (!raw || !raw.trim()) {
150
+ return { ok: false, error: 'Server URL is required' };
151
+ }
152
+ let url = raw.trim();
153
+ if (!/^https?:\/\//i.test(url)) {
154
+ return {
155
+ ok: false,
156
+ error: 'Server URL must start with http:// or https:// (e.g. http://localhost:4010)',
157
+ };
158
+ }
159
+ // Strip trailing slashes and common mistakes like /admin or /api
160
+ url = url.replace(/\/+$/, '').replace(/\/admin$/i, '').replace(/\/api$/i, '');
161
+ try {
162
+ const parsed = new URL(url);
163
+ if (!parsed.hostname) {
164
+ return { ok: false, error: 'Server URL is missing a hostname' };
165
+ }
166
+ if (parsed.pathname && parsed.pathname !== '/' && parsed.pathname !== '') {
167
+ return {
168
+ ok: false,
169
+ error: `Server URL should be the Strapi root (e.g. http://localhost:4010), not include a path like "${parsed.pathname}"`,
170
+ };
171
+ }
172
+ } catch {
173
+ return { ok: false, error: 'Server URL is not a valid URL' };
174
+ }
175
+ return { ok: true, normalized: url };
176
+ };
177
+
146
178
  // Login with credentials to remote server and get/create API token
147
179
  const handleLoginWithCredentials = async () => {
148
180
  if (!config.baseUrl || !credentials.email || !credentials.password) {
@@ -150,12 +182,22 @@ const ConfigTab = () => {
150
182
  return;
151
183
  }
152
184
 
185
+ const urlCheck = validateBaseUrl(config.baseUrl);
186
+ if (!urlCheck.ok) {
187
+ setLoginState({ loading: false, success: false, error: urlCheck.error });
188
+ return;
189
+ }
190
+ // Auto-correct the stored value if we had to normalize it (e.g. trim slash or /admin)
191
+ if (urlCheck.normalized !== config.baseUrl) {
192
+ setConfig((prev) => ({ ...prev, baseUrl: urlCheck.normalized }));
193
+ }
194
+
153
195
  setLoginState({ loading: true, success: false, error: null });
154
196
 
155
197
  try {
156
198
  // Call our backend to proxy the login request
157
199
  const response = await post(`/${PLUGIN_ID}/config/remote-login`, {
158
- baseUrl: config.baseUrl,
200
+ baseUrl: urlCheck.normalized,
159
201
  email: credentials.email,
160
202
  password: credentials.password,
161
203
  });
@@ -368,10 +410,46 @@ const ConfigTab = () => {
368
410
  placeholder="https://my-other-strapi.com"
369
411
  value={config.baseUrl}
370
412
  onChange={(e) => setConfig((p) => ({ ...p, baseUrl: e.target.value }))}
413
+ onBlur={(e) => {
414
+ const v = validateBaseUrl(e.target.value);
415
+ if (v.ok && v.normalized !== e.target.value) {
416
+ setConfig((p) => ({ ...p, baseUrl: v.normalized }));
417
+ }
418
+ }}
371
419
  />
372
- <Field.Hint>URL of the remote Strapi server where this plugin is also installed</Field.Hint>
420
+ <Field.Hint>URL of the remote Strapi server where this plugin is also installed. Use the root URL only (e.g. http://localhost:4010) — do not append /admin or /api.</Field.Hint>
421
+ {config.baseUrl && (() => {
422
+ const v = validateBaseUrl(config.baseUrl);
423
+ return v.ok ? null : (
424
+ <Box paddingTop={1}>
425
+ <Typography variant="pi" textColor="danger600">
426
+ {v.error}
427
+ </Typography>
428
+ </Box>
429
+ );
430
+ })()}
373
431
  </Field.Root>
374
432
 
433
+ <Box>
434
+ <Alert variant="default" title="Before you continue">
435
+ <Box>
436
+ <Typography variant="pi">
437
+ • <strong>Server URL</strong> must be the Strapi root (e.g. <code>http://localhost:4010</code>) — not <code>/admin</code>, <code>/api</code>, or an internal IP the server can't see.
438
+ </Typography>
439
+ </Box>
440
+ <Box paddingTop={1}>
441
+ <Typography variant="pi">
442
+ • Use an <strong>existing admin user's</strong> email and password for the remote Strapi panel. This is not an API token and not a local DB user.
443
+ </Typography>
444
+ </Box>
445
+ <Box paddingTop={1}>
446
+ <Typography variant="pi">
447
+ • If you see "Missing or invalid credentials", verify the URL first, then the email/password. A wrong URL often surfaces as an auth error.
448
+ </Typography>
449
+ </Box>
450
+ </Alert>
451
+ </Box>
452
+
375
453
  <Field.Root>
376
454
  <Field.Label>API Token</Field.Label>
377
455
  <Flex gap={2}>
@@ -391,7 +469,7 @@ const ConfigTab = () => {
391
469
  <Button
392
470
  variant="secondary"
393
471
  onClick={() => setShowLoginModal(true)}
394
- disabled={!config.baseUrl}
472
+ disabled={!config.baseUrl || !validateBaseUrl(config.baseUrl).ok}
395
473
  >
396
474
  {config.apiToken ? 'Regenerate' : 'Generate'}
397
475
  </Button>
@@ -840,6 +840,40 @@ http://localhost:1337</CodeBlock>
840
840
  </Typography>
841
841
  </HelpSection>
842
842
 
843
+ <HelpSection title="Live status, Pause, Resume, and Stop">
844
+ <Typography variant="omega">
845
+ Media sync runs can take a long time. The Media tab shows live state for any profile
846
+ that is currently running or paused and automatically polls status every 2 seconds
847
+ while work is in progress, so you can navigate away and come back without losing
848
+ visibility. The Status sub-tab shows the active phase (for example
849
+ <code> listing</code>, <code>pushing</code>, <code>pulling</code>) and live counters
850
+ for <code>pushed</code>, <code>pulled</code>, <code>skipped</code>, and
851
+ <code> errors</code>.
852
+ </Typography>
853
+ <ul style={{ paddingLeft: '20px', marginTop: '8px', lineHeight: '1.8' }}>
854
+ <li><Typography variant="omega"><strong>Run</strong> - starts the profile as a background job; the UI flips to Running immediately and keeps polling.</Typography></li>
855
+ <li><Typography variant="omega"><strong>Pause</strong> - requests a cooperative halt at the next checkpoint (between pages/batches). Already in-flight file transfers finish first.</Typography></li>
856
+ <li><Typography variant="omega"><strong>Resume</strong> - continues a paused run from where it stopped without redoing completed work.</Typography></li>
857
+ <li><Typography variant="omega"><strong>Stop</strong> - cancels the run at the next checkpoint. Work already synced is kept; remaining items are skipped.</Typography></li>
858
+ </ul>
859
+ <Typography variant="omega" paddingTop={2}>
860
+ Corresponding endpoints:
861
+ </Typography>
862
+ <ul style={{ paddingLeft: '20px', marginTop: '8px', lineHeight: '1.8' }}>
863
+ <li><code>POST /api/strapi-content-sync-pro/media-sync/profiles/:id/pause</code></li>
864
+ <li><code>POST /api/strapi-content-sync-pro/media-sync/profiles/:id/resume</code></li>
865
+ <li><code>POST /api/strapi-content-sync-pro/media-sync/profiles/:id/cancel</code></li>
866
+ <li><code>GET /api/strapi-content-sync-pro/media-sync/status</code> (returns <code>running</code>, <code>paused</code>, and per-profile <code>progress</code>)</li>
867
+ </ul>
868
+ <Typography variant="pi" textColor="warning600" paddingTop={2}>
869
+ <strong>Note:</strong> Pause/Resume/Stop are implemented for the <strong>URL</strong>
870
+ strategy, which is the default and most common choice. The <strong>rsync</strong>
871
+ strategy runs as a single child process and cannot be paused mid-flight; for rsync
872
+ profiles these controls are a no-op until the current rsync invocation finishes on
873
+ its own.
874
+ </Typography>
875
+ </HelpSection>
876
+
843
877
  <HelpSection title="Dry run & testing">
844
878
  <Typography variant="omega">
845
879
  Toggle <strong>Dry run</strong> on a profile to list what would change without transferring any
@@ -19,7 +19,7 @@ import {
19
19
  Dialog,
20
20
  IconButton,
21
21
  } from '@strapi/design-system';
22
- import { Pencil, Trash, Play, Check } from '@strapi/icons';
22
+ import { Pencil, Trash, Play, Check, Stop } from '@strapi/icons';
23
23
  import { useFetchClient } from '@strapi/strapi/admin';
24
24
 
25
25
  const PLUGIN_ID = 'strapi-content-sync-pro';
@@ -122,8 +122,25 @@ const MediaTab = () => {
122
122
  }
123
123
  };
124
124
 
125
+ const refreshStatus = async () => {
126
+ try {
127
+ const sRes = await get(`/${PLUGIN_ID}/media-sync/status`);
128
+ setStatus(sRes.data.data || {});
129
+ } catch {
130
+ /* silent — polling should not spam errors */
131
+ }
132
+ };
133
+
125
134
  useEffect(() => { reload(); }, []);
126
135
 
136
+ // Live status polling: while anything is running or paused, poll every 2s.
137
+ useEffect(() => {
138
+ const anyActive = (status?.profiles || []).some((p) => p.running || p.paused);
139
+ if (!anyActive) return undefined;
140
+ const id = setInterval(refreshStatus, 2000);
141
+ return () => clearInterval(id);
142
+ }, [status]);
143
+
127
144
  const handleSaveGlobal = async () => {
128
145
  setSaving(true); setMessage(null);
129
146
  try {
@@ -174,25 +191,67 @@ const MediaTab = () => {
174
191
  };
175
192
 
176
193
  const handleRunProfile = async (id) => {
177
- setRunning(true); setMessage(null);
194
+ setMessage(null);
195
+ // Fire-and-forget: don't await — the sync may run for a long time.
196
+ // Poll status to reflect progress and completion.
197
+ post(`/${PLUGIN_ID}/media-sync/profiles/${id}/run`, {})
198
+ .then(() => {
199
+ setMessage({ type: 'success', text: 'Media sync complete.' });
200
+ refreshStatus();
201
+ })
202
+ .catch((err) => {
203
+ setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message });
204
+ refreshStatus();
205
+ });
206
+ setMessage({ type: 'success', text: 'Media sync started. You can pause or stop it from the Status tab.' });
207
+ // Kick an immediate status refresh so the UI flips to Running right away.
208
+ setTimeout(refreshStatus, 500);
209
+ };
210
+
211
+ const handleRunAll = async () => {
212
+ setMessage(null);
213
+ post(`/${PLUGIN_ID}/media-sync/run-active`, {})
214
+ .then(() => {
215
+ setMessage({ type: 'success', text: 'All active media profiles synced.' });
216
+ refreshStatus();
217
+ })
218
+ .catch((err) => {
219
+ setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message });
220
+ refreshStatus();
221
+ });
222
+ setMessage({ type: 'success', text: 'Sync All started. Watch progress in the Status tab.' });
223
+ setTimeout(refreshStatus, 500);
224
+ };
225
+
226
+ const handlePauseProfile = async (id) => {
178
227
  try {
179
- await post(`/${PLUGIN_ID}/media-sync/profiles/${id}/run`, {});
180
- setMessage({ type: 'success', text: 'Media sync complete.' });
181
- await reload();
228
+ await post(`/${PLUGIN_ID}/media-sync/profiles/${id}/pause`, {});
229
+ setMessage({ type: 'success', text: 'Pause requested. The run will halt at the next checkpoint.' });
230
+ refreshStatus();
182
231
  } catch (err) {
183
232
  setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message });
184
- } finally { setRunning(false); }
233
+ }
185
234
  };
186
235
 
187
- const handleRunAll = async () => {
188
- setRunning(true); setMessage(null);
236
+ const handleResumeProfile = async (id) => {
189
237
  try {
190
- await post(`/${PLUGIN_ID}/media-sync/run-active`, {});
191
- setMessage({ type: 'success', text: 'All active media profiles synced.' });
192
- await reload();
238
+ await post(`/${PLUGIN_ID}/media-sync/profiles/${id}/resume`, {});
239
+ setMessage({ type: 'success', text: 'Run resumed.' });
240
+ refreshStatus();
193
241
  } catch (err) {
194
242
  setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message });
195
- } finally { setRunning(false); }
243
+ }
244
+ };
245
+
246
+ const handleCancelProfile = async (id) => {
247
+ if (!confirm('Stop this media profile run? Progress already done is kept, remaining work is aborted.')) return;
248
+ try {
249
+ await post(`/${PLUGIN_ID}/media-sync/profiles/${id}/cancel`, {});
250
+ setMessage({ type: 'success', text: 'Stop requested.' });
251
+ refreshStatus();
252
+ } catch (err) {
253
+ setMessage({ type: 'danger', text: err?.response?.data?.error?.message || err.message });
254
+ }
196
255
  };
197
256
 
198
257
  const handleTest = async () => {
@@ -245,7 +304,7 @@ const MediaTab = () => {
245
304
  <Typography variant="delta">Media Sync Profiles</Typography>
246
305
  <Flex gap={2}>
247
306
  <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>
307
+ <Button variant="secondary" onClick={handleRunAll} disabled={!!status?.running}>Sync All Active</Button>
249
308
  <Button onClick={() => { setEditProfile({ ...EMPTY_PROFILE, includeMime: defaults?.mimeAll || [] }); setEditMode('create'); }}>
250
309
  Create Profile
251
310
  </Button>
@@ -289,9 +348,30 @@ const MediaTab = () => {
289
348
  {!p.active && (
290
349
  <Button variant="tertiary" size="S" onClick={() => handleActivate(p.id)} startIcon={<Check />}>Activate</Button>
291
350
  )}
292
- {p.active && (
293
- <Button variant="secondary" size="S" onClick={() => handleRunProfile(p.id)} loading={running} disabled={running} startIcon={<Play />}>Run</Button>
294
- )}
351
+ {p.active && (() => {
352
+ const sp = (status?.profiles || []).find((x) => x.id === p.id);
353
+ const isRunning = !!sp?.running;
354
+ const isPaused = !!sp?.paused;
355
+ if (isRunning && !isPaused) {
356
+ return (
357
+ <>
358
+ <Button variant="tertiary" size="S" onClick={() => handlePauseProfile(p.id)}>Pause</Button>
359
+ <IconButton label="Stop" onClick={() => handleCancelProfile(p.id)}><Stop /></IconButton>
360
+ </>
361
+ );
362
+ }
363
+ if (isPaused) {
364
+ return (
365
+ <>
366
+ <Button variant="secondary" size="S" onClick={() => handleResumeProfile(p.id)} startIcon={<Play />}>Resume</Button>
367
+ <IconButton label="Stop" onClick={() => handleCancelProfile(p.id)}><Stop /></IconButton>
368
+ </>
369
+ );
370
+ }
371
+ return (
372
+ <Button variant="secondary" size="S" onClick={() => handleRunProfile(p.id)} startIcon={<Play />}>Run</Button>
373
+ );
374
+ })()}
295
375
  <IconButton label="Edit" onClick={() => { setEditProfile({ ...p }); setEditMode('edit'); }}><Pencil /></IconButton>
296
376
  <IconButton label="Delete" onClick={() => handleDelete(p.id)}><Trash /></IconButton>
297
377
  </Flex>
@@ -382,21 +462,52 @@ const MediaTab = () => {
382
462
  {/* ── Status Tab ──────────────────────────────────────────────── */}
383
463
  <Tabs.Content value="status">
384
464
  <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>
465
+ <Flex justifyContent="space-between" alignItems="center" paddingBottom={3}>
466
+ <Typography variant="delta">Media Sync Status</Typography>
467
+ <Button variant="tertiary" size="S" onClick={refreshStatus}>Refresh</Button>
468
+ </Flex>
469
+ {status?.profiles?.map((sp) => {
470
+ const prog = sp.progress || null;
471
+ const stateLabel = sp.paused ? 'Paused' : sp.running ? 'Running' : 'Idle';
472
+ return (
473
+ <Box key={sp.id} background="neutral0" padding={3} hasRadius shadow="tableShadow" marginBottom={2}>
474
+ <Flex justifyContent="space-between" alignItems="center">
475
+ <Flex gap={2} alignItems="center">
476
+ <Typography variant="omega" fontWeight="bold">{sp.name}</Typography>
477
+ {sp.active && <Badge active>Active</Badge>}
478
+ <Badge>{stateLabel}</Badge>
479
+ {prog?.phase && (sp.running || sp.paused) && (
480
+ <Typography variant="pi" textColor="neutral600">phase: {prog.phase}</Typography>
481
+ )}
482
+ </Flex>
483
+ <Flex gap={1} alignItems="center">
484
+ {sp.running && !sp.paused && (
485
+ <>
486
+ <Button variant="tertiary" size="S" onClick={() => handlePauseProfile(sp.id)}>Pause</Button>
487
+ <Button variant="danger-light" size="S" startIcon={<Stop />} onClick={() => handleCancelProfile(sp.id)}>Stop</Button>
488
+ </>
489
+ )}
490
+ {sp.paused && (
491
+ <>
492
+ <Button variant="secondary" size="S" startIcon={<Play />} onClick={() => handleResumeProfile(sp.id)}>Resume</Button>
493
+ <Button variant="danger-light" size="S" startIcon={<Stop />} onClick={() => handleCancelProfile(sp.id)}>Stop</Button>
494
+ </>
495
+ )}
496
+ <Typography variant="pi" textColor="neutral600" paddingLeft={2}>
497
+ Mode: {(sp.executionMode || '').replace('_', ' ')} | Last: {sp.lastExecutedAt ? new Date(sp.lastExecutedAt).toLocaleString() : 'never'}
498
+ </Typography>
499
+ </Flex>
393
500
  </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
- ))}
501
+ {(sp.running || sp.paused) && prog && (
502
+ <Box paddingTop={2}>
503
+ <Typography variant="pi" textColor="neutral700">
504
+ pushed: {prog.pushed || 0} · pulled: {prog.pulled || 0} · skipped: {prog.skipped || 0} · errors: {prog.errors || 0}
505
+ </Typography>
506
+ </Box>
507
+ )}
508
+ </Box>
509
+ );
510
+ })}
400
511
  {status?.lastResult && (
401
512
  <Box paddingTop={3} background="neutral0" padding={4} hasRadius shadow="tableShadow">
402
513
  <Typography variant="sigma">Last Run Result</Typography>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strapi-content-sync-pro",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "Strapi v5 plugin to copy, migrate, and live-sync content, media, and data between multiple Strapi environments with bi-directional sync, field-level policies, scheduling, and alerts.",
5
5
  "license": "MIT",
6
6
  "author": {