Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 56 additions & 2 deletions web/static/css/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -474,13 +474,15 @@ footer {
display: none;
}

/* Global Operation Banner - centered within content-wrapper */
/* Global Operation Banner - centered within content-wrapper.
z-index 100: above page content, below the theme toggle (101), the sidebar (160)
and the mobile header (200), so it can never cover the navigation (#216). */
#global-operation-banner {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
z-index: 100;
pointer-events: none;
display: flex;
justify-content: center;
Expand All @@ -494,6 +496,28 @@ footer {
pointer-events: auto;
}

/* Fit mode (#216): the fit check in base.html adds .di-flow when the centred pill
would collide with the page title, the action buttons or the theme toggle (narrow
windows, high zoom, phones). The banner then becomes a full-width row of the page
header, ordered after the buttons, and moves back up as soon as there is room. */
.page-header > #global-operation-banner.di-flow {
position: relative;
top: auto;
left: auto;
right: auto;
transform: none;
order: 1;
flex: 1 1 100%;
min-height: 38px;
align-items: flex-start;
}

/* Dashboard/Maintenance start with a light "Updated ... / Refresh" row instead of a
card, so in fit mode the space below the pill can be a bit tighter. */
.content-wrapper:has(#global-operation-banner.di-flow) .stats-header {
margin-top: -0.75rem;
}


/* Foreground/background tokens for the pill — dark by default.
Text-legibility tiers (--di-t*) exist because opacity-based text
Expand Down Expand Up @@ -1357,6 +1381,18 @@ footer {
border-bottom-color: rgba(0,0,0,0.3);
}

/* Fit mode: the expanded pill stays an overlay on top of the page content */
#global-operation-banner.di-flow[data-di-expanded="tier2"] .di-pill,
#global-operation-banner.di-flow[data-di-expanded="tier3"] .di-pill,
#global-operation-banner.di-flow .di-pill.di-expanded {
position: absolute;
top: 0;
left: 50%;
translate: -50% 0;
width: max-content;
max-width: min(620px, calc(100vw - 2rem));
}

/* ── Mobile responsive ───────────────────────────────── */
@media (max-width: 768px) {
#global-operation-banner {
Expand All @@ -1376,6 +1412,24 @@ footer {
min-width: auto;
}

/* The header is a column here: stretch the banner to the full width (the row
layout's flex-basis does not apply) so the pill and its overlay get a width. */
.page-header > #global-operation-banner.di-flow {
flex: 0 0 auto;
align-self: stretch;
}

#global-operation-banner.di-flow[data-di-expanded="tier2"] .di-pill,
#global-operation-banner.di-flow[data-di-expanded="tier3"] .di-pill,
#global-operation-banner.di-flow .di-pill.di-expanded {
left: 0;
right: 0;
translate: none;
width: auto;
min-width: 0;
max-width: none;
}

.di-pill__detail {
max-height: 150px;
}
Expand Down
65 changes: 65 additions & 0 deletions web/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,71 @@

{% block content %}{% endblock %}

<script>
// Operation pill placement (#216). The pill stays centred on top of the page
// (Dynamic-Island style) as long as it does not collide with the page title,
// the action buttons or the theme toggle. If it would, it gets .di-flow and
// becomes a full-width row of the header, below the buttons. Measured with
// .di-flow removed, so it moves back up as soon as there is room again.
(function () {
var banner = document.getElementById('global-operation-banner');
var header = document.querySelector('.content-wrapper > .page-header');
if (!banner || !header) return;
// Child of the header so the fit mode can place it in the header flow;
// the default state is absolutely positioned and does not affect the layout.
var title = header.querySelector(':scope > .page-title');
header.insertBefore(banner, title ? title.nextSibling : header.firstChild);

function collides(a, b, gap) {
if (!b || !b.width || !b.height) return false;
return a.left < b.right + gap && a.right + gap > b.left &&
a.top < b.bottom && a.bottom > b.top;
}

function fit() {
var state = banner.getAttribute('data-di-expanded');
if (state === 'tier2' || state === 'tier3') return; // expanded overlay: keep mode
if (!banner.querySelector('.di-pill')) return;
var inFlow = banner.classList.contains('di-flow');
if (inFlow) banner.classList.remove('di-flow');
var rect = banner.getBoundingClientRect();
var gap = inFlow ? 40 : 16; // hysteresis: no flip-flopping at the edge
var hit = [
header.querySelector(':scope > .page-title'),
header.querySelector(':scope > .action-buttons'),
document.querySelector('.top-controls')
].some(function (el) {
return el && collides(rect, el.getBoundingClientRect(), gap);
});
if (hit) banner.classList.add('di-flow');
}

// Content swaps are handled synchronously (microtask, before the next paint),
// so the pill never flashes in the wrong place. Expand/collapse animations
// are re-checked once they are done.
var slow = null;
if (window.ResizeObserver) {
new ResizeObserver(function () { requestAnimationFrame(fit); }).observe(header);
} else {
window.addEventListener('resize', fit);
}
// Banner content is replaced by htmx and by diTriggerRun() (innerHTML)
new MutationObserver(function (list) {
if (list.some(function (m) { return m.type === 'childList'; })) fit();
if (list.some(function (m) { return m.type === 'attributes'; })) {
clearTimeout(slow);
slow = setTimeout(fit, 450);
}
}).observe(banner, {
childList: true, // direct children only: the pill itself being replaced
attributes: true, attributeFilter: ['data-di-expanded']
});
if (document.fonts && document.fonts.ready) document.fonts.ready.then(fit);
window.addEventListener('load', fit);
fit();
})();
</script>

<footer>
PlexCache-D Web UI <span class="version">v{{ web_version }}</span>
</footer>
Expand Down
Loading