@echomem/echo-memory-cloud-openclaw-plugin 0.1.1 → 0.1.2
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/clawdbot.plugin.json +1 -1
- package/lib/api-client.js +125 -44
- package/lib/local-server.js +554 -472
- package/lib/local-ui/src/App.jsx +131 -23
- package/lib/local-ui/src/canvas/Viewport.jsx +10 -8
- package/lib/local-ui/src/cards/Card.css +13 -0
- package/lib/local-ui/src/cards/Card.jsx +10 -1
- package/lib/local-ui/src/styles/global.css +8 -0
- package/lib/local-ui/src/sync/api.js +6 -5
- package/lib/sync.js +480 -207
- package/moltbot.plugin.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/lib/local-ui/src/App.jsx
CHANGED
|
@@ -75,6 +75,44 @@ function formatDuration(ms) {
|
|
|
75
75
|
return `${hours}h ${minutes % 60}m`;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
function basenameFromPath(relativePath) {
|
|
79
|
+
if (!relativePath) return '';
|
|
80
|
+
const parts = String(relativePath).split(/[\\/]/);
|
|
81
|
+
return parts[parts.length - 1] || relativePath;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function formatStageLabel(stage) {
|
|
85
|
+
if (!stage) return null;
|
|
86
|
+
const normalized = String(stage).trim().toLowerCase();
|
|
87
|
+
if (!normalized) return null;
|
|
88
|
+
return normalized.replace(/[_-]+/g, ' ');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function buildSyncResultState(result) {
|
|
92
|
+
const summary = result?.summary || {};
|
|
93
|
+
const runResults = Array.isArray(result?.run_results) ? result.run_results : [];
|
|
94
|
+
const failed = runResults.filter((item) => item?.status === 'failed');
|
|
95
|
+
const parts = [];
|
|
96
|
+
if (summary.new_memory_count > 0) parts.push(`${summary.new_memory_count} new memories`);
|
|
97
|
+
if (summary.new_source_count > 0) parts.push(`${summary.new_source_count} files uploaded`);
|
|
98
|
+
if (summary.skipped_count > 0) parts.push(`${summary.skipped_count} already synced`);
|
|
99
|
+
if (summary.duplicate_count > 0) parts.push(`${summary.duplicate_count} duplicates`);
|
|
100
|
+
if (summary.failed_file_count > 0) parts.push(`${summary.failed_file_count} failed`);
|
|
101
|
+
|
|
102
|
+
let msg = parts.join(' | ') || 'Sync complete';
|
|
103
|
+
if (failed.length > 0 && failed.length === runResults.length && runResults.length > 0) {
|
|
104
|
+
msg = `All ${failed.length} selected file${failed.length === 1 ? '' : 's'} failed`;
|
|
105
|
+
} else if (failed.length > 0) {
|
|
106
|
+
msg = `Partial failure | ${msg}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
ok: failed.length === 0,
|
|
111
|
+
msg,
|
|
112
|
+
failed,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
78
116
|
export default function App() {
|
|
79
117
|
const [files, setFiles] = useState([]);
|
|
80
118
|
const [contentMap, setContentMap] = useState(null);
|
|
@@ -166,10 +204,10 @@ export default function App() {
|
|
|
166
204
|
return;
|
|
167
205
|
}
|
|
168
206
|
|
|
169
|
-
if (progress.phase === '
|
|
207
|
+
if (progress.phase === 'file-started') {
|
|
170
208
|
setCardSyncState((prev) => {
|
|
171
209
|
const next = { ...prev };
|
|
172
|
-
for (const path of progress.currentRelativePaths || []) {
|
|
210
|
+
for (const path of progress.currentRelativePaths || (progress.currentRelativePath ? [progress.currentRelativePath] : [])) {
|
|
173
211
|
next[path] = 'syncing';
|
|
174
212
|
}
|
|
175
213
|
return next;
|
|
@@ -177,11 +215,28 @@ export default function App() {
|
|
|
177
215
|
return;
|
|
178
216
|
}
|
|
179
217
|
|
|
180
|
-
if (progress.phase === '
|
|
218
|
+
if (progress.phase === 'file-stage') {
|
|
181
219
|
setCardSyncState((prev) => {
|
|
182
220
|
const next = { ...prev };
|
|
183
|
-
for (const path of progress.
|
|
184
|
-
next[path] = '
|
|
221
|
+
for (const path of progress.currentRelativePaths || (progress.currentRelativePath ? [progress.currentRelativePath] : [])) {
|
|
222
|
+
next[path] = 'syncing';
|
|
223
|
+
}
|
|
224
|
+
return next;
|
|
225
|
+
});
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (progress.phase === 'file-finished') {
|
|
230
|
+
setCardSyncState((prev) => {
|
|
231
|
+
const next = { ...prev };
|
|
232
|
+
const recentStatus = progress.recentFileResult?.status;
|
|
233
|
+
const completedPaths = progress.completedRelativePaths || [];
|
|
234
|
+
const failedPaths = progress.failedRelativePaths || [];
|
|
235
|
+
for (const path of completedPaths) {
|
|
236
|
+
next[path] = recentStatus === 'failed' ? 'failed' : 'done';
|
|
237
|
+
}
|
|
238
|
+
for (const path of failedPaths) {
|
|
239
|
+
next[path] = 'failed';
|
|
185
240
|
}
|
|
186
241
|
return next;
|
|
187
242
|
});
|
|
@@ -257,6 +312,14 @@ export default function App() {
|
|
|
257
312
|
return computeSystemLayout(layout.systemFiles || [], vpWidth, contentMap);
|
|
258
313
|
}, [view, layout.systemFiles, vpWidth, contentMap]);
|
|
259
314
|
|
|
315
|
+
const syncMetaByPath = useMemo(() => {
|
|
316
|
+
const next = {};
|
|
317
|
+
for (const status of syncStatus?.fileStatuses || []) {
|
|
318
|
+
next[status.relativePath] = status;
|
|
319
|
+
}
|
|
320
|
+
return next;
|
|
321
|
+
}, [syncStatus]);
|
|
322
|
+
|
|
260
323
|
const syncMap = useMemo(() => {
|
|
261
324
|
const next = {};
|
|
262
325
|
|
|
@@ -274,7 +337,7 @@ export default function App() {
|
|
|
274
337
|
);
|
|
275
338
|
for (const file of files) {
|
|
276
339
|
const normalized = normalizePathKey(file.absolutePath || file.filePath);
|
|
277
|
-
if (normalized && backendPaths.has(normalized)) {
|
|
340
|
+
if (normalized && backendPaths.has(normalized) && !next[file.relativePath]) {
|
|
278
341
|
next[file.relativePath] = 'synced';
|
|
279
342
|
}
|
|
280
343
|
}
|
|
@@ -291,6 +354,16 @@ export default function App() {
|
|
|
291
354
|
return next;
|
|
292
355
|
}, [syncStatus, backendSources, files]);
|
|
293
356
|
|
|
357
|
+
const selectablePaths = useMemo(
|
|
358
|
+
() =>
|
|
359
|
+
new Set(
|
|
360
|
+
(syncStatus?.fileStatuses || [])
|
|
361
|
+
.filter((status) => status.syncEligible && ['new', 'modified', 'failed'].includes(status.status))
|
|
362
|
+
.map((status) => status.relativePath),
|
|
363
|
+
),
|
|
364
|
+
[syncStatus],
|
|
365
|
+
);
|
|
366
|
+
|
|
294
367
|
const stats = useMemo(() => {
|
|
295
368
|
const t1 = filteredAnnotated.filter((file) => file._tier === 1).length;
|
|
296
369
|
const t2 = filteredAnnotated.filter((file) => file._tier === 2).length;
|
|
@@ -302,7 +375,7 @@ export default function App() {
|
|
|
302
375
|
const pendingCount = useMemo(() => {
|
|
303
376
|
let count = 0;
|
|
304
377
|
for (const status of Object.values(syncMap)) {
|
|
305
|
-
if (status === 'new' || status === 'modified') count++;
|
|
378
|
+
if (status === 'new' || status === 'modified' || status === 'failed') count++;
|
|
306
379
|
}
|
|
307
380
|
return count;
|
|
308
381
|
}, [syncMap]);
|
|
@@ -312,6 +385,14 @@ export default function App() {
|
|
|
312
385
|
return Math.max(0, Math.min(100, Math.round((syncProgress.completedFiles / syncProgress.totalFiles) * 100)));
|
|
313
386
|
}, [syncProgress]);
|
|
314
387
|
|
|
388
|
+
useEffect(() => {
|
|
389
|
+
setSyncSelection((prev) => {
|
|
390
|
+
const next = new Set([...prev].filter((path) => selectablePaths.has(path)));
|
|
391
|
+
if (next.size === prev.size) return prev;
|
|
392
|
+
return next;
|
|
393
|
+
});
|
|
394
|
+
}, [selectablePaths]);
|
|
395
|
+
|
|
315
396
|
const handleSync = useCallback(async () => {
|
|
316
397
|
setSyncing(true);
|
|
317
398
|
setSyncResult(null);
|
|
@@ -325,14 +406,7 @@ export default function App() {
|
|
|
325
406
|
} else {
|
|
326
407
|
result = await triggerSync();
|
|
327
408
|
}
|
|
328
|
-
|
|
329
|
-
const parts = [];
|
|
330
|
-
if (summary.new_memory_count > 0) parts.push(`${summary.new_memory_count} new memories`);
|
|
331
|
-
if (summary.new_source_count > 0) parts.push(`${summary.new_source_count} files uploaded`);
|
|
332
|
-
if (summary.skipped_count > 0) parts.push(`${summary.skipped_count} already synced`);
|
|
333
|
-
if (summary.duplicate_count > 0) parts.push(`${summary.duplicate_count} duplicates`);
|
|
334
|
-
if (summary.failed_file_count > 0) parts.push(`${summary.failed_file_count} failed`);
|
|
335
|
-
setSyncResult({ ok: true, msg: parts.join(' | ') || 'Sync complete' });
|
|
409
|
+
setSyncResult(buildSyncResultState(result));
|
|
336
410
|
loadSyncStatus();
|
|
337
411
|
loadBackendSources();
|
|
338
412
|
} catch (error) {
|
|
@@ -343,13 +417,14 @@ export default function App() {
|
|
|
343
417
|
}, [loadBackendSources, loadSyncStatus, selectMode, syncSelection]);
|
|
344
418
|
|
|
345
419
|
const toggleFileSelection = useCallback((filePath) => {
|
|
420
|
+
if (!selectablePaths.has(filePath)) return;
|
|
346
421
|
setSyncSelection((prev) => {
|
|
347
422
|
const next = new Set(prev);
|
|
348
423
|
if (next.has(filePath)) next.delete(filePath);
|
|
349
424
|
else next.add(filePath);
|
|
350
425
|
return next;
|
|
351
426
|
});
|
|
352
|
-
}, []);
|
|
427
|
+
}, [selectablePaths]);
|
|
353
428
|
|
|
354
429
|
const handleSetupFieldChange = useCallback((key, value) => {
|
|
355
430
|
setSetupDraft((prev) => ({ ...prev, [key]: value }));
|
|
@@ -513,14 +588,16 @@ export default function App() {
|
|
|
513
588
|
sections={activeLayout.sections}
|
|
514
589
|
bounds={activeLayout.bounds}
|
|
515
590
|
syncStatus={syncMap}
|
|
591
|
+
syncMetaByPath={syncMetaByPath}
|
|
516
592
|
transientStatusMap={cardSyncState}
|
|
517
593
|
contentMap={contentMap}
|
|
518
594
|
selectedPath={selectedPath}
|
|
519
595
|
selectMode={selectMode}
|
|
520
596
|
syncSelection={syncSelection}
|
|
597
|
+
selectablePaths={selectablePaths}
|
|
521
598
|
onCardClick={(path) => {
|
|
522
599
|
if (selectMode) {
|
|
523
|
-
if (path) toggleFileSelection(path);
|
|
600
|
+
if (path && selectablePaths.has(path)) toggleFileSelection(path);
|
|
524
601
|
return;
|
|
525
602
|
}
|
|
526
603
|
if (path === null) {
|
|
@@ -554,24 +631,45 @@ export default function App() {
|
|
|
554
631
|
<div className="sync-progress-dock">
|
|
555
632
|
<div className="sync-progress-top">
|
|
556
633
|
<span className="sync-progress-title">
|
|
557
|
-
{syncProgress.phase === 'failed'
|
|
634
|
+
{syncProgress.phase === 'failed'
|
|
635
|
+
? 'Sync failed'
|
|
636
|
+
: syncProgress.phase === 'finished'
|
|
637
|
+
? syncProgress.failedCount > 0
|
|
638
|
+
? syncProgress.failedCount === syncProgress.totalFiles
|
|
639
|
+
? 'All files failed'
|
|
640
|
+
: 'Sync finished with failures'
|
|
641
|
+
: 'Sync complete'
|
|
642
|
+
: 'Sync in progress'}
|
|
558
643
|
</span>
|
|
559
644
|
<span className="sync-progress-meta">
|
|
560
|
-
{syncProgress.completedFiles} / {syncProgress.totalFiles} files
|
|
645
|
+
{Math.max(syncProgress.currentFileIndex || syncProgress.completedFiles, syncProgress.completedFiles)} / {syncProgress.totalFiles} files
|
|
561
646
|
</span>
|
|
562
|
-
{syncProgress.
|
|
647
|
+
{syncProgress.currentRelativePath && (
|
|
648
|
+
<span className="sync-progress-meta">
|
|
649
|
+
File {basenameFromPath(syncProgress.currentRelativePath)}
|
|
650
|
+
</span>
|
|
651
|
+
)}
|
|
652
|
+
{formatStageLabel(syncProgress.currentStage) && (
|
|
563
653
|
<span className="sync-progress-meta">
|
|
564
|
-
|
|
654
|
+
Stage {formatStageLabel(syncProgress.currentStage)}
|
|
565
655
|
</span>
|
|
566
656
|
)}
|
|
567
657
|
<span className="sync-progress-meta">Elapsed {formatDuration(syncProgress.elapsedMs)}</span>
|
|
568
658
|
{syncProgress.etaMs && syncProgress.phase !== 'finished' && syncProgress.phase !== 'failed' && (
|
|
569
659
|
<span className="sync-progress-meta">ETA {formatDuration(syncProgress.etaMs)}</span>
|
|
570
660
|
)}
|
|
661
|
+
<span className="sync-progress-meta">
|
|
662
|
+
OK {syncProgress.successCount} | Failed {syncProgress.failedCount}
|
|
663
|
+
</span>
|
|
571
664
|
</div>
|
|
572
665
|
<div className="sync-progress-track">
|
|
573
666
|
<div className="sync-progress-fill" style={{ width: `${syncProgressPercent}%` }} />
|
|
574
667
|
</div>
|
|
668
|
+
{syncProgress.recentFileResult?.status === 'failed' && (
|
|
669
|
+
<div className="sync-progress-detail">
|
|
670
|
+
Failed {basenameFromPath(syncProgress.recentFileResult.relativePath || syncProgress.currentRelativePath)}: {syncProgress.recentFileResult.lastError || 'Unknown error'}
|
|
671
|
+
</div>
|
|
672
|
+
)}
|
|
575
673
|
</div>
|
|
576
674
|
)}
|
|
577
675
|
|
|
@@ -585,7 +683,7 @@ export default function App() {
|
|
|
585
683
|
className="ftr-select-toggle"
|
|
586
684
|
onClick={() => {
|
|
587
685
|
const pending = (syncStatus?.fileStatuses || [])
|
|
588
|
-
.filter((status) => status.
|
|
686
|
+
.filter((status) => status.syncEligible && ['new', 'modified', 'failed'].includes(status.status))
|
|
589
687
|
.map((status) => status.relativePath);
|
|
590
688
|
setSyncSelection(new Set(pending));
|
|
591
689
|
}}
|
|
@@ -615,7 +713,17 @@ export default function App() {
|
|
|
615
713
|
)}
|
|
616
714
|
<span className="ftr-spacer" />
|
|
617
715
|
{syncResult && (
|
|
618
|
-
|
|
716
|
+
<>
|
|
717
|
+
<span className={syncResult.ok ? 'sync-result' : 'sync-error'}>{syncResult.msg}</span>
|
|
718
|
+
{!syncResult.ok && syncResult.failed?.length > 0 && (
|
|
719
|
+
<span className="sync-error">
|
|
720
|
+
{syncResult.failed
|
|
721
|
+
.slice(0, 2)
|
|
722
|
+
.map((item) => `${basenameFromPath(item.filePath)}: ${item.lastError || 'Unknown error'}`)
|
|
723
|
+
.join(' | ')}
|
|
724
|
+
</span>
|
|
725
|
+
)}
|
|
726
|
+
</>
|
|
619
727
|
)}
|
|
620
728
|
{!selectMode && pendingCount > 0 && (
|
|
621
729
|
<button className="ftr-select-toggle" onClick={() => setSelectMode(true)}>
|
|
@@ -19,7 +19,7 @@ const RENDER_MARGIN = 600; // px in screen space
|
|
|
19
19
|
// Cards beyond this are not in DOM at all
|
|
20
20
|
const PLACEHOLDER_MARGIN = 2000; // px in screen space
|
|
21
21
|
|
|
22
|
-
export function Viewport({ cards, sections, bounds, syncStatus, transientStatusMap, contentMap, selectedPath, selectMode, syncSelection, onCardClick, onCardExpand }) {
|
|
22
|
+
export function Viewport({ cards, sections, bounds, syncStatus, syncMetaByPath, transientStatusMap, contentMap, selectedPath, selectMode, syncSelection, selectablePaths, onCardClick, onCardExpand }) {
|
|
23
23
|
const vpRef = useRef(null);
|
|
24
24
|
const canvasRef = useRef(null);
|
|
25
25
|
const { viewState, ready, panMoved, focusCard, animateTo, handlers } =
|
|
@@ -179,15 +179,17 @@ export function Viewport({ cards, sections, bounds, syncStatus, transientStatusM
|
|
|
179
179
|
key={card.key}
|
|
180
180
|
card={card}
|
|
181
181
|
syncStatus={syncStatus?.[card.key]}
|
|
182
|
+
syncMeta={syncMetaByPath?.[card.key]}
|
|
182
183
|
transientStatus={transientStatusMap?.[card.key]}
|
|
183
184
|
content={contentMap?.get(card.key) ?? ''}
|
|
184
|
-
zoom={viewState.zoom}
|
|
185
|
-
selected={selectedPath === card.key}
|
|
186
|
-
dimmed={!!selectedPath && selectedPath !== card.key}
|
|
187
|
-
selectMode={selectMode}
|
|
188
|
-
checked={syncSelection?.has(card.key)}
|
|
189
|
-
|
|
190
|
-
|
|
185
|
+
zoom={viewState.zoom}
|
|
186
|
+
selected={selectedPath === card.key}
|
|
187
|
+
dimmed={!!selectedPath && selectedPath !== card.key}
|
|
188
|
+
selectMode={selectMode}
|
|
189
|
+
checked={syncSelection?.has(card.key)}
|
|
190
|
+
selectable={selectablePaths?.has(card.key)}
|
|
191
|
+
/>
|
|
192
|
+
))}
|
|
191
193
|
</>
|
|
192
194
|
)}
|
|
193
195
|
</div>
|
|
@@ -177,6 +177,11 @@
|
|
|
177
177
|
background: rgba(144, 144, 160, 0.06);
|
|
178
178
|
opacity: 0.5;
|
|
179
179
|
}
|
|
180
|
+
.stamp-local {
|
|
181
|
+
color: #8d7e67;
|
|
182
|
+
border-color: #8d7e67;
|
|
183
|
+
background: rgba(141, 126, 103, 0.08);
|
|
184
|
+
}
|
|
180
185
|
|
|
181
186
|
.stamp-transient {
|
|
182
187
|
opacity: 0.92;
|
|
@@ -205,6 +210,14 @@
|
|
|
205
210
|
border-color: #e58a8a;
|
|
206
211
|
background: rgba(229, 138, 138, 0.14);
|
|
207
212
|
}
|
|
213
|
+
|
|
214
|
+
.card-unselectable {
|
|
215
|
+
opacity: 0.88;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.card-checkbox-disabled {
|
|
219
|
+
opacity: 0.35;
|
|
220
|
+
}
|
|
208
221
|
|
|
209
222
|
/* Sealed: centered overlay stamp — red ink chop style */
|
|
210
223
|
.stamp-overlay {
|
|
@@ -5,6 +5,8 @@ const STATUS_PALETTE = {
|
|
|
5
5
|
sealed: { bg: '#fdf2f2', border: '#c9a0a0', text: '#8b4c4c', content: '#7a4040' },
|
|
6
6
|
new: { bg: '#fefcf6', border: '#d4b882', text: '#7a6230', content: '#6b5228' },
|
|
7
7
|
modified: { bg: '#fefcf6', border: '#d4b882', text: '#7a6230', content: '#6b5228' },
|
|
8
|
+
failed: { bg: '#fff5f5', border: '#d48b8b', text: '#9b4545', content: '#7c3d3d' },
|
|
9
|
+
local: { bg: '#f5f4f0', border: '#bbb1a2', text: '#756b5c', content: '#5f574d' },
|
|
8
10
|
synced: { bg: '#f3f3f5', border: '#c0c0c8', text: '#888890', content: '#6e6e78' },
|
|
9
11
|
none: { bg: '#eaeaed', border: '#b8b8c0', text: '#808088', content: '#606068' },
|
|
10
12
|
};
|
|
@@ -16,6 +18,8 @@ function getPalette(syncStatus, tier) {
|
|
|
16
18
|
if (syncStatus === 'sealed') return STATUS_PALETTE.sealed;
|
|
17
19
|
if (syncStatus === 'new') return STATUS_PALETTE.new;
|
|
18
20
|
if (syncStatus === 'modified') return STATUS_PALETTE.modified;
|
|
21
|
+
if (syncStatus === 'failed') return STATUS_PALETTE.failed;
|
|
22
|
+
if (syncStatus === 'local') return STATUS_PALETTE.local;
|
|
19
23
|
if (syncStatus === 'synced') return STATUS_PALETTE.synced;
|
|
20
24
|
return STATUS_PALETTE[TIER_DEFAULTS[tier] || 'none'];
|
|
21
25
|
}
|
|
@@ -49,6 +53,8 @@ const STAMP_CONFIG = {
|
|
|
49
53
|
sealed: { label: 'SENSITIVE', cls: 'stamp-sealed' },
|
|
50
54
|
new: { label: 'NEW', cls: 'stamp-new' },
|
|
51
55
|
modified: { label: 'MOD', cls: 'stamp-mod' },
|
|
56
|
+
failed: { label: 'FAILED', cls: 'stamp-failed' },
|
|
57
|
+
local: { label: 'LOCAL', cls: 'stamp-local' },
|
|
52
58
|
synced: { label: 'OK', cls: 'stamp-synced' },
|
|
53
59
|
};
|
|
54
60
|
|
|
@@ -77,6 +83,7 @@ function TransientStamp({ status }) {
|
|
|
77
83
|
export const Card = React.memo(function Card({
|
|
78
84
|
card,
|
|
79
85
|
syncStatus,
|
|
86
|
+
syncMeta,
|
|
80
87
|
transientStatus,
|
|
81
88
|
content,
|
|
82
89
|
zoom = 1,
|
|
@@ -84,6 +91,7 @@ export const Card = React.memo(function Card({
|
|
|
84
91
|
dimmed,
|
|
85
92
|
selectMode,
|
|
86
93
|
checked,
|
|
94
|
+
selectable,
|
|
87
95
|
}) {
|
|
88
96
|
const { file, x, y, w, h } = card;
|
|
89
97
|
const tier = file._tier || 3;
|
|
@@ -144,6 +152,7 @@ export const Card = React.memo(function Card({
|
|
|
144
152
|
effectiveStatus === 'sealed' ? 'card-sealed' : '',
|
|
145
153
|
selected ? 'card-selected' : '',
|
|
146
154
|
dimmed ? 'card-dimmed' : '',
|
|
155
|
+
selectMode && selectable === false ? 'card-unselectable' : '',
|
|
147
156
|
]
|
|
148
157
|
.filter(Boolean)
|
|
149
158
|
.join(' ');
|
|
@@ -163,7 +172,7 @@ export const Card = React.memo(function Card({
|
|
|
163
172
|
>
|
|
164
173
|
<div className="card-header">
|
|
165
174
|
{selectMode && (
|
|
166
|
-
<span className={`card-checkbox ${checked ? 'card-checkbox-on' : ''}`} data-checkbox="true">
|
|
175
|
+
<span className={`card-checkbox ${checked ? 'card-checkbox-on' : ''} ${selectable === false ? 'card-checkbox-disabled' : ''}`} data-checkbox="true">
|
|
167
176
|
{checked ? '[x]' : '[ ]'}
|
|
168
177
|
</span>
|
|
169
178
|
)}
|
|
@@ -445,6 +445,14 @@ body {
|
|
|
445
445
|
transition: width 0.18s ease;
|
|
446
446
|
}
|
|
447
447
|
|
|
448
|
+
.sync-progress-detail {
|
|
449
|
+
color: #f3b2b2;
|
|
450
|
+
font-size: 11px;
|
|
451
|
+
white-space: nowrap;
|
|
452
|
+
overflow: hidden;
|
|
453
|
+
text-overflow: ellipsis;
|
|
454
|
+
}
|
|
455
|
+
|
|
448
456
|
.empty-state {
|
|
449
457
|
flex: 1;
|
|
450
458
|
display: flex;
|
|
@@ -52,11 +52,12 @@ export async function saveSetupConfig(payload) {
|
|
|
52
52
|
return data;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
export async function triggerSync() {
|
|
56
|
-
const res = await fetch('/api/sync', { method: 'POST' });
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
55
|
+
export async function triggerSync() {
|
|
56
|
+
const res = await fetch('/api/sync', { method: 'POST' });
|
|
57
|
+
const data = await res.json().catch(() => ({}));
|
|
58
|
+
if (!res.ok) throw new Error(data?.details || data?.error || `Sync failed: HTTP ${res.status}`);
|
|
59
|
+
return data;
|
|
60
|
+
}
|
|
60
61
|
|
|
61
62
|
export async function fetchBackendSources() {
|
|
62
63
|
try {
|