From 7a8b66fd2ee9e2f5e767d85f6996570f82dd3e9d Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Thu, 25 Jun 2026 13:06:07 -0700 Subject: [PATCH] Auto-Sync Manager: redesign hourly + weekly boards as horizontal lanes Replace the side-scrolling column board with vertically-stacked interval lanes (hourly) and day lanes Mon-Sun (weekly). Empty intervals/days collapse to thin dashed strips, busy ones grow; scheduled playlists flow as cards within a lane. Kills the horizontal scroll + the wasted whitespace of the old kanban columns, and the two boards now share one cohesive design. Polish: accent gradient wash + gradient interval numerals + count badge on filled lanes, drag-over glow/lift, card pop-in animation, hover states. Also preserves the board's scroll position across the full re-render so dropping/removing a playlist no longer snaps it back to the top. Same drag-and-drop handlers + scheduled-card content reused; old column CSS is now unused (harmless). --- webui/static/auto-sync.js | 61 ++++++++++---- webui/static/style.css | 165 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+), 15 deletions(-) diff --git a/webui/static/auto-sync.js b/webui/static/auto-sync.js index 8a91d3ad..74f08428 100644 --- a/webui/static/auto-sync.js +++ b/webui/static/auto-sync.js @@ -73,6 +73,17 @@ function autoSyncIntervalLabel(hours) { return `Every ${hours} hour${hours === 1 ? '' : 's'}`; } +// Short cadence word for the lane badge (under the interval number). +function autoSyncLaneCadence(hours) { + if (hours === 1) return 'Hourly'; + if (hours === 12) return 'Twice a day'; + if (hours === 24) return 'Daily'; + if (hours === 168) return 'Weekly'; + if (hours < 24) return `Every ${hours}h`; + const days = hours / 24; + return `Every ${days} days`; +} + // Browser-detected default tz for new schedules. Used when the user // creates a weekly schedule and hasn't picked an explicit tz — falls // back to UTC on browsers where Intl is unavailable (very old ones). @@ -354,6 +365,12 @@ function renderAutoSyncScheduleModal() { const overlay = document.getElementById('auto-sync-schedule-modal'); if (!overlay) return; + // Preserve the visible lane board's scroll across the full re-render so dropping / + // removing a playlist doesn't snap it back to the top (the user used to have to scroll + // back). Targets the ACTIVE tab so it works for both the hourly + weekly boards. + const _prevLanes = overlay.querySelector('.auto-sync-tab-panel.active .auto-sync-lanes'); + const _prevScroll = _prevLanes ? _prevLanes.scrollTop : null; + const { playlists, playlistSchedules, weeklySchedules, automationPipelines, runHistory, runHistoryTotal } = _autoSyncScheduleState; const scheduledCount = Object.keys(playlistSchedules).length + Object.keys(weeklySchedules || {}).length; const enabledCount = Object.values(playlistSchedules).filter(s => s.enabled).length @@ -408,6 +425,11 @@ function renderAutoSyncScheduleModal() { `; populateAutoSyncHistoryList(overlay); bindAutoSyncHistoryCardInteractions(overlay); + + if (_prevScroll != null) { + const nl = overlay.querySelector('.auto-sync-tab-panel.active .auto-sync-lanes'); + if (nl) nl.scrollTop = _prevScroll; + } } function setAutoSyncTab(tab) { @@ -479,17 +501,23 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) { .map(s => parseInt(s?.hours, 10)) .filter(h => Number.isFinite(h) && h > 0 && !AUTO_SYNC_BUCKETS.includes(h)); const allBuckets = [...new Set([...AUTO_SYNC_BUCKETS, ...customHours])].sort((a, b) => a - b); + // Concept 1 — interval LANES (horizontal rows) instead of columns: no side-scroll, + // empty intervals collapse to thin strips, busy ones grow. Same drag handlers + card. const bucketHtml = allBuckets.map(hours => { const assigned = schedulablePlaylists.filter(p => playlistSchedules[p.id]?.hours === hours); const isCustom = !AUTO_SYNC_BUCKETS.includes(hours); + const filled = assigned.length > 0; return ` -
-
- ${autoSyncBucketLabel(hours)}${isCustom ? ' custom' : ''} - ${assigned.length} playlist${assigned.length === 1 ? '' : 's'} +
+
+ ${autoSyncBucketLabel(hours)} + ${_esc(autoSyncLaneCadence(hours))}${isCustom ? ' · custom' : ''} + ${filled ? `${assigned.length}` : ''}
-
- ${assigned.length ? assigned.map(p => autoSyncScheduledCardHtml(p, playlistSchedules[p.id])).join('') : '
Drop hereSchedule playlists at this interval
'} +
+ ${filled + ? assigned.map(p => autoSyncScheduledCardHtml(p, playlistSchedules[p.id])).join('') + : `
+ Drag a playlist here to sync ${_esc(autoSyncIntervalLabel(hours).toLowerCase())}
`}
`; @@ -514,7 +542,7 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) {
${sidebarHtml}${unavailableHtml}
-
${bucketHtml}
+
${bucketHtml}
`; } @@ -598,21 +626,24 @@ function renderAutoSyncWeeklyPanel(playlists, playlistSchedules) { }); }); + // Day LANES (horizontal rows, Mon–Sun) — mirrors the hourly board's lane layout. const dayColumnsHtml = AUTO_SYNC_WEEKDAYS.map(day => { const cards = cardsByDay[day]; - const cardHtml = cards.length + const filled = cards.length > 0; + const cardHtml = filled ? cards.map(({ playlist, schedule }) => autoSyncWeeklyCardHtml(playlist, schedule)).join('') - : '
Drop hereSchedule playlists on this day
'; + : `
+ Drag a playlist here to sync every ${_esc(AUTO_SYNC_WEEKDAY_LABELS[day])}
`; return ` -
-
- ${AUTO_SYNC_WEEKDAY_LABELS[day]} - ${cards.length} playlist${cards.length === 1 ? '' : 's'} +
+ ${_esc(AUTO_SYNC_WEEKDAY_LABELS[day])} + Weekly + ${filled ? `${cards.length}` : ''}
-
${cardHtml}
+
${cardHtml}
`; }).join(''); @@ -637,7 +668,7 @@ function renderAutoSyncWeeklyPanel(playlists, playlistSchedules) {
${sidebarHtml}${unavailableHtml}
-
${dayColumnsHtml}
+
${dayColumnsHtml}
${editorHtml} `; diff --git a/webui/static/style.css b/webui/static/style.css index 4939df93..6b0f8677 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -12465,6 +12465,171 @@ body.helper-mode-active #dashboard-activity-feed:hover { color: rgba(255, 255, 255, 0.45); } +/* ── Hourly board: interval LANES (horizontal rows — Concept 1) ───────────── */ +.auto-sync-lanes { + min-width: 0; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding: 16px 20px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.auto-sync-lane { + display: flex; + align-items: stretch; + gap: 12px; + padding: 11px 14px; + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 16px; + background: rgba(255, 255, 255, 0.025); + transition: border-color 0.22s ease, background 0.22s ease, + box-shadow 0.22s ease, transform 0.18s ease; +} + +.auto-sync-lane.empty { + background: transparent; + border-style: dashed; + border-color: rgba(255, 255, 255, 0.08); +} + +.auto-sync-lane.filled { + border-color: rgba(var(--accent-rgb), 0.22); + background: + linear-gradient(100deg, rgba(var(--accent-rgb), 0.07), rgba(var(--accent-rgb), 0.012) 55%), + rgba(255, 255, 255, 0.02); +} + +.auto-sync-lane:hover { + border-color: rgba(var(--accent-rgb), 0.3); +} + +.auto-sync-lane.drag-over { + border-color: rgba(var(--accent-rgb), 0.65); + border-style: solid; + background: rgba(var(--accent-rgb), 0.1); + box-shadow: + inset 0 0 0 1px rgba(var(--accent-rgb), 0.35), + 0 10px 30px rgba(var(--accent-rgb), 0.16); + transform: translateY(-1px); +} + +.auto-sync-lane-badge { + position: relative; + flex: 0 0 86px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; + padding: 4px 14px 4px 4px; + border-right: 1px solid rgba(255, 255, 255, 0.07); +} + +.auto-sync-lane-badge b { + font-size: 21px; + font-weight: 800; + line-height: 1; + letter-spacing: -0.02em; + color: rgba(255, 255, 255, 0.92); +} + +.auto-sync-lane.filled .auto-sync-lane-badge b { + background: linear-gradient(160deg, rgb(var(--accent-rgb)), rgba(var(--accent-rgb), 0.6)); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +.auto-sync-lane-badge span { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: rgba(255, 255, 255, 0.4); + white-space: nowrap; +} + +.auto-sync-lane-count { + position: absolute; + top: 0; + right: 10px; + min-width: 17px; + height: 17px; + padding: 0 4px; + display: grid; + place-items: center; + font-size: 10px; + font-weight: 800; + font-style: normal; + border-radius: 999px; + color: #fff; + background: rgba(var(--accent-rgb), 0.9); + box-shadow: 0 2px 8px rgba(var(--accent-rgb), 0.35); +} + +.auto-sync-lane-track { + flex: 1; + min-width: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 2px 0; +} + +.auto-sync-lane-hint { + display: flex; + align-items: center; + gap: 9px; + padding: 7px 2px; + color: rgba(255, 255, 255, 0.32); + font-size: 12.5px; + font-weight: 500; +} + +.auto-sync-lane-hint-ic { + display: grid; + place-items: center; + width: 22px; + height: 22px; + border-radius: 7px; + border: 1px dashed rgba(255, 255, 255, 0.2); + font-size: 15px; + line-height: 1; +} + +.auto-sync-lane.drag-over .auto-sync-lane-hint { color: rgb(var(--accent-rgb)); } +.auto-sync-lane.drag-over .auto-sync-lane-hint-ic { + border-style: solid; + border-color: rgba(var(--accent-rgb), 0.6); + color: rgb(var(--accent-rgb)); +} + +/* Scheduled cards flow horizontally inside a lane (override the column full-width) */ +.auto-sync-lane .auto-sync-scheduled-card { + width: auto; + min-width: 212px; + max-width: 264px; + margin-bottom: 0; + padding: 10px 12px; + border-radius: 12px; + background: rgba(0, 0, 0, 0.28); + border: 1px solid rgba(255, 255, 255, 0.07); + animation: autoSyncPop 0.24s ease both; +} + +.auto-sync-lane .auto-sync-scheduled-card:hover { + border-color: rgba(var(--accent-rgb), 0.4); + background: rgba(0, 0, 0, 0.36); +} + +@keyframes autoSyncPop { + from { opacity: 0; transform: translateY(5px) scale(0.97); } + to { opacity: 1; transform: none; } +} + .auto-sync-board { min-width: 0; min-height: 0;