mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 15:02:20 +02:00
Nothing in the UI said which build was loaded. /api/version has reported version, build and source_commit since the harness started versioning itself apart from the public semver, but the only way to read it was to curl the endpoint — so "is the preview actually running the commit I just merged?" took a terminal to answer. Pins a footer under the settings sidebar nav showing the registered version (plus the harness build when it differs) and the short source commit, with the full hash on hover. It sits outside the nav's scroll container so it stays at the bottom-left, and it is .admin-only, so syncAdminVisibility() hides it from non-admins the same way it hides the Admin nav group. The commit resolves at import via `git rev-parse HEAD` and is the string "unknown" when that fails — a read-only Docker tree with no .git. The footer treats "unknown" as absent and stays hidden when nothing is left to show, rather than printing it. The collapsed rail and the two narrow tab-rail layouts hide it too: neither has a bottom-left to write in.
317 lines
9.8 KiB
JavaScript
317 lines
9.8 KiB
JavaScript
const STORAGE_WIDTH = 'odysseus-settings-sidebar-width';
|
|
const STORAGE_COLLAPSED = 'odysseus-settings-sidebar-collapsed';
|
|
|
|
export const SETTINGS_SIDEBAR_DEFAULT_WIDTH = 220;
|
|
export const SETTINGS_SIDEBAR_MIN_WIDTH = 150;
|
|
export const SETTINGS_SIDEBAR_MAX_WIDTH = 340;
|
|
export const SETTINGS_SIDEBAR_COLLAPSE_THRESHOLD = 110;
|
|
|
|
const _bound = new WeakSet();
|
|
|
|
function clampWidth(value) {
|
|
const width = Number(value);
|
|
if (!Number.isFinite(width)) return SETTINGS_SIDEBAR_DEFAULT_WIDTH;
|
|
return Math.max(
|
|
SETTINGS_SIDEBAR_MIN_WIDTH,
|
|
Math.min(SETTINGS_SIDEBAR_MAX_WIDTH, width),
|
|
);
|
|
}
|
|
|
|
function readStoredWidth() {
|
|
try {
|
|
const stored = localStorage.getItem(STORAGE_WIDTH);
|
|
if (stored == null || String(stored).trim() === '') {
|
|
return SETTINGS_SIDEBAR_DEFAULT_WIDTH;
|
|
}
|
|
return clampWidth(stored);
|
|
} catch {
|
|
return SETTINGS_SIDEBAR_DEFAULT_WIDTH;
|
|
}
|
|
}
|
|
|
|
function readStoredCollapsed() {
|
|
try {
|
|
return localStorage.getItem(STORAGE_COLLAPSED) === '1';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function storeWidth(width) {
|
|
try {
|
|
localStorage.setItem(STORAGE_WIDTH, String(Math.round(width)));
|
|
} catch {}
|
|
}
|
|
|
|
function storeCollapsed(collapsed) {
|
|
try {
|
|
localStorage.setItem(STORAGE_COLLAPSED, collapsed ? '1' : '0');
|
|
} catch {}
|
|
}
|
|
|
|
function syncResizeHandleAria(modalEl, width = null) {
|
|
const handle = modalEl?.querySelector('#settings-sidebar-resize-handle');
|
|
if (!handle) return;
|
|
|
|
const sidebar = modalEl.querySelector('.settings-sidebar');
|
|
const collapsed = sidebar?.classList.contains('settings-sidebar-collapsed');
|
|
|
|
const current = collapsed
|
|
? SETTINGS_SIDEBAR_MIN_WIDTH
|
|
: clampWidth(
|
|
width ?? sidebar?.getBoundingClientRect?.().width
|
|
?? SETTINGS_SIDEBAR_DEFAULT_WIDTH
|
|
);
|
|
|
|
handle.setAttribute('aria-valuemin', String(SETTINGS_SIDEBAR_MIN_WIDTH));
|
|
handle.setAttribute('aria-valuemax', String(SETTINGS_SIDEBAR_MAX_WIDTH));
|
|
handle.setAttribute('aria-valuenow', String(Math.round(current)));
|
|
}
|
|
|
|
function isDesktopSidebarMode(modalEl) {
|
|
const content = modalEl?.querySelector('.settings-modal-content');
|
|
if (!content) return false;
|
|
|
|
// Mirrors the existing container breakpoint where the sidebar becomes a
|
|
// horizontal rail. Resizing/collapse applies only to the vertical desktop
|
|
// navigation layout.
|
|
return content.getBoundingClientRect().width > 620;
|
|
}
|
|
|
|
export function setSettingsSidebarCollapsed(modalEl, collapsed, options = {}) {
|
|
const sidebar = modalEl?.querySelector('.settings-sidebar');
|
|
if (!sidebar) return false;
|
|
|
|
const next = collapsed === true;
|
|
sidebar.classList.toggle('settings-sidebar-collapsed', next);
|
|
|
|
const toggle = sidebar.querySelector('#settings-sidebar-toggle');
|
|
if (toggle) {
|
|
toggle.setAttribute('aria-expanded', next ? 'false' : 'true');
|
|
toggle.setAttribute(
|
|
'aria-label',
|
|
next ? 'Expand settings navigation' : 'Collapse settings navigation',
|
|
);
|
|
toggle.title = next
|
|
? 'Expand settings navigation'
|
|
: 'Collapse settings navigation';
|
|
}
|
|
|
|
if (!next) {
|
|
const width = clampWidth(options.width ?? readStoredWidth());
|
|
sidebar.style.setProperty('--settings-sidebar-width', `${width}px`);
|
|
syncResizeHandleAria(modalEl, width);
|
|
} else {
|
|
syncResizeHandleAria(modalEl);
|
|
}
|
|
|
|
if (options.persist !== false) storeCollapsed(next);
|
|
return next;
|
|
}
|
|
|
|
export function setSettingsSidebarWidth(modalEl, width, options = {}) {
|
|
const sidebar = modalEl?.querySelector('.settings-sidebar');
|
|
if (!sidebar) return null;
|
|
|
|
const next = clampWidth(width);
|
|
sidebar.style.setProperty('--settings-sidebar-width', `${next}px`);
|
|
syncResizeHandleAria(modalEl, next);
|
|
|
|
if (options.persist !== false) storeWidth(next);
|
|
return next;
|
|
}
|
|
|
|
/* ── Build provenance ──
|
|
The bottom-left of the panel answers "which build am I actually looking
|
|
at": the version this build registers and the commit its process loaded.
|
|
Both come from /api/version, which resolves the commit once at import via
|
|
`git rev-parse HEAD` and reports the string "unknown" when it cannot — a
|
|
read-only Docker tree with no .git, for instance. */
|
|
const BUILD_COMMIT_SHORT_LEN = 8;
|
|
const BUILD_UNKNOWN = 'unknown';
|
|
const BUILD_COMMIT_SHA = /^[0-9a-f]{7,64}$/i;
|
|
|
|
function buildField(value) {
|
|
const text = String(value ?? '').trim();
|
|
return text && text.toLowerCase() !== BUILD_UNKNOWN ? text : '';
|
|
}
|
|
|
|
/** Turn an /api/version payload into the two sidebar lines, or null when it
|
|
* carries nothing worth a footer. */
|
|
export function formatSettingsBuildInfo(payload) {
|
|
const version = buildField(payload?.version);
|
|
const build = buildField(payload?.build);
|
|
const commit = buildField(payload?.source_commit);
|
|
|
|
const parts = [];
|
|
if (version) parts.push(`v${version}`);
|
|
// The preview harness versions itself separately from the public semver, so
|
|
// show both — but never the same number twice.
|
|
if (build && build !== version) parts.push(`build ${build}`);
|
|
|
|
// An ODYSSEUS_SOURCE_COMMIT override need not be a sha; leave those intact
|
|
// and let the CSS ellipsis deal with the width.
|
|
const commitLabel = BUILD_COMMIT_SHA.test(commit)
|
|
? commit.slice(0, BUILD_COMMIT_SHORT_LEN).toLowerCase()
|
|
: commit;
|
|
|
|
if (!parts.length && !commitLabel) return null;
|
|
|
|
return {
|
|
versionLabel: parts.join(' \u00b7 '),
|
|
commitLabel,
|
|
commitTitle: commit,
|
|
};
|
|
}
|
|
|
|
function paintSettingsBuildInfo(modalEl, info) {
|
|
const host = modalEl?.querySelector('#settings-sidebar-build');
|
|
if (!host) return;
|
|
|
|
const versionEl = host.querySelector('#settings-sidebar-build-version');
|
|
const commitEl = host.querySelector('#settings-sidebar-build-commit');
|
|
|
|
if (!info) {
|
|
host.hidden = true;
|
|
return;
|
|
}
|
|
|
|
if (versionEl) versionEl.textContent = info.versionLabel;
|
|
if (commitEl) {
|
|
commitEl.textContent = info.commitLabel;
|
|
// The short hash is what fits; keep the full one reachable on hover.
|
|
if (info.commitTitle) commitEl.title = info.commitTitle;
|
|
}
|
|
|
|
host.hidden = false;
|
|
}
|
|
|
|
async function loadSettingsBuildInfo(modalEl) {
|
|
try {
|
|
const res = await fetch('/api/version', { credentials: 'same-origin' });
|
|
if (!res.ok) return;
|
|
paintSettingsBuildInfo(modalEl, formatSettingsBuildInfo(await res.json()));
|
|
} catch {
|
|
// Provenance is informational: a failed probe leaves the footer hidden
|
|
// rather than putting an error in the middle of the settings nav.
|
|
}
|
|
}
|
|
|
|
export function bindSettingsSidebar(modalEl) {
|
|
if (!modalEl || _bound.has(modalEl)) return;
|
|
_bound.add(modalEl);
|
|
|
|
loadSettingsBuildInfo(modalEl);
|
|
|
|
const sidebar = modalEl.querySelector('.settings-sidebar');
|
|
const handle = modalEl.querySelector('#settings-sidebar-resize-handle');
|
|
const toggle = modalEl.querySelector('#settings-sidebar-toggle');
|
|
|
|
if (!sidebar || !handle || !toggle) return;
|
|
|
|
setSettingsSidebarWidth(modalEl, readStoredWidth(), { persist: false });
|
|
setSettingsSidebarCollapsed(
|
|
modalEl,
|
|
readStoredCollapsed(),
|
|
{ persist: false },
|
|
);
|
|
|
|
toggle.addEventListener('click', event => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
if (!isDesktopSidebarMode(modalEl)) return;
|
|
|
|
const collapsed = sidebar.classList.contains('settings-sidebar-collapsed');
|
|
setSettingsSidebarCollapsed(modalEl, !collapsed);
|
|
});
|
|
|
|
let startX = 0;
|
|
let startWidth = 0;
|
|
|
|
function stopResize() {
|
|
if (!sidebar.classList.contains('settings-sidebar-resizing')) return;
|
|
|
|
sidebar.classList.remove('settings-sidebar-resizing');
|
|
document.body.classList.remove('settings-sidebar-resize-active');
|
|
|
|
window.removeEventListener('pointermove', onPointerMove);
|
|
window.removeEventListener('pointerup', stopResize);
|
|
|
|
const width = sidebar.getBoundingClientRect().width;
|
|
if (width < SETTINGS_SIDEBAR_COLLAPSE_THRESHOLD) {
|
|
setSettingsSidebarCollapsed(modalEl, true);
|
|
return;
|
|
}
|
|
|
|
setSettingsSidebarCollapsed(modalEl, false, { persist: false });
|
|
setSettingsSidebarWidth(modalEl, width);
|
|
storeCollapsed(false);
|
|
}
|
|
|
|
function onPointerMove(event) {
|
|
const rawWidth = startWidth + (event.clientX - startX);
|
|
|
|
if (rawWidth < SETTINGS_SIDEBAR_COLLAPSE_THRESHOLD) {
|
|
sidebar.style.setProperty(
|
|
'--settings-sidebar-width',
|
|
`${Math.max(34, rawWidth)}px`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
setSettingsSidebarCollapsed(modalEl, false, { persist: false });
|
|
setSettingsSidebarWidth(modalEl, rawWidth, { persist: false });
|
|
}
|
|
|
|
handle.addEventListener('pointerdown', event => {
|
|
if (!isDesktopSidebarMode(modalEl)) return;
|
|
|
|
event.preventDefault();
|
|
startX = event.clientX;
|
|
startWidth = sidebar.getBoundingClientRect().width;
|
|
|
|
sidebar.classList.remove('settings-sidebar-collapsed');
|
|
sidebar.classList.add('settings-sidebar-resizing');
|
|
document.body.classList.add('settings-sidebar-resize-active');
|
|
|
|
window.addEventListener('pointermove', onPointerMove);
|
|
window.addEventListener('pointerup', stopResize);
|
|
});
|
|
|
|
handle.addEventListener('keydown', event => {
|
|
if (!isDesktopSidebarMode(modalEl)) return;
|
|
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault();
|
|
toggle.click();
|
|
return;
|
|
}
|
|
|
|
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
|
|
|
|
event.preventDefault();
|
|
|
|
if (sidebar.classList.contains('settings-sidebar-collapsed')) {
|
|
setSettingsSidebarCollapsed(modalEl, false);
|
|
}
|
|
|
|
const current = sidebar.getBoundingClientRect().width;
|
|
const delta = event.key === 'ArrowLeft' ? -16 : 16;
|
|
|
|
// Once keyboard resizing reaches the declared minimum, another ArrowLeft
|
|
// collapses the rail. Without this explicit boundary transition the width
|
|
// setter clamps 134px back to 150px forever, making keyboard collapse via
|
|
// ArrowLeft unreachable.
|
|
if (
|
|
event.key === 'ArrowLeft'
|
|
&& current <= SETTINGS_SIDEBAR_MIN_WIDTH
|
|
) {
|
|
setSettingsSidebarCollapsed(modalEl, true);
|
|
return;
|
|
}
|
|
|
|
setSettingsSidebarWidth(modalEl, current + delta);
|
|
});
|
|
}
|