diff --git a/public/shell.js b/public/shell.js index 9e0785f..d0d10bd 100644 --- a/public/shell.js +++ b/public/shell.js @@ -273,6 +273,10 @@ function buildTile(tile) { resize.className = 'tile-resize'; resize.title = 'Drag to resize'; + const resizeLeft = document.createElement('div'); + resizeLeft.className = 'tile-resize-left'; + resizeLeft.title = 'Drag to resize'; + // notch on the title bar's bottom edge: hides/shows the bar const notch = document.createElement('button'); notch.className = 'tile-notch'; @@ -284,7 +288,7 @@ function buildTile(tile) { saveLayout(); }); - el.append(head, body, resize, notch); + el.append(head, body, resize, resizeLeft, notch); el.classList.toggle('head-hidden', Boolean(tile.headHidden)); grid.appendChild(el); @@ -293,7 +297,8 @@ function buildTile(tile) { applyRect(tile); wireDrag(tile, el, head); - wireResize(tile, el, resize); + wireResize(tile, el, resize, false); + wireResize(tile, el, resizeLeft, true); mountModule(tile); return entry; @@ -309,6 +314,7 @@ function wireDrag(tile, el, handle) { handle.addEventListener('pointerdown', (ev) => { if (ev.target.closest('.tile-btn') || ev.target.closest('.tile-menu') || ev.target.closest('.tile-notch')) return; if (ev.button !== 0 && ev.pointerType === 'mouse') return; + if (document.body.classList.contains('fullscreen')) return; ev.preventDefault(); closeSettingsPopovers(); handle.setPointerCapture(ev.pointerId); @@ -356,17 +362,19 @@ function wireDrag(tile, el, handle) { }); } -/* ── drag to resize ─────────────────────────────────────────────────── */ +/* ── drag to resize (bottom-right and bottom-left corners) ──────────── */ -function wireResize(tile, el, handle) { +function wireResize(tile, el, handle, fromLeft) { handle.addEventListener('pointerdown', (ev) => { if (ev.button !== 0 && ev.pointerType === 'mouse') return; + if (document.body.classList.contains('fullscreen')) return; ev.preventDefault(); ev.stopPropagation(); handle.setPointerCapture(ev.pointerId); const m = cellMetrics(); const sx = ev.clientX; const sy = ev.clientY; + const startX = tile.x; const startW = tile.w; const startH = tile.h; const min = minSizeOf(tile); @@ -377,13 +385,26 @@ function wireResize(tile, el, handle) { const onMove = (e) => { const dCols = Math.round((e.clientX - sx) / (m.cw + m.gap)); const dRows = Math.round((e.clientY - sy) / (m.ch + m.gap)); - const cand = { - x: tile.x, - y: tile.y, - w: Math.max(min.w, Math.min(COLS - tile.x, startW + dCols)), - h: Math.max(min.h, startH + dRows), - }; - if ((cand.w !== tile.w || cand.h !== tile.h) && isFree(cand, tile.id)) { + let cand; + if (fromLeft) { + // the left edge moves; the right edge stays put + const newX = Math.max(0, Math.min(startX + startW - min.w, startX + dCols)); + cand = { + x: newX, + y: tile.y, + w: startX + startW - newX, + h: Math.max(min.h, startH + dRows), + }; + } else { + cand = { + x: tile.x, + y: tile.y, + w: Math.max(min.w, Math.min(COLS - tile.x, startW + dCols)), + h: Math.max(min.h, startH + dRows), + }; + } + if ((cand.x !== tile.x || cand.w !== tile.w || cand.h !== tile.h) && isFree(cand, tile.id)) { + tile.x = cand.x; tile.w = cand.w; tile.h = cand.h; applyRect(tile); @@ -452,6 +473,7 @@ function buildDivider(L, R, top, bottom) { el.addEventListener('pointerdown', (ev) => { if (ev.button !== 0 && ev.pointerType === 'mouse') return; + if (document.body.classList.contains('fullscreen')) return; ev.preventDefault(); closeSettingsPopovers(); el.setPointerCapture(ev.pointerId); @@ -915,6 +937,7 @@ function renderLayout(list) { for (const tile of tiles) buildTile(tile); refreshEmptyState(); rebuildDividers(); + if (document.body.classList.contains('fullscreen')) applyFullscreenFit(); } /* ── menus ──────────────────────────────────────────────────────────── */ @@ -1026,6 +1049,140 @@ wireDropdown('layout-btn', 'layout-menu', async (menu) => { }, { danger: true, sub: 'Remove every tile from this browser' })); }); +/* ── menu bar show/hide (notch, like the tile title bars) ───────────── */ + +const topbar = document.getElementById('topbar'); +const topbarNotch = document.getElementById('topbar-notch'); +const LS_MENU = 'proddash:menuHidden'; + +/** Keep the notch glued to the menubar's bottom edge, whatever its height. */ +const topbarSizer = new ResizeObserver(() => { + document.documentElement.style.setProperty('--topbar-h', topbar.offsetHeight + 'px'); +}); +topbarSizer.observe(topbar); + +function setMenuHidden(hidden, { persist = true } = {}) { + document.body.classList.toggle('menu-hidden', hidden); + if (persist) { + try { localStorage.setItem(LS_MENU, hidden ? '1' : '0'); } catch { /* fine */ } + } +} + +topbarNotch.addEventListener('click', () => { + setMenuHidden(!document.body.classList.contains('menu-hidden')); +}); + +try { + if (localStorage.getItem(LS_MENU) === '1') setMenuHidden(true, { persist: false }); +} catch { /* fine */ } + +/* ── fullscreen: auto-fit the layout to the screen ──────────────────── */ + +/* Fullscreen is a viewing mode. The occupied columns stretch proportionally + to the full width; tiles whose bottom edge lands near the bottom of the + viewport are stretched to end exactly at it; when nothing extends below + the viewport, scrolling is disabled. All of it is visual-only — the saved + layout is untouched and everything reverts on exit. */ + +const fsBtn = document.getElementById('fs-btn'); +/** Rows this close to the viewport bottom count as "near" and get stretched. */ +const FS_NEAR_ROWS = 2; +let menuHiddenBeforeFs = false; + +function fsSmallExempt(tile) { + // A small tile (like the clock) alone in its columns must not become a + // full-height ribbon. + if (tile.h > 2) return false; + return !tiles.some((t) => t !== tile && t.x < tile.x + tile.w && tile.x < t.x + t.w); +} + +function fsBottomMost(tile) { + return !tiles.some((t) => t !== tile + && t.x < tile.x + tile.w && tile.x < t.x + t.w + && t.y >= tile.y + tile.h); +} + +function applyFullscreenFit() { + if (!document.body.classList.contains('fullscreen') || !tiles.length) return; + const m = cellMetrics(); + const baseCellH = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--cell-h')) || 72; + const wrapH = gridwrap.clientHeight; + const maxCol = Math.max(1, ...tiles.map((t) => t.x + t.w)); + const maxRow = Math.max(1, ...tiles.map((t) => t.y + t.h)); + + // x: the occupied columns stretch proportionally across the full width + grid.style.gridTemplateColumns = `repeat(${maxCol}, minmax(0, 1fr))`; + + const layoutH = maxRow * (baseCellH + m.gap) - m.gap; + if (layoutH > wrapH) { + // deeper than the screen: keep natural rows and scrolling + grid.style.gridAutoRows = ''; + gridwrap.classList.remove('no-scroll'); + for (const tile of tiles) applyRect(tile); + return; + } + + // nothing below the fold: lock scrolling and make R rows fill the height exactly + gridwrap.classList.add('no-scroll'); + const R = Math.max(maxRow, Math.floor((wrapH + m.gap) / (baseCellH + m.gap))); + const cellH = (wrapH - (R - 1) * m.gap) / R; + grid.style.gridAutoRows = cellH + 'px'; + + for (const tile of tiles) { + applyRect(tile); // reset any previous stretch first + const bottom = tile.y + tile.h; + if (R - bottom <= FS_NEAR_ROWS && fsBottomMost(tile) && !fsSmallExempt(tile)) { + const entry = tileEls.get(tile.id); + if (entry) { + entry.el.style.gridRow = `${tile.y + 1} / span ${R - tile.y}`; + try { entry.instance?.onResize?.(tile.w, R - tile.y); } catch { /* module's problem */ } + } + } + } +} + +function clearFullscreenFit() { + grid.style.gridTemplateColumns = ''; + grid.style.gridAutoRows = ''; + gridwrap.classList.remove('no-scroll'); + for (const tile of tiles) { + applyRect(tile); + notifyResize(tile); + } +} + +fsBtn.addEventListener('click', () => { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen?.().catch(() => {}); + } else { + document.exitFullscreen?.().catch(() => {}); + } +}); + +document.addEventListener('fullscreenchange', () => { + const active = Boolean(document.fullscreenElement); + document.body.classList.toggle('fullscreen', active); + if (active) { + closeSettingsPopovers(); + menuHiddenBeforeFs = document.body.classList.contains('menu-hidden'); + setMenuHidden(true, { persist: false }); // the menu auto-hides; its notch brings it back + // let the collapsed menubar's space settle before measuring + requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit)); + } else { + setMenuHidden(menuHiddenBeforeFs, { persist: false }); + clearFullscreenFit(); + } +}); + +window.addEventListener('resize', () => { + if (document.body.classList.contains('fullscreen')) applyFullscreenFit(); +}); + +// the menubar collapse animates, so the grid's final height arrives late +topbar.addEventListener('transitionend', () => { + if (document.body.classList.contains('fullscreen')) applyFullscreenFit(); +}); + /* ── live updates from the admin page ───────────────────────────────── */ /* The server broadcasts `modules-changed` on /api/events whenever admin diff --git a/public/style.css b/public/style.css index 0cca1cf..f435d9c 100644 --- a/public/style.css +++ b/public/style.css @@ -39,12 +39,50 @@ button { font-family: inherit; } display: flex; align-items: center; gap: 10px; - padding: 8px 12px; + height: 46px; + padding: 0 12px; background: var(--panel); border-bottom: 1px solid var(--border); user-select: none; -webkit-user-select: none; + transition: height 0.22s ease, border-bottom-width 0.22s ease; } +/* collapsed by its notch (and automatically in fullscreen) */ +body.menu-hidden #topbar { + height: 0; + border-bottom-width: 0; + overflow: hidden; +} + +/* the menubar's own show/hide tab, same as the tile notches */ +#topbar-notch { + position: fixed; + top: var(--topbar-h, 46px); + left: 50%; + transform: translateX(-50%); + width: 54px; + height: 14px; + padding: 0; + background: var(--panel); + border: 1px solid var(--border); + border-top: none; + border-radius: 0 0 10px 10px; + cursor: pointer; + z-index: 60; + opacity: 0.35; + transition: opacity 0.15s ease, top 0.22s ease; +} +#topbar-notch:hover { opacity: 1; } +#topbar-notch::after { + content: ""; + display: block; + width: 20px; + height: 3px; + margin: 4px auto 0; + border-radius: 999px; + background: var(--muted); +} +#topbar-notch:hover::after { background: var(--text); } #brand { font-weight: 800; @@ -207,9 +245,15 @@ button { font-family: inherit; } user-select: none; -webkit-user-select: none; touch-action: none; + transition: height 0.22s ease, border-bottom-width 0.22s ease; } .tile.dragging .tile-head { cursor: grabbing; } -.tile.head-hidden .tile-head { display: none; } +/* hidden = collapsed, not removed, so the notch toggle animates */ +.tile.head-hidden .tile-head { + height: 0; + border-bottom-width: 0; + overflow: hidden; +} .tile-head .dot { flex: none; @@ -305,7 +349,7 @@ button { font-family: inherit; } cursor: pointer; z-index: 6; opacity: 0; - transition: opacity 0.15s ease, top 0.15s ease; + transition: opacity 0.15s ease, top 0.22s ease; } .tile:hover .tile-notch { opacity: 0.6; } .tile .tile-notch:hover { opacity: 1; } @@ -329,30 +373,43 @@ button { font-family: inherit; } overflow: hidden; } -.tile-resize { +.tile-resize, +.tile-resize-left { position: absolute; - right: 0; bottom: 0; width: 20px; height: 20px; - cursor: nwse-resize; touch-action: none; z-index: 5; color: var(--muted); - opacity: 0.6; + opacity: 0; /* corner indicators only show while hovering the tile */ + transition: opacity 0.15s ease; } -.tile-resize:hover { opacity: 1; color: var(--text); } -.tile-resize::after { +.tile-resize { right: 0; cursor: nwse-resize; } +.tile-resize-left { left: 0; cursor: nesw-resize; } +.tile:hover .tile-resize, +.tile:hover .tile-resize-left { opacity: 0.6; } +.tile .tile-resize:hover, +.tile .tile-resize-left:hover { opacity: 1; color: var(--text); } +.tile-resize::after, +.tile-resize-left::after { content: ""; position: absolute; - right: 4px; bottom: 4px; width: 9px; height: 9px; - border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; +} +.tile-resize::after { + right: 4px; + border-right: 2px solid currentColor; border-radius: 0 0 3px 0; } +.tile-resize-left::after { + left: 4px; + border-left: 2px solid currentColor; + border-radius: 0 0 0 3px; +} /* ── seam divider between adjacent tiles ───────────────────────────── */ @@ -389,6 +446,16 @@ button { font-family: inherit; } they're rebuilt for the new geometry on release. */ #grid.no-dividers .tile-divider { display: none; } +/* ── fullscreen mode ───────────────────────────────────────────────── */ + +/* Fullscreen is a viewing mode: the layout auto-fits the screen, so the + editing affordances (corner handles, seam pills) are parked. */ +body.fullscreen .tile-resize, +body.fullscreen .tile-resize-left, +body.fullscreen .tile-divider { display: none; } +body.fullscreen #grid { padding-bottom: 0; } +#gridwrap.no-scroll { overflow-y: hidden; } + /* status message a module can show while degraded (also used by the shell when a module fails to load) */ .tile-msg { @@ -544,5 +611,5 @@ button { font-family: inherit; } } @media (max-width: 560px) { - #topbar { padding: 6px 8px; flex-wrap: wrap; } + #topbar { padding: 0 8px; gap: 6px; } }