feat(sidecars): restyle migration result as a summary modal

Follow the app's existing operation-summary convention (Metadata Fetch
Summary / Batch Download Summary): a self-managed modal appended to
document.body with a 3-state summary header, stat cards (moved /
models / skipped / conflicts / errors), and a failure table listing
per-model errors that were previously swallowed into a single count.

The storage location line and Open Folder action move into the modal
actions; the page reload still happens only when the modal is
dismissed. ESC is captured so it never reaches the settings modal
underneath. The obsolete migrateSuccess toast key is dropped — the
modal is the success feedback now.
This commit is contained in:
Will Miao
2026-09-27 10:05:18 +08:00
parent 485679223b
commit e7c1c07db0
7 changed files with 293 additions and 164 deletions
@@ -0,0 +1,15 @@
/* Sidecar Migration Summary Modal — component styles only.
Stat cards, summary header and failure table styles are shared with the
metadata refresh result modal (metadata-refresh-result.css) and the batch
download summary modal (download-batch-summary.css); not redefined here. */
.sidecar-migration-summary-modal {
max-width: 700px;
}
.sidecar-migration-location {
margin: 0 0 var(--space-3) 0;
font-size: var(--text-sm);
color: var(--text-secondary);
word-break: break-all;
}
+1
View File
@@ -43,6 +43,7 @@
@import 'components/media-viewer.css';
@import 'components/metadata-refresh-result.css';
@import 'components/download-batch-summary.css';
@import 'components/sidecar-migration-summary.css';
.initialization-notice {
display: flex;
@@ -0,0 +1,193 @@
import { translate } from '../utils/i18nHelpers.js';
/**
* Escape HTML entities in a string to prevent injection when interpolating into innerHTML.
* @param {string} str - The string to escape
* @returns {string} - The escaped string
*/
function _escapeHtml(str) {
if (str === null || str === undefined) return '';
const div = document.createElement('div');
div.textContent = String(str);
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
}
/**
* Show the sidecar migration summary modal after a migration completes.
* Mirrors the Metadata Fetch Summary / Batch Download Summary lifecycle: the
* modal element is appended directly to document.body and removed on close;
* it is not registered with ModalManager.
* @param {Object} options - Summary options
* @param {Object} options.result - Migration result payload from /api/lm/sidecars/migrate
* @param {Function} options.onClose - Callback invoked after the modal is dismissed
* @param {Function} [options.onOpenLocation] - Callback for the "Open Folder" action; the modal stays open
*/
export function showSidecarMigrationSummary({ result, onClose, onOpenLocation }) {
const errorCount = result.error_count || 0;
const errors = Array.isArray(result.errors) ? result.errors : [];
const moved = result.moved || 0;
const modelsMoved = result.models_moved || 0;
const modelsTotal = result.models_total || 0;
const skipped = result.skipped || 0;
const conflicts = result.conflicts || 0;
const showLocation = result.direction !== 'to_alongside' && !!result.sidecar_root;
// 3-state summary header semantics (mirrors DownloadBatchSummaryModal)
let headerState;
let headerIcon;
let headerText;
if (errorCount > 0) {
headerState = 'warning';
headerIcon = 'fa-exclamation-circle';
headerText = translate('modals.sidecarMigrationResult.completedWithErrors', { count: errorCount }, 'Completed with ' + errorCount + ' error(s)');
} else {
headerState = 'success';
headerIcon = 'fa-check-circle';
headerText = translate('modals.sidecarMigrationResult.completedSuccessfully', {}, 'Migration completed successfully');
}
const failureRows = errors.map((entry, i) => {
const name = entry?.model || 'Unknown';
const error = entry?.error ? String(entry.error) : 'Unknown error';
return `<tr>
<td class="failure-index">${i + 1}</td>
<td class="failure-name" title="${_escapeHtml(name)}">${_escapeHtml(name)}</td>
<td class="failure-error" title="${_escapeHtml(error)}">${_escapeHtml(error)}</td>
</tr>`;
}).join('');
const modalHtml = `
<div id="sidecarMigrationSummaryModal" class="modal" style="display: block;">
<div class="modal-content sidecar-migration-summary-modal">
<button class="close" data-action="close-modal">&times;</button>
<h2>${translate('modals.sidecarMigrationResult.title', {}, 'Sidecar Migration Summary')}</h2>
<div class="summary-header ${headerState}">
<i class="fas ${headerIcon}"></i>
<span class="summary-title">${headerText}</span>
<span class="summary-hint">${modelsMoved}/${modelsTotal}</span>
</div>
<div class="refresh-summary-stats">
<div class="stat-card stat-card-success">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.sidecarMigrationResult.statMoved', {}, 'Moved Files')}</span>
<span class="stat-card-value">${moved}</span>
</div>
</div>
<div class="stat-card stat-card-total">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.sidecarMigrationResult.statModels', {}, 'Models')}</span>
<span class="stat-card-value">${modelsMoved}</span>
</div>
</div>
<div class="stat-card stat-card-skipped">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.sidecarMigrationResult.statSkipped', {}, 'Skipped')}</span>
<span class="stat-card-value">${skipped}</span>
</div>
</div>
<div class="stat-card stat-card-time">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.sidecarMigrationResult.statConflicts', {}, 'Conflicts Resolved')}</span>
<span class="stat-card-value">${conflicts}</span>
</div>
</div>
${errorCount > 0 ? `
<div class="stat-card stat-card-failure">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.sidecarMigrationResult.statErrors', {}, 'Errors')}</span>
<span class="stat-card-value">${errorCount}</span>
</div>
</div>
` : ''}
</div>
${errorCount > 0 ? `
<div class="refresh-failures-section">
<h4><i class="fas fa-exclamation-triangle"></i> ${translate('modals.sidecarMigrationResult.failedItems', { count: errorCount }, 'Failed Items (' + errorCount + ')')}</h4>
<div class="failure-table-wrapper">
<table class="failure-table">
<thead>
<tr>
<th>#</th>
<th>${translate('modals.sidecarMigrationResult.columnModel', {}, 'Model')}</th>
<th>${translate('modals.sidecarMigrationResult.columnError', {}, 'Error')}</th>
</tr>
</thead>
<tbody>${failureRows}</tbody>
</table>
</div>
</div>
` : `
<div class="refresh-success-message">
<i class="fas fa-check-circle"></i> ${translate('modals.sidecarMigrationResult.successMessage', { moved: moved, models: modelsMoved }, 'Moved ' + moved + ' files for ' + modelsMoved + ' models')}
</div>
`}
${showLocation ? `
<p class="sidecar-migration-location">
${translate('modals.sidecarMigrationResult.location', { path: result.sidecar_root }, 'Storage location: ' + result.sidecar_root)}
</p>
` : ''}
<div class="modal-actions">
${showLocation ? `
<button class="secondary-btn" data-action="open-sidecar-location"><i class="fas fa-folder-open"></i> ${translate('settings.sidecarStorage.openFolderButton', {}, 'Open Folder')}</button>
` : ''}
<button class="cancel-btn" data-action="close-modal">${translate('common.actions.close', {}, 'Close')}</button>
</div>
</div>
</div>
`;
const existing = document.getElementById('sidecarMigrationSummaryModal');
if (existing) existing.remove();
const container = document.createElement('div');
container.innerHTML = modalHtml;
const modal = container.firstElementChild;
document.body.appendChild(modal);
const close = () => {
document.removeEventListener('keydown', handleEscape, true);
modal.remove();
if (typeof onClose === 'function') {
onClose();
}
};
// Capture phase + stopPropagation so ESC never reaches the settings
// modal's own ESC handler underneath.
const handleEscape = (event) => {
if (event.key === 'Escape') {
event.stopPropagation();
event.preventDefault();
close();
}
};
document.addEventListener('keydown', handleEscape, true);
modal.addEventListener('click', (e) => {
const actionEl = e.target.closest('[data-action]');
const action = actionEl?.dataset.action;
if (!action) return;
e.preventDefault();
switch (action) {
case 'close-modal':
close();
break;
case 'open-sidecar-location':
// Keep the modal open; only trigger the file-manager action.
if (typeof onOpenLocation === 'function') {
onOpenLocation();
}
break;
}
});
return modal;
}
+8 -96
View File
@@ -18,6 +18,7 @@ import { configureModelCardVideo } from '../components/shared/ModelCard.js';
import { validatePriorityTagString, getPriorityTagSuggestionsMap, invalidatePriorityTagSuggestionsCache } from '../utils/priorityTagHelpers.js';
import { bannerService } from './BannerService.js';
import { directoryPickerModal } from '../components/DirectoryPickerModal.js';
import { showSidecarMigrationSummary } from '../components/SidecarMigrationSummaryModal.js';
const VALID_MATURE_BLUR_LEVELS = new Set(['PG13', 'R', 'X', 'XXX']);
@@ -3682,103 +3683,14 @@ export class SettingsManager {
// Post-migration summary: counters + storage location, with an "open
// folder" shortcut. Closing reloads so cards pick up the new paths.
showSidecarMigrationResult(result) {
const modalElement = document.getElementById('sidecarMigrationResultModal');
if (!modalElement) {
showToast('settings.sidecarStorage.migrateSuccess', {}, 'success');
resetAndReload(true);
return;
}
const errorCount = result.error_count || 0;
const titleElement = modalElement.querySelector('[data-role="title"]');
if (titleElement) {
titleElement.textContent = errorCount
? translate('modals.sidecarMigrationResult.titleWithErrors', { count: errorCount }, `Sidecar migration completed with ${errorCount} error(s)`)
: translate('modals.sidecarMigrationResult.title', {}, 'Sidecar migration completed');
}
const messageElement = modalElement.querySelector('[data-role="message"]');
if (messageElement) {
messageElement.textContent = translate(
'modals.sidecarMigrationResult.summary',
{
moved: result.moved || 0,
models: result.models_moved || 0,
skipped: result.skipped || 0,
conflicts: result.conflicts || 0,
},
`Moved ${result.moved || 0} files for ${result.models_moved || 0} models. Skipped: ${result.skipped || 0}, conflicts resolved: ${result.conflicts || 0}.`
);
}
const showLocation = result.direction !== 'to_alongside' && !!result.sidecar_root;
const destinationElement = modalElement.querySelector('[data-role="destination"]');
if (destinationElement) {
if (showLocation) {
destinationElement.textContent = translate(
'modals.sidecarMigrationResult.location',
{ path: result.sidecar_root },
`Storage location: ${result.sidecar_root}`
);
destinationElement.style.display = 'block';
} else {
destinationElement.style.display = 'none';
}
}
const openButton = modalElement.querySelector('[data-action="open-sidecar-location"]');
const closeButton = modalElement.querySelector('[data-action="close-sidecar-result"]');
if (!closeButton) {
resetAndReload(true);
return;
}
if (openButton) {
openButton.style.display = showLocation ? '' : 'none';
}
const cleanup = () => {
closeButton.removeEventListener('click', handleClose);
if (openButton) {
openButton.removeEventListener('click', handleOpen);
}
document.removeEventListener('keydown', handleEscape, true);
};
const handleClose = (event) => {
event.preventDefault();
cleanup();
modalElement.classList.remove('show');
showSidecarMigrationSummary({
result,
// Reload so cards pick up metadata/preview paths from the new location
resetAndReload(true);
};
// Opening the folder keeps the result modal open; the reload happens
// when the user closes it.
const handleOpen = (event) => {
event.preventDefault();
this.openSidecarStorageLocation();
};
// Capture phase + stopPropagation so ESC never reaches the settings
// modal's own ESC handler underneath.
const handleEscape = (event) => {
if (event.key === 'Escape') {
event.stopPropagation();
handleClose(event);
}
};
closeButton.addEventListener('click', handleClose);
if (openButton) {
openButton.addEventListener('click', handleOpen);
}
document.addEventListener('keydown', handleEscape, true);
modalElement.classList.add('show');
closeButton.focus();
onClose: () => resetAndReload(true),
// Opening the folder keeps the result modal open; the reload
// happens when the user closes it.
onOpenLocation: () => this.openSidecarStorageLocation(),
});
}
async loadMetadataArchiveSettings() {