Remove embedded Download Manager from Search page, bump to 2.44
Phase 3c of the Search/Artists unification. The Search page carried
a second copy of the Download Manager (active + finished queues,
clear/cancel-all buttons) that was hidden by default and duplicated
the dedicated Downloads page. That duplicate is now gone.
Removed:
- Side-panel HTML block and the toggle button that showed/hid it
- ~290 lines of polling + render infra in downloads.js: loadDownloads-
Data, startDownloadPolling/stopDownloadPolling, updateDownload-
Queues, renderQueue, updateTabCounts/updateDownloadStats,
initializeDownloadTabs/switchDownloadTab, cancelDownloadItem,
clearFinishedDownloads, cancelAllDownloads, and the
activeDownloads/finishedDownloads globals
- initializeDownloadManagerToggle and its call from init.js
- Stopped hitting /api/downloads/status every second on the Search
page (the dedicated Downloads page already polls its own view)
CSS grid for the Search page collapsed from '1fr 370px' to '1fr' now
that the right panel is gone. Unused .controls-panel__* / .download-
manager__* / .downloads-side-panel CSS rules kept in place — harmless,
can be pruned later.
This commit is contained in:
parent
6992e2e5b5
commit
f203b3e46d
6 changed files with 22 additions and 411 deletions
|
|
@ -37,7 +37,7 @@ _log_dir = Path(_log_path).parent
|
|||
logger = setup_logging(_log_level, _log_path)
|
||||
|
||||
# App version — single source of truth for backup metadata, version-info endpoint, etc.
|
||||
_SOULSYNC_BASE_VERSION = "2.43"
|
||||
_SOULSYNC_BASE_VERSION = "2.44"
|
||||
|
||||
def _build_version_string():
|
||||
"""Append short commit hash to version when available (e.g. 2.35+abc1234)."""
|
||||
|
|
@ -22809,6 +22809,16 @@ def get_version_info():
|
|||
"title": "What's New in SoulSync",
|
||||
"subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes",
|
||||
"sections": [
|
||||
{
|
||||
"title": "Remove Embedded Download Manager from Search Page",
|
||||
"description": "The Search page used to carry a second copy of the Download Manager (active + finished queues, clear/cancel-all buttons) that was hidden by default and duplicated the dedicated Downloads page. That duplicate is gone",
|
||||
"features": [
|
||||
"• Toggle button, side-panel HTML, and its 1-second polling loop removed — Downloads page is now the single downloads UI",
|
||||
"• About 330 lines of dead code gone across downloads.js and init.js",
|
||||
"• CSS grid for the Search page collapsed to a single-column layout now that the right panel is gone",
|
||||
"• Phase 3c of the Search/Artists unification project",
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "Search Page Renamed to /search",
|
||||
"description": "The Search page's internal id is now 'search' instead of 'downloads', which no longer conflicts with the real Downloads page. URL /downloads still works for bookmarks and external links",
|
||||
|
|
|
|||
|
|
@ -1848,10 +1848,10 @@
|
|||
This top-level container replicates the QSplitter from downloads.py,
|
||||
creating the two-panel layout for the page.
|
||||
-->
|
||||
<div class="downloads-content manager-hidden">
|
||||
<div class="downloads-content">
|
||||
|
||||
<!-- ======================================================= -->
|
||||
<!-- == LEFT PANEL: Search, Filters, and Results == -->
|
||||
<!-- == Search page main panel == -->
|
||||
<!-- ======================================================= -->
|
||||
<div class="downloads-main-panel">
|
||||
|
||||
|
|
@ -1859,13 +1859,9 @@
|
|||
<div class="downloads-header">
|
||||
<div class="downloads-header-content">
|
||||
<div class="downloads-header-text">
|
||||
<h2 class="downloads-title"><img src="/static/search.png" class="page-header-icon" alt=""><span>Music Downloads</span></h2>
|
||||
<p class="downloads-subtitle">Search, discover, and download high-quality music</p>
|
||||
<h2 class="downloads-title"><img src="/static/search.png" class="page-header-icon" alt=""><span>Search</span></h2>
|
||||
<p class="downloads-subtitle">Find artists, albums, and tracks from any metadata source</p>
|
||||
</div>
|
||||
<button id="toggle-download-manager-btn" class="toggle-manager-btn"
|
||||
title="Toggle Download Manager">
|
||||
<span class="toggle-icon">›</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -2107,44 +2103,6 @@
|
|||
<!-- End Enhanced Search Section -->
|
||||
</div>
|
||||
|
||||
<!-- ======================================================= -->
|
||||
<!-- == RIGHT PANEL: Controls and Download Queue == -->
|
||||
<!-- ======================================================= -->
|
||||
<div class="downloads-side-panel">
|
||||
|
||||
<!-- Controls Panel: Replicates create_collapsible_controls_panel() -->
|
||||
<div class="controls-panel">
|
||||
<h3 class="controls-panel__header">Download Manager</h3>
|
||||
<div class="controls-panel__stats">
|
||||
<p id="active-downloads-label">• Active Downloads: 0</p>
|
||||
<p id="finished-downloads-label">• Finished Downloads: 0</p>
|
||||
</div>
|
||||
<div class="controls-panel__actions">
|
||||
<button class="controls-panel__clear-btn">🗑️ Clear Completed</button>
|
||||
<button class="controls-panel__cancel-all-btn">⛔ Clear Current</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Download Queue: Replicates TabbedDownloadManager -->
|
||||
<div class="download-manager">
|
||||
<div class="download-manager__tabs">
|
||||
<button class="tab-btn active" data-tab="active-queue">Download Queue (0)</button>
|
||||
<button class="tab-btn" data-tab="finished-queue">Finished (0)</button>
|
||||
</div>
|
||||
<div class="download-manager__content">
|
||||
|
||||
<!-- Active Queue -->
|
||||
<div class="download-queue active" id="active-queue">
|
||||
<div class="download-queue__empty-message">No active downloads.</div>
|
||||
</div>
|
||||
|
||||
<!-- Finished Queue -->
|
||||
<div class="download-queue" id="finished-queue">
|
||||
<div class="download-queue__empty-message">No finished downloads.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -4267,335 +4267,7 @@ function updateModalSyncProgress(playlistId, progress) {
|
|||
}
|
||||
|
||||
|
||||
// Download tracking state management - matching GUI functionality
|
||||
let activeDownloads = {};
|
||||
let finishedDownloads = {};
|
||||
let downloadStatusInterval = null;
|
||||
let isDownloadPollingActive = false;
|
||||
|
||||
async function loadDownloadsData() {
|
||||
// Downloads page loads search results dynamically
|
||||
console.log('Downloads page loaded');
|
||||
|
||||
// Event listeners are already set up in initializeSearch() - don't duplicate them
|
||||
const clearButton = document.querySelector('.controls-panel__clear-btn');
|
||||
const cancelAllButton = document.querySelector('.controls-panel__cancel-all-btn');
|
||||
|
||||
if (clearButton) {
|
||||
clearButton.addEventListener('click', clearFinishedDownloads);
|
||||
}
|
||||
if (cancelAllButton) {
|
||||
cancelAllButton.addEventListener('click', cancelAllDownloads);
|
||||
}
|
||||
|
||||
// Start sophisticated polling system (1-second interval like GUI)
|
||||
startDownloadPolling();
|
||||
|
||||
// Initialize tab management
|
||||
initializeDownloadTabs();
|
||||
}
|
||||
|
||||
function startDownloadPolling() {
|
||||
if (isDownloadPollingActive) return;
|
||||
|
||||
console.log('Starting download status polling (1-second interval)');
|
||||
isDownloadPollingActive = true;
|
||||
|
||||
// Initial call
|
||||
updateDownloadQueues();
|
||||
|
||||
// Start 1-second polling (matching GUI's 1000ms timer)
|
||||
downloadStatusInterval = setInterval(updateDownloadQueues, 1000);
|
||||
}
|
||||
|
||||
function stopDownloadPolling() {
|
||||
if (downloadStatusInterval) {
|
||||
clearInterval(downloadStatusInterval);
|
||||
downloadStatusInterval = null;
|
||||
}
|
||||
isDownloadPollingActive = false;
|
||||
console.log('Stopped download status polling');
|
||||
}
|
||||
|
||||
async function updateDownloadQueues() {
|
||||
if (document.hidden) return; // Skip polling when tab is not visible
|
||||
try {
|
||||
const response = await fetch('/api/downloads/status');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
console.error("Error fetching download status:", data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const newActive = {};
|
||||
const newFinished = {};
|
||||
|
||||
// Terminal states matching GUI logic
|
||||
const terminalStates = ['Completed', 'Succeeded', 'Cancelled', 'Canceled', 'Failed', 'Errored'];
|
||||
|
||||
// Process transfers exactly like GUI
|
||||
data.transfers.forEach(item => {
|
||||
const isTerminal = terminalStates.some(state =>
|
||||
item.state && item.state.includes(state)
|
||||
);
|
||||
|
||||
if (isTerminal) {
|
||||
newFinished[item.id] = item;
|
||||
} else {
|
||||
newActive[item.id] = item;
|
||||
}
|
||||
});
|
||||
|
||||
// Update global state
|
||||
activeDownloads = newActive;
|
||||
finishedDownloads = newFinished;
|
||||
|
||||
// Render both queues
|
||||
renderQueue('active-queue', activeDownloads, true);
|
||||
renderQueue('finished-queue', finishedDownloads, false);
|
||||
|
||||
// Update tab counts
|
||||
updateTabCounts();
|
||||
|
||||
// Update stats in the side panel
|
||||
updateDownloadStats();
|
||||
|
||||
} catch (error) {
|
||||
// Only log errors occasionally to avoid console spam
|
||||
if (Math.random() < 0.1) {
|
||||
console.error("Failed to update download queues:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderQueue(containerId, downloads, isActiveQueue) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
|
||||
const downloadIds = Object.keys(downloads);
|
||||
|
||||
if (downloadIds.length === 0) {
|
||||
container.innerHTML = `<div class="download-queue__empty-message">${isActiveQueue ? 'No active downloads.' : 'No finished downloads.'}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const id of downloadIds) {
|
||||
const item = downloads[id];
|
||||
|
||||
// Extract display title from filename
|
||||
let title = 'Unknown File';
|
||||
if (item.filename) {
|
||||
// YouTube/Tidal filenames are encoded as "id||title"
|
||||
if ((item.username === 'youtube' || item.username === 'tidal' || item.username === 'qobuz' || item.username === 'hifi') && item.filename.includes('||')) {
|
||||
const parts = item.filename.split('||');
|
||||
title = parts[1] || parts[0]; // Use title part, fallback to id
|
||||
} else {
|
||||
// Regular Soulseek filename - extract last part of path
|
||||
title = item.filename.split(/[\\/]/).pop();
|
||||
}
|
||||
}
|
||||
|
||||
const progress = item.percentComplete || 0;
|
||||
const bytesTransferred = item.bytesTransferred || 0;
|
||||
const totalBytes = item.size || 0;
|
||||
const speed = item.averageSpeed || 0;
|
||||
|
||||
// Format file size
|
||||
const formatSize = (bytes) => {
|
||||
if (!bytes) return 'Unknown size';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
// Format speed
|
||||
const formatSpeed = (bytesPerSecond) => {
|
||||
if (!bytesPerSecond || bytesPerSecond <= 0) return '';
|
||||
return `${formatSize(bytesPerSecond)}/s`;
|
||||
};
|
||||
|
||||
let actionButtonHTML = '';
|
||||
if (isActiveQueue) {
|
||||
// Active items get progress bar and cancel button
|
||||
actionButtonHTML = `
|
||||
<div class="download-item__progress-container">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: ${progress}%;"></div>
|
||||
</div>
|
||||
<div class="progress-text">
|
||||
${item.state} - ${progress.toFixed(1)}%
|
||||
${speed > 0 ? `• ${formatSpeed(speed)}` : ''}
|
||||
${totalBytes > 0 ? `• ${formatSize(bytesTransferred)} / ${formatSize(totalBytes)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button class="download-item__cancel-btn" onclick="cancelDownloadItem('${item.id}', '${item.username}')">✕ Cancel</button>
|
||||
`;
|
||||
} else {
|
||||
// Finished items get status and open button
|
||||
let statusClass = '';
|
||||
if (item.state.includes('Cancelled')) statusClass = 'status--cancelled';
|
||||
else if (item.state.includes('Failed') || item.state.includes('Errored')) statusClass = 'status--failed';
|
||||
else if (item.state.includes('Completed') || item.state.includes('Succeeded')) statusClass = 'status--completed';
|
||||
|
||||
actionButtonHTML = `
|
||||
<div class="download-item__status-container">
|
||||
<span class="download-item__status-text ${statusClass}">${item.state}</span>
|
||||
</div>
|
||||
<button class="download-item__open-btn" title="Cannot open folder from web browser" disabled>📁 Open</button>
|
||||
`;
|
||||
}
|
||||
|
||||
// Enrich with metadata from backend context (artist, album, artwork)
|
||||
const meta = item._meta || {};
|
||||
const sourceLabels = { youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', lidarr: 'Lidarr' };
|
||||
const sourceBadge = sourceLabels[item.username] || item.username;
|
||||
|
||||
html += `
|
||||
<div class="download-item" data-id="${item.id}">
|
||||
<div class="download-item__art">
|
||||
${meta.artwork_url
|
||||
? `<img src="${meta.artwork_url}" alt="" loading="lazy" onerror="this.parentElement.innerHTML='<div class=\\'download-item__art-placeholder\\'>♫</div>'">`
|
||||
: '<div class="download-item__art-placeholder">♫</div>'}
|
||||
</div>
|
||||
<div class="download-item__info">
|
||||
<div class="download-item__title" title="${title}">${title}</div>
|
||||
${meta.artist || meta.album ? `
|
||||
<div class="download-item__meta">
|
||||
${meta.artist ? `<span>${escapeHtml(meta.artist)}</span>` : ''}
|
||||
${meta.artist && meta.album ? '<span class="download-item__sep">·</span>' : ''}
|
||||
${meta.album ? `<span class="download-item__album">${escapeHtml(meta.album)}</span>` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="download-item__badges">
|
||||
<span class="download-item__source">${sourceBadge}</span>
|
||||
${meta.quality ? `<span class="download-item__quality">${escapeHtml(meta.quality)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="download-item__content">
|
||||
${actionButtonHTML}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function updateTabCounts() {
|
||||
const activeCount = Object.keys(activeDownloads).length;
|
||||
const finishedCount = Object.keys(finishedDownloads).length;
|
||||
|
||||
const activeTabBtn = document.querySelector('.tab-btn[data-tab="active-queue"]');
|
||||
const finishedTabBtn = document.querySelector('.tab-btn[data-tab="finished-queue"]');
|
||||
|
||||
if (activeTabBtn) activeTabBtn.textContent = `Download Queue (${activeCount})`;
|
||||
if (finishedTabBtn) finishedTabBtn.textContent = `Finished (${finishedCount})`;
|
||||
}
|
||||
|
||||
function updateDownloadStats() {
|
||||
const activeCount = Object.keys(activeDownloads).length;
|
||||
const finishedCount = Object.keys(finishedDownloads).length;
|
||||
|
||||
const activeLabel = document.getElementById('active-downloads-label');
|
||||
const finishedLabel = document.getElementById('finished-downloads-label');
|
||||
|
||||
if (activeLabel) activeLabel.textContent = `• Active Downloads: ${activeCount}`;
|
||||
if (finishedLabel) finishedLabel.textContent = `• Finished Downloads: ${finishedCount}`;
|
||||
}
|
||||
|
||||
function initializeDownloadTabs() {
|
||||
const tabButtons = document.querySelectorAll('.tab-btn');
|
||||
tabButtons.forEach(btn => {
|
||||
btn.addEventListener('click', () => switchDownloadTab(btn));
|
||||
});
|
||||
}
|
||||
|
||||
function switchDownloadTab(button) {
|
||||
const targetTabId = button.getAttribute('data-tab');
|
||||
|
||||
// Update buttons
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
||||
button.classList.add('active');
|
||||
|
||||
// Update content panes
|
||||
document.querySelectorAll('.download-queue').forEach(queue => queue.classList.remove('active'));
|
||||
const targetQueue = document.getElementById(targetTabId);
|
||||
if (targetQueue) targetQueue.classList.add('active');
|
||||
}
|
||||
|
||||
async function cancelDownloadItem(downloadId, username) {
|
||||
try {
|
||||
const response = await fetch('/api/downloads/cancel', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ download_id: downloadId, username: username })
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast('Download cancelled', 'success');
|
||||
} else {
|
||||
showToast(`Failed to cancel: ${result.error}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cancelling download:', error);
|
||||
showToast('Error sending cancel request', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function clearFinishedDownloads() {
|
||||
const finishedCount = Object.keys(finishedDownloads).length;
|
||||
if (finishedCount === 0) {
|
||||
showToast('No finished downloads to clear', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/downloads/clear-finished', {
|
||||
method: 'POST'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast('Finished downloads cleared', 'success');
|
||||
} else {
|
||||
showToast(`Failed to clear: ${result.error}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error clearing finished downloads:', error);
|
||||
showToast('Error sending clear request', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelAllDownloads() {
|
||||
if (!await showConfirmDialog({ title: 'Cancel All Downloads', message: 'Cancel ALL active downloads and clear the transfer list? This cannot be undone.', confirmText: 'Cancel All', destructive: true })) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/downloads/cancel-all', {
|
||||
method: 'POST'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast('All downloads cancelled and cleared', 'success');
|
||||
} else {
|
||||
showToast(`Failed to cancel: ${result.error}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cancelling all downloads:', error);
|
||||
showToast('Error cancelling downloads', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// REPLACE the old performDownloadsSearch function with this new one.
|
||||
// Raw Soulseek file search (used by the 'Soulseek (raw files)' source picker option).
|
||||
async function performDownloadsSearch() {
|
||||
const query = document.getElementById('downloads-search-input').value.trim();
|
||||
if (!query) {
|
||||
|
|
|
|||
|
|
@ -3599,6 +3599,11 @@ function closeHelperSearch() {
|
|||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const WHATS_NEW = {
|
||||
'2.44': [
|
||||
// --- April 23, 2026 (evening) ---
|
||||
{ date: 'April 23, 2026 (evening)' },
|
||||
{ title: 'Remove Embedded Download Manager from Search Page', desc: 'The Search page used to carry a second copy of the Download Manager (active + finished queues, clear/cancel-all buttons) that was hidden by default and duplicated the dedicated Downloads page. That duplicate is gone — toggle button, side-panel HTML, and its 1-second polling loop all removed. About 330 lines of dead code cleaned up across downloads.js and init.js. CSS grid for the Search page collapsed to single-column now that the right panel is gone. The dedicated Downloads sidebar page is now the single downloads UI. Phase 3c of the Search/Artists unification project', page: 'search' },
|
||||
],
|
||||
'2.43': [
|
||||
// --- April 23, 2026 (later) ---
|
||||
{ date: 'April 23, 2026 (later)' },
|
||||
|
|
|
|||
|
|
@ -1925,7 +1925,6 @@ function initApp() {
|
|||
initExpandedPlayer();
|
||||
initializeSyncPage();
|
||||
initializeWatchlist();
|
||||
initializeDownloadManagerToggle();
|
||||
|
||||
|
||||
// Initialize WebSocket connection (falls back to HTTP polling if unavailable)
|
||||
|
|
@ -2115,37 +2114,6 @@ function initializeWatchlist() {
|
|||
console.log('Watchlist system initialized');
|
||||
}
|
||||
|
||||
function initializeDownloadManagerToggle() {
|
||||
const toggleButton = document.getElementById('toggle-download-manager-btn');
|
||||
const downloadsContent = document.querySelector('.downloads-content');
|
||||
|
||||
if (!toggleButton || !downloadsContent) {
|
||||
console.log('Download manager toggle not found on this page');
|
||||
return;
|
||||
}
|
||||
|
||||
// Load saved state from localStorage (hidden by default for more search space)
|
||||
const isHidden = localStorage.getItem('downloadManagerHidden') !== 'false';
|
||||
if (isHidden) {
|
||||
downloadsContent.classList.add('manager-hidden');
|
||||
}
|
||||
|
||||
// Add click handler
|
||||
toggleButton.addEventListener('click', () => {
|
||||
const isCurrentlyHidden = downloadsContent.classList.contains('manager-hidden');
|
||||
|
||||
if (isCurrentlyHidden) {
|
||||
downloadsContent.classList.remove('manager-hidden');
|
||||
localStorage.setItem('downloadManagerHidden', 'false');
|
||||
} else {
|
||||
downloadsContent.classList.add('manager-hidden');
|
||||
localStorage.setItem('downloadManagerHidden', 'true');
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Download manager toggle initialized');
|
||||
}
|
||||
|
||||
function navigateToPage(pageId, options = {}) {
|
||||
// Backwards-compat alias — the Search page used to live under id 'downloads'.
|
||||
if (pageId === 'downloads') pageId = 'search';
|
||||
|
|
@ -2249,7 +2217,6 @@ async function loadPageData(pageId) {
|
|||
initializeSearch();
|
||||
initializeSearchModeToggle();
|
||||
initializeFilters();
|
||||
await loadDownloadsData();
|
||||
break;
|
||||
case 'artists':
|
||||
// Only fully initialize if not already initialized
|
||||
|
|
|
|||
|
|
@ -4255,8 +4255,7 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
/* Main Layout: Replicates QSplitter */
|
||||
.downloads-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 370px;
|
||||
/* Left panel is flexible, right is fixed */
|
||||
grid-template-columns: 1fr;
|
||||
gap: 24px;
|
||||
height: 100%;
|
||||
/* Fill parent .page content area */
|
||||
|
|
|
|||
Loading…
Reference in a new issue