diff --git a/docs/design/batch-duplicate-review.md b/docs/design/batch-duplicate-review.md index 654ae3a..5d2f20d 100644 --- a/docs/design/batch-duplicate-review.md +++ b/docs/design/batch-duplicate-review.md @@ -18,6 +18,10 @@ backup containing every affected audio file plus the Serato metadata snapshot, then installs all compatibility shortcuts as one rollback unit. Any validation or filesystem failure prevents or rolls back the batch. +The dry-run opens in a closable modal that names every selected keeper and every +path that will become a compatibility shortcut. Closing the modal preserves the +queue position and all decisions so the DJ can continue reviewing before apply. + ## Edge cases - The same group cannot be submitted twice. diff --git a/serato_doctor/web.py b/serato_doctor/web.py index 7b26505..e71d7e9 100644 --- a/serato_doctor/web.py +++ b/serato_doctor/web.py @@ -360,6 +360,13 @@ def duplicate_repair_batch( result = { "choice_count": len(plans), "replaced": replaced, + "decisions": [ + { + "keeper": str(plan.keeper), + "replaced": [str(path) for path in plan.replaced], + } + for plan in plans + ], "metadata_backups": len(plans[0].metadata_files), "strategy": "shortcut", "database_v2_modified": False, diff --git a/serato_doctor/webui/app.js b/serato_doctor/webui/app.js index 0126106..06f1c85 100644 --- a/serato_doctor/webui/app.js +++ b/serato_doctor/webui/app.js @@ -22,6 +22,13 @@ const audioPreview = document.querySelector('#audio-preview'); const audioPreviewName = document.querySelector('#audio-preview-name'); const audioPlayer = document.querySelector('#audio-player'); const closeAudioPreview = document.querySelector('#close-audio-preview'); +const batchPreviewModal = document.querySelector('#batch-preview-modal'); +const batchPreviewSummary = document.querySelector('#batch-preview-summary'); +const batchPreviewList = document.querySelector('#batch-preview-list'); +const batchPreviewSafety = document.querySelector('#batch-preview-safety'); +const closeBatchPreview = document.querySelector('#close-batch-preview'); +const continueReviewing = document.querySelector('#continue-reviewing'); +const acceptPreview = document.querySelector('#accept-preview'); let latestAnalysis = null; let selectedDuplicateGroup = null; let previewedRepair = null; @@ -191,12 +198,23 @@ previewRepairButton.addEventListener('click', async () => { repairMessage.textContent = 'Checking the plan…'; applyRepairButton.disabled = true; try { previewedRepair = await requestRepair('/api/duplicates/batch/preview'); - repairPreview.innerHTML = `One safe plan for ${previewedRepair.choice_count} approved group(s)

${previewedRepair.replaced.length} duplicate file(s) will be backed up, then replaced with shortcuts to their selected keepers. ${previewedRepair.metadata_backups} Serato metadata file(s) will also be copied into the rollback snapshot. Database V2 will not be changed.

`; + batchPreviewSummary.textContent = `${previewedRepair.choice_count} keeper decision(s) · ${previewedRepair.replaced.length} duplicate file(s) consolidated`; + batchPreviewList.innerHTML = previewedRepair.decisions.map((decision, index) => ` +
Decision ${index + 1}
Keep${escapeHtml(decision.keeper.split('/').pop())}${escapeHtml(decision.keeper)}
Replace with a shortcut${decision.replaced.map((path) => `${escapeHtml(path)}`).join('')}
+ `).join(''); + batchPreviewSafety.textContent = `${previewedRepair.metadata_backups} Serato metadata file(s) and every replaced audio file will be backed up. Database V2 will not be modified.`; + repairPreview.innerHTML = `Preview approved

${previewedRepair.choice_count} keeper decision(s) are ready for one backed-up apply.

`; repairPreview.hidden = false; applyRepairButton.disabled = false; repairMessage.textContent = 'Preview complete. Nothing has changed yet.'; + batchPreviewModal.showModal(); } catch (error) { repairMessage.textContent = error.message; } }); +function dismissBatchPreview() { batchPreviewModal.close(); } +closeBatchPreview.addEventListener('click', dismissBatchPreview); +continueReviewing.addEventListener('click', dismissBatchPreview); +acceptPreview.addEventListener('click', dismissBatchPreview); + applyRepairButton.addEventListener('click', async () => { if (!previewedRepair) return; applyRepairButton.disabled = true; repairMessage.textContent = 'Creating the backup before making changes…'; diff --git a/serato_doctor/webui/index.html b/serato_doctor/webui/index.html index bc55502..aeb6058 100644 --- a/serato_doctor/webui/index.html +++ b/serato_doctor/webui/index.html @@ -121,8 +121,15 @@ + + +

+
+ + +
- + diff --git a/serato_doctor/webui/layout-fixes.css b/serato_doctor/webui/layout-fixes.css index bf4780a..6a33659 100644 --- a/serato_doctor/webui/layout-fixes.css +++ b/serato_doctor/webui/layout-fixes.css @@ -139,6 +139,164 @@ } } +.batch-preview-modal { + width: min(820px, calc(100vw - 32px)); + max-height: min(82vh, 760px); + padding: 24px; + overflow: auto; + border: 1px solid rgba(155, 135, 245, .3); + border-radius: 20px; + background: #161923; + color: var(--text); + box-shadow: 0 30px 100px rgba(0, 0, 0, .65); +} + +.batch-preview-modal::backdrop { + background: rgba(4, 5, 8, .78); + backdrop-filter: blur(7px); +} + +.modal-heading, +.modal-actions, +.modal-safety { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; +} + +.modal-heading h2 { + margin: 0; + font-size: 23px; + letter-spacing: -.03em; +} + +.modal-heading > button { + border: 0; + background: transparent; + color: var(--muted); + font-size: 28px; + cursor: pointer; +} + +#batch-preview-summary { + color: var(--muted); + font-size: 12px; +} + +.batch-preview-list { + display: grid; + gap: 10px; + max-height: 390px; + margin: 18px 0; + padding-right: 4px; + overflow: auto; +} + +.preview-decision { + display: grid; + grid-template-columns: 80px 1fr 1fr; + gap: 13px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 13px; + background: rgba(255, 255, 255, .025); +} + +.preview-decision > span, +.preview-decision b { + color: var(--muted); + font-size: 9px; + text-transform: uppercase; + letter-spacing: .08em; +} + +.winner-path, +.replaced-paths { + min-width: 0; +} + +.winner-path strong, +.winner-path small, +.replaced-paths small { + display: block; + margin-top: 5px; + overflow-wrap: anywhere; +} + +.winner-path strong { + color: #8ce4b8; + font-size: 11px; +} + +.winner-path small, +.replaced-paths small { + color: var(--muted); + font-size: 9px; + line-height: 1.45; +} + +.modal-safety { + justify-content: flex-start; + padding: 13px; + border: 1px solid rgba(102, 217, 232, .2); + border-radius: 12px; + background: rgba(102, 217, 232, .05); +} + +.modal-safety > span { + color: var(--cyan); +} + +.modal-safety p, +.modal-safety strong, +.modal-safety small { + display: block; + margin: 0; +} + +.modal-safety strong { + font-size: 11px; +} + +.modal-safety small { + margin-top: 4px; + color: var(--muted); + font-size: 9px; + line-height: 1.45; +} + +.modal-actions { + justify-content: flex-end; + margin-top: 16px; +} + +.modal-actions button { + border: 1px solid var(--line); + border-radius: 10px; + padding: 10px 13px; + background: rgba(255, 255, 255, .04); + color: var(--text); + font: 700 11px/1 inherit; + cursor: pointer; +} + +#accept-preview { + border-color: transparent; + background: #6f5bd0; +} + +@media (max-width: 620px) { + .preview-decision { + grid-template-columns: 1fr; + } + + .modal-actions { + align-items: stretch; + flex-direction: column-reverse; + } +} + .file-compare-row { display: grid; gap: 8px; diff --git a/tests/test_web.py b/tests/test_web.py index 87ca5a2..c7e3923 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -60,6 +60,8 @@ def test_web_static_assets_are_declared_and_packaged(): assert "Old crate references" in html assert "Choose a diagnostic" in html assert "Backup recovery" in html + assert "Here’s exactly what will happen" in html + assert 'id="batch-preview-modal"' in html assert 'data-detail="database_missing_tracks"' in html assert 'data-detail="old_crate_references"' in html assert html.count('class="info-button"') >= 10 @@ -139,4 +141,7 @@ def test_batch_preview_combines_approved_groups_without_changes(tmp_path): assert result["applied"] is False assert result["choice_count"] == 2 assert len(result["replaced"]) == 2 + assert len(result["decisions"]) == 2 + assert result["decisions"][0]["keeper"].endswith("First.mp3") + assert len(result["decisions"][0]["replaced"]) == 1 assert all(not Path(choice["group_files"][1]).is_symlink() for choice in choices)