Expand auto-sync run history cards
Make playlist pipeline run history cards clickable and keyboard-accessible, with expanded detail sections for summary stats, timeline, before/after snapshots, result payload fields, and future run logs. Refresh the card styling so the expanded state remains responsive inside the Auto-Sync modal.
This commit is contained in:
parent
60b346aa33
commit
e86e74d640
2 changed files with 303 additions and 29 deletions
|
|
@ -459,16 +459,9 @@ function autoSyncHistoryEntryHtml(entry, index = 0) {
|
|||
const entryId = `auto-sync-history-${entry.id}`;
|
||||
const playlistName = entry.playlist_name || after.name || before.name || `Playlist #${entry.playlist_id || 'unknown'}`;
|
||||
const summary = entry.summary || autoSyncHistoryFallbackSummary(before, after, status);
|
||||
const resultHtml = [
|
||||
autoSyncHistoryResultPill('Refreshed', result.playlists_refreshed),
|
||||
autoSyncHistoryResultPill('Synced', result.tracks_synced),
|
||||
autoSyncHistoryResultPill('Skipped', result.sync_skipped),
|
||||
autoSyncHistoryResultPill('Queued', result.wishlist_queued),
|
||||
result.error ? `<span class="error">${_esc(result.error)}</span>` : '',
|
||||
].filter(Boolean).join('');
|
||||
return `
|
||||
<article class="auto-sync-history-entry">
|
||||
<div class="auto-sync-history-row" onclick="autoSyncToggleHistoryEntry('${entryId}')">
|
||||
<article class="auto-sync-history-entry" id="${entryId}-card">
|
||||
<div class="auto-sync-history-row" role="button" tabindex="0" aria-expanded="false" aria-controls="${entryId}" onclick="autoSyncToggleHistoryEntry('${entryId}')" onkeydown="autoSyncHistoryEntryKeydown(event, '${entryId}')">
|
||||
<span class="auto-sync-card-status-dot ${autoSyncHistoryStatusClass(status)}"></span>
|
||||
<div class="auto-sync-history-main">
|
||||
<div class="auto-sync-history-title-row">
|
||||
|
|
@ -496,19 +489,11 @@ function autoSyncHistoryEntryHtml(entry, index = 0) {
|
|||
${started ? `<span>${_esc(started)}</span>` : ''}
|
||||
${duration ? `<span>${_esc(duration)}</span>` : ''}
|
||||
<span>${_esc(entry.trigger_source || 'pipeline')}</span>
|
||||
<button type="button" onclick="event.stopPropagation(); autoSyncToggleHistoryEntry('${entryId}')">Details</button>
|
||||
<span class="auto-sync-history-expand-label">Expand</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="${entryId}" class="auto-sync-history-detail">
|
||||
<div class="auto-sync-history-stats">
|
||||
${autoSyncHistoryStatHtml('Tracks', before.track_count, after.track_count, trackDelta)}
|
||||
${autoSyncHistoryStatHtml('Discovered', before.discovered_count, after.discovered_count, discoveredDelta)}
|
||||
${autoSyncHistoryStatHtml('Wishlisted', before.wishlisted_count, after.wishlisted_count, wishlistDelta)}
|
||||
${autoSyncHistoryStatHtml('In library', before.in_library_count, after.in_library_count, libraryDelta)}
|
||||
</div>
|
||||
<div class="auto-sync-history-result">
|
||||
${resultHtml || '<span class="muted">No detailed result payload recorded for this run.</span>'}
|
||||
</div>
|
||||
${autoSyncHistoryDetailHtml(entry, before, after, result, { trackDelta, discoveredDelta, wishlistDelta, libraryDelta })}
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
|
|
@ -556,7 +541,18 @@ function autoSyncHistoryFallbackSummary(before, after, status) {
|
|||
|
||||
function autoSyncToggleHistoryEntry(entryId) {
|
||||
const el = document.getElementById(entryId);
|
||||
if (el) el.classList.toggle('expanded');
|
||||
const card = document.getElementById(`${entryId}-card`);
|
||||
const row = card?.querySelector('.auto-sync-history-row');
|
||||
if (!el) return;
|
||||
const expanded = el.classList.toggle('expanded');
|
||||
if (card) card.classList.toggle('expanded', expanded);
|
||||
if (row) row.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function autoSyncHistoryEntryKeydown(event, entryId) {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
autoSyncToggleHistoryEntry(entryId);
|
||||
}
|
||||
|
||||
function autoSyncHistoryStatusLabel(status) {
|
||||
|
|
@ -610,6 +606,141 @@ function autoSyncHistoryResultPill(label, value) {
|
|||
return `<span>${_esc(label)}: ${_esc(String(value))}</span>`;
|
||||
}
|
||||
|
||||
function autoSyncHistoryDetailHtml(entry, before, after, result, deltas) {
|
||||
const resultPills = [
|
||||
autoSyncHistoryResultPill('Refreshed', result.playlists_refreshed),
|
||||
autoSyncHistoryResultPill('Discovered', result.tracks_discovered),
|
||||
autoSyncHistoryResultPill('Synced', result.tracks_synced),
|
||||
autoSyncHistoryResultPill('Skipped', result.sync_skipped),
|
||||
autoSyncHistoryResultPill('Wishlisted', result.wishlist_queued),
|
||||
autoSyncHistoryResultPill('Duration', result.duration_seconds ? autoSyncDurationLabel(result.duration_seconds) : ''),
|
||||
result.error ? `<span class="error">${_esc(result.error)}</span>` : '',
|
||||
].filter(Boolean).join('');
|
||||
const timeline = [
|
||||
['Started', autoSyncFormatDateTime(entry.started_at)],
|
||||
['Finished', autoSyncFormatDateTime(entry.finished_at)],
|
||||
['Duration', entry.duration_seconds ? autoSyncDurationLabel(entry.duration_seconds) : 'Not recorded'],
|
||||
['Trigger', entry.trigger_source || 'pipeline'],
|
||||
['Source', entry.source || after.source || before.source || 'Unknown'],
|
||||
['Playlist ID', entry.playlist_id || after.playlist_id || before.playlist_id || 'Unknown'],
|
||||
];
|
||||
return `
|
||||
<div class="auto-sync-history-detail-grid">
|
||||
<section class="auto-sync-history-section">
|
||||
<div class="auto-sync-history-section-title">Run Summary</div>
|
||||
<div class="auto-sync-history-stats">
|
||||
${autoSyncHistoryStatHtml('Tracks', before.track_count, after.track_count, deltas.trackDelta)}
|
||||
${autoSyncHistoryStatHtml('Discovered', before.discovered_count, after.discovered_count, deltas.discoveredDelta)}
|
||||
${autoSyncHistoryStatHtml('Wishlisted', before.wishlisted_count, after.wishlisted_count, deltas.wishlistDelta)}
|
||||
${autoSyncHistoryStatHtml('In library', before.in_library_count, after.in_library_count, deltas.libraryDelta)}
|
||||
</div>
|
||||
<div class="auto-sync-history-result">
|
||||
${resultPills || '<span class="muted">No detailed result payload recorded for this run.</span>'}
|
||||
</div>
|
||||
</section>
|
||||
<section class="auto-sync-history-section">
|
||||
<div class="auto-sync-history-section-title">Timeline</div>
|
||||
<div class="auto-sync-history-facts">
|
||||
${timeline.map(([label, value]) => autoSyncHistoryFactHtml(label, value)).join('')}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="auto-sync-history-snapshots">
|
||||
${autoSyncHistorySnapshotHtml('Before refresh', before)}
|
||||
${autoSyncHistorySnapshotHtml('After pipeline', after)}
|
||||
</div>
|
||||
${autoSyncHistoryObjectHtml('Result payload', result, { skipPrivate: true })}
|
||||
${autoSyncHistoryLogsHtml(entry.log_lines)}
|
||||
`;
|
||||
}
|
||||
|
||||
function autoSyncHistoryFactHtml(label, value) {
|
||||
return `
|
||||
<div>
|
||||
<span>${_esc(label)}</span>
|
||||
<strong>${_esc(autoSyncValueLabel(value))}</strong>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function autoSyncHistorySnapshotHtml(title, snapshot) {
|
||||
const fields = [
|
||||
['Name', snapshot.name],
|
||||
['Source', snapshot.source],
|
||||
['Tracks', snapshot.track_count],
|
||||
['Discovered', snapshot.discovered_count],
|
||||
['Wishlisted', snapshot.wishlisted_count],
|
||||
['In library', snapshot.in_library_count],
|
||||
];
|
||||
return `
|
||||
<section class="auto-sync-history-section auto-sync-history-snapshot">
|
||||
<div class="auto-sync-history-section-title">${_esc(title)}</div>
|
||||
<div class="auto-sync-history-facts compact">
|
||||
${fields.map(([label, value]) => autoSyncHistoryFactHtml(label, value)).join('')}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function autoSyncHistoryObjectHtml(title, obj, options = {}) {
|
||||
if (!obj || typeof obj !== 'object') return '';
|
||||
const entries = Object.entries(obj)
|
||||
.filter(([key, value]) => !(options.skipPrivate && key.startsWith('_')) && value !== undefined && value !== null && value !== '')
|
||||
.slice(0, 24);
|
||||
if (!entries.length) return '';
|
||||
return `
|
||||
<section class="auto-sync-history-section">
|
||||
<div class="auto-sync-history-section-title">${_esc(title)}</div>
|
||||
<div class="auto-sync-history-payload">
|
||||
${entries.map(([key, value]) => `
|
||||
<div>
|
||||
<span>${_esc(autoSyncHumanizeKey(key))}</span>
|
||||
<strong>${_esc(autoSyncValueLabel(value))}</strong>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function autoSyncHistoryLogsHtml(logLines) {
|
||||
if (!Array.isArray(logLines) || !logLines.length) return '';
|
||||
return `
|
||||
<section class="auto-sync-history-section">
|
||||
<div class="auto-sync-history-section-title">Run Log</div>
|
||||
<div class="auto-sync-history-logs">
|
||||
${logLines.slice(-12).map(line => {
|
||||
const text = typeof line === 'string' ? line : (line.message || line.log_line || JSON.stringify(line));
|
||||
const type = typeof line === 'object' ? (line.type || line.log_type || 'info') : 'info';
|
||||
return `<div class="${_escAttr(type)}">${_esc(text)}</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function autoSyncFormatDateTime(value) {
|
||||
if (!value) return '';
|
||||
const ts = _autoParseUTC(value);
|
||||
if (!Number.isFinite(ts)) return value;
|
||||
return new Date(ts).toLocaleString();
|
||||
}
|
||||
|
||||
function autoSyncHumanizeKey(key) {
|
||||
return String(key || '')
|
||||
.replace(/^_+/, '')
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, ch => ch.toUpperCase());
|
||||
}
|
||||
|
||||
function autoSyncValueLabel(value) {
|
||||
if (value === undefined || value === null || value === '') return 'Not recorded';
|
||||
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
||||
if (Array.isArray(value)) return value.length ? value.map(autoSyncValueLabel).join(', ') : 'None';
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function autoSyncAutomationCardHtml(auto, playlists) {
|
||||
const cfg = auto.action_config || {};
|
||||
const playlistId = autoSyncPlaylistIdFromAutomation(auto);
|
||||
|
|
|
|||
|
|
@ -12088,6 +12088,11 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
box-shadow: none;
|
||||
}
|
||||
|
||||
.auto-sync-history-entry.expanded {
|
||||
border-color: rgba(var(--accent-rgb), 0.26);
|
||||
background: rgba(26, 26, 26, 0.98);
|
||||
}
|
||||
|
||||
.auto-sync-history-entry:empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -12116,6 +12121,11 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auto-sync-history-row:focus-visible {
|
||||
outline: 2px solid rgba(var(--accent-rgb), 0.45);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
.auto-sync-history-status {
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
|
|
@ -12196,11 +12206,11 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
max-width: 190px;
|
||||
}
|
||||
|
||||
.auto-sync-history-meta span,
|
||||
.auto-sync-history-result span,
|
||||
.auto-sync-history-meta button {
|
||||
.auto-sync-history-result span {
|
||||
padding: 4px 7px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
|
|
@ -12209,32 +12219,74 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
font-weight: 700;
|
||||
}
|
||||
|
||||
.auto-sync-history-meta button {
|
||||
.auto-sync-history-meta .auto-sync-history-expand-label {
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.18);
|
||||
color: rgb(var(--accent-light-rgb));
|
||||
cursor: pointer;
|
||||
padding-right: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.auto-sync-history-meta button:hover {
|
||||
.auto-sync-history-meta .auto-sync-history-expand-label::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-right: 1.5px solid currentColor;
|
||||
border-bottom: 1.5px solid currentColor;
|
||||
transform: translateY(-65%) rotate(45deg);
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
.auto-sync-history-entry.expanded .auto-sync-history-expand-label::after {
|
||||
transform: translateY(-30%) rotate(225deg);
|
||||
}
|
||||
|
||||
.auto-sync-history-entry:hover .auto-sync-history-expand-label {
|
||||
background: rgba(var(--accent-rgb), 0.14);
|
||||
border-color: rgba(var(--accent-rgb), 0.35);
|
||||
}
|
||||
|
||||
.auto-sync-history-detail {
|
||||
display: none;
|
||||
padding: 0 16px 16px;
|
||||
padding: 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
.auto-sync-history-detail.expanded {
|
||||
display: block;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.auto-sync-history-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.45fr) minmax(220px, 0.8fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.auto-sync-history-section {
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.075);
|
||||
border-radius: 9px;
|
||||
background: rgba(255, 255, 255, 0.028);
|
||||
}
|
||||
|
||||
.auto-sync-history-section-title {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 10px;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.auto-sync-history-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
padding-top: 14px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.auto-sync-history-stats div {
|
||||
|
|
@ -12265,7 +12317,7 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
margin-top: 9px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.auto-sync-history-result span {
|
||||
|
|
@ -12286,6 +12338,81 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.auto-sync-history-snapshots {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.auto-sync-history-facts,
|
||||
.auto-sync-history-payload {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.auto-sync-history-facts.compact {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.auto-sync-history-facts div,
|
||||
.auto-sync-history-payload div {
|
||||
min-width: 0;
|
||||
padding: 9px;
|
||||
border-radius: 7px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.auto-sync-history-facts span,
|
||||
.auto-sync-history-payload span,
|
||||
.auto-sync-history-facts strong,
|
||||
.auto-sync-history-payload strong {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.auto-sync-history-facts span,
|
||||
.auto-sync-history-payload span {
|
||||
color: rgba(255, 255, 255, 0.36);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.auto-sync-history-facts strong,
|
||||
.auto-sync-history-payload strong {
|
||||
margin-top: 4px;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.auto-sync-history-logs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 190px;
|
||||
overflow: auto;
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.auto-sync-history-logs div {
|
||||
color: rgba(255, 255, 255, 0.54);
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, 'Liberation Mono', monospace;
|
||||
font-size: 10px;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.auto-sync-history-logs .success { color: #4ade80; }
|
||||
.auto-sync-history-logs .error { color: #ef4444; }
|
||||
.auto-sync-history-logs .warning { color: #facc15; }
|
||||
|
||||
.auto-sync-history-empty {
|
||||
margin: 24px;
|
||||
padding: 44px;
|
||||
|
|
@ -12358,6 +12485,15 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
grid-template-columns: repeat(10, minmax(230px, 250px));
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.auto-sync-history-detail-grid,
|
||||
.auto-sync-history-snapshots {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.auto-sync-history-facts.compact {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 760px) {
|
||||
|
|
@ -12428,12 +12564,19 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
|
||||
.auto-sync-history-meta {
|
||||
justify-content: flex-start;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.auto-sync-history-stats {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.auto-sync-history-facts,
|
||||
.auto-sync-history-facts.compact,
|
||||
.auto-sync-history-payload {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.auto-sync-scheduled-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue