Shell 1.6.0: bars that tuck away, and every edge resizes
Three things about arranging a dashboard. **Auto-hide bars.** A new button left of ⛶ folds every tile's title bar away at once, for a windowed screen that should read like a wall display. Fullscreen already did this for watching; this is the windowed equivalent, asked for by hand. It starts off and saves with the layout, so a booth's named layout loads with the bars already tucked away. **A hidden bar is not a gone bar.** Folded away — by that switch or by a tile's own notch — the bar leaves the flow and parks just above its tile, so the module keeps the whole body and nothing reflows as the bar comes and goes. Point at the tile's top edge and it slides back in over the content, where its buttons click and its width drags the tile exactly as before. A dashboard can now be arranged with every bar out of the way, which is the point of hiding them. **Every edge resizes.** The top, left and right edges join the bottom one: each moves only itself, so the tile grows into the space beside it while the opposite edge stays put, stopping at a neighbour or at the module's own minimum. Where two tiles meet, that boundary already belongs to the seam pill between them — it moves both edges and leaves no gap — so the two tiles' side marks step aside there rather than offer a second, different control on the same pixels. One fix underneath: saving a named layout was dropping each tile's variant, title, minimum size and hidden-bar state, so a saved "Now" tile came back as the full Now/Next card and a solo timer came back with a full tile's settings. The layout file now carries them — and the new switch with them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6cca6d4d1a
commit
8e579ecb34
@@ -190,12 +190,15 @@ below. Nothing is installed without someone clicking.
|
|||||||
- **+** — add any enabled module; multiple tiles of the same module are
|
- **+** — add any enabled module; multiple tiles of the same module are
|
||||||
fine (e.g. two transcripts filtered to different channels).
|
fine (e.g. two transcripts filtered to different channels).
|
||||||
- Drag a tile by its **title bar** to move it; drag either **bottom
|
- Drag a tile by its **title bar** to move it; drag either **bottom
|
||||||
corner** to resize; **✕** removes it. Hover a tile and a small pill
|
corner** to resize; **✕** removes it. Hover a tile and a small mark
|
||||||
appears on its **bottom edge** — drag it to change just the height. If
|
appears on each **edge** — drag one to move just that edge, so the tile
|
||||||
another tile sits directly below, the pill moves the shared edge instead,
|
grows or shrinks into the space beside it while the opposite edge stays
|
||||||
trading rows between the two so the column keeps its total height. Tiles
|
put. The bottom edge is the exception: when another tile sits directly
|
||||||
sit flush — when two share a vertical edge, hover the seam and the same
|
below, it moves the shared edge instead, trading rows between the two so
|
||||||
kind of pill appears; drag it to trade width (macOS split-view style).
|
the column keeps its total height. Tiles sit flush — when two share a
|
||||||
|
vertical edge, hover the seam and a pill appears; drag it to trade width
|
||||||
|
(macOS split-view style). That seam belongs to the pill, so the two tiles'
|
||||||
|
own side marks step aside there.
|
||||||
- **Fullscreen** (the ⛶ button) is a viewing mode: the menu bar hides (its
|
- **Fullscreen** (the ⛶ button) is a viewing mode: the menu bar hides (its
|
||||||
green lip at the top-left brings it back, minus the Admin button), the
|
green lip at the top-left brings it back, minus the Admin button), the
|
||||||
occupied columns stretch to the full width, and every column runs to the
|
occupied columns stretch to the full width, and every column runs to the
|
||||||
@@ -207,7 +210,16 @@ below. Nothing is installed without someone clicking.
|
|||||||
its normal row height and scrolls.
|
its normal row height and scrolls.
|
||||||
- A tile's actions (menus, text size, clear…) live in its **title bar**;
|
- A tile's actions (menus, text size, clear…) live in its **title bar**;
|
||||||
the small **notch** on the bar's bottom edge hides/shows the whole bar
|
the small **notch** on the bar's bottom edge hides/shows the whole bar
|
||||||
for a clean wall-display look. The menu bar's handle is the **ProdDash
|
for a clean wall-display look. A hidden bar is not gone: point at the
|
||||||
|
tile's top edge and it slides back in over the content, where its buttons
|
||||||
|
click and its width drags the tile exactly as before — so a dashboard can
|
||||||
|
be arranged with every bar out of the way.
|
||||||
|
- **Auto-hide bars** (the button left of ⛶) folds every title bar away at
|
||||||
|
once, for a windowed screen that should read like a wall display. It is
|
||||||
|
off on a fresh dashboard and saves with the layout — including a named
|
||||||
|
layout on the server, so a booth loads its screen with the bars already
|
||||||
|
tucked away. Fullscreen folds the bars away regardless; this is the
|
||||||
|
windowed equivalent, asked for by hand. The menu bar's handle is the **ProdDash
|
||||||
name itself**, wrapped in a translucent green pill at the left — click it
|
name itself**, wrapped in a translucent green pill at the left — click it
|
||||||
to hide the bar; the slim green lip it leaves at the top-left edge brings
|
to hide the bar; the slim green lip it leaves at the top-left edge brings
|
||||||
the bar back. It stays at the left so it never sits on top of a tile's
|
the bar back. It stays at the left so it never sits on top of a tile's
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proddash",
|
"name": "proddash",
|
||||||
"version": "1.5.0",
|
"version": "1.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
|
|||||||
@@ -19,6 +19,14 @@
|
|||||||
|
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
|
|
||||||
|
<button class="tb-btn icon" id="autohide-btn" title="Auto-hide bars" aria-label="Auto-hide tile title bars" aria-pressed="false">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M9 6l3-3 3 3"/>
|
||||||
|
<rect x="4" y="10" width="16" height="10" rx="1.5"/>
|
||||||
|
<path d="M4 14h16"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button class="tb-btn icon" id="fs-btn" title="Fullscreen" aria-label="Fullscreen">
|
<button class="tb-btn icon" id="fs-btn" title="Fullscreen" aria-label="Fullscreen">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
<path d="M4 9V5a1 1 0 0 1 1-1h4"/><path d="M20 9V5a1 1 0 0 0-1-1h-4"/>
|
<path d="M4 9V5a1 1 0 0 1 1-1h4"/><path d="M20 9V5a1 1 0 0 0-1-1h-4"/>
|
||||||
|
|||||||
+204
-10
@@ -24,6 +24,7 @@ function toast(msg, isError = false) {
|
|||||||
toastTimer = setTimeout(() => { toastEl.hidden = true; }, 3200);
|
toastTimer = setTimeout(() => { toastEl.hidden = true; }, 3200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const autoHideBtn = document.getElementById('autohide-btn');
|
||||||
const modalBackdrop = document.getElementById('modal-backdrop');
|
const modalBackdrop = document.getElementById('modal-backdrop');
|
||||||
const modalEl = document.getElementById('modal');
|
const modalEl = document.getElementById('modal');
|
||||||
|
|
||||||
@@ -161,18 +162,25 @@ async function loadRegistry() {
|
|||||||
let tiles = [];
|
let tiles = [];
|
||||||
const tileEls = new Map(); // tile id -> { el, body, dot, titleEl, instance }
|
const tileEls = new Map(); // tile id -> { el, body, dot, titleEl, instance }
|
||||||
|
|
||||||
|
/* Whether tile title bars fold away until you point at them. It is part of
|
||||||
|
how a dashboard is arranged rather than a preference of this browser, so
|
||||||
|
it saves with the layout — including a named layout on the server — and a
|
||||||
|
fresh dashboard starts with the bars showing. Fullscreen folds every bar
|
||||||
|
away regardless; this is the windowed equivalent, asked for by hand. */
|
||||||
|
let autoHideHeads = false;
|
||||||
|
|
||||||
function saveLayout() {
|
function saveLayout() {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(LS_LAYOUT, JSON.stringify({ v: 1, tiles }));
|
localStorage.setItem(LS_LAYOUT, JSON.stringify({ v: 1, autoHideHeads, tiles }));
|
||||||
} catch { /* private mode / full — layout just won't persist */ }
|
} catch { /* private mode / full — layout just won't persist */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadLayoutFromStorage() {
|
function loadLayoutFromStorage() {
|
||||||
try {
|
try {
|
||||||
const raw = JSON.parse(localStorage.getItem(LS_LAYOUT) || 'null');
|
const raw = JSON.parse(localStorage.getItem(LS_LAYOUT) || 'null');
|
||||||
if (raw && Array.isArray(raw.tiles)) return raw.tiles;
|
if (raw && Array.isArray(raw.tiles)) return raw;
|
||||||
} catch { /* corrupted — start fresh */ }
|
} catch { /* corrupted — start fresh */ }
|
||||||
return [];
|
return { tiles: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeTile(t) {
|
function sanitizeTile(t) {
|
||||||
@@ -249,6 +257,44 @@ function minSizeOf(tile) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── title bars that fold away ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* A bar is "off" when it is folded out of sight but the tile is still being
|
||||||
|
worked on — either this tile's own notch hid it, or the dashboard has
|
||||||
|
auto-hide switched on. Fullscreen is not one of these: there the bars fold
|
||||||
|
away for watching, and arranging is switched off entirely. */
|
||||||
|
|
||||||
|
/** How close to the tile's top edge the pointer opens a folded-away bar. */
|
||||||
|
const HEAD_REVEAL_PX = 22;
|
||||||
|
/** How far down it may wander before that bar folds away again (bar + a little). */
|
||||||
|
const HEAD_HOLD_PX = 38;
|
||||||
|
|
||||||
|
function headIsOff(tile) {
|
||||||
|
if (document.body.classList.contains('fullscreen')) return false;
|
||||||
|
return autoHideHeads || Boolean(tile.headHidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHeadState(tile, el = tileEls.get(tile.id)?.el) {
|
||||||
|
if (!el) return;
|
||||||
|
const off = headIsOff(tile);
|
||||||
|
el.classList.toggle('head-off', off);
|
||||||
|
if (!off) el.classList.remove('head-peek');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-read every tile's bar — after the toggle, or after fullscreen changes. */
|
||||||
|
function applyAllHeadStates() {
|
||||||
|
for (const tile of tiles) applyHeadState(tile);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAutoHideHeads(on, { save = true } = {}) {
|
||||||
|
autoHideHeads = Boolean(on);
|
||||||
|
document.body.classList.toggle('autohide-heads', autoHideHeads);
|
||||||
|
autoHideBtn?.classList.toggle('is-on', autoHideHeads);
|
||||||
|
autoHideBtn?.setAttribute('aria-pressed', autoHideHeads ? 'true' : 'false');
|
||||||
|
applyAllHeadStates();
|
||||||
|
if (save) saveLayout();
|
||||||
|
}
|
||||||
|
|
||||||
/* ── tile DOM ───────────────────────────────────────────────────────── */
|
/* ── tile DOM ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const CLOSE_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>';
|
const CLOSE_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>';
|
||||||
@@ -320,6 +366,19 @@ function buildTile(tile) {
|
|||||||
resizeBottom.className = 'tile-resize-bottom';
|
resizeBottom.className = 'tile-resize-bottom';
|
||||||
resizeBottom.title = 'Drag to adjust height';
|
resizeBottom.title = 'Drag to adjust height';
|
||||||
|
|
||||||
|
// The other three edges move only themselves: the top edge changes where
|
||||||
|
// the tile starts, the sides change how wide it is. Each stops where a
|
||||||
|
// neighbour or the tile's own minimum says it must.
|
||||||
|
const edgeGrip = (cls, title) => {
|
||||||
|
const gripEl = document.createElement('div');
|
||||||
|
gripEl.className = cls;
|
||||||
|
gripEl.title = title;
|
||||||
|
return gripEl;
|
||||||
|
};
|
||||||
|
const edgeTop = edgeGrip('tile-resize-top', 'Drag to adjust height');
|
||||||
|
const edgeRight = edgeGrip('tile-resize-right', 'Drag to adjust width');
|
||||||
|
const edgeLeft = edgeGrip('tile-resize-side', 'Drag to adjust width');
|
||||||
|
|
||||||
// notch on the title bar's bottom edge: hides/shows the bar
|
// notch on the title bar's bottom edge: hides/shows the bar
|
||||||
const notch = document.createElement('button');
|
const notch = document.createElement('button');
|
||||||
notch.className = 'tile-notch';
|
notch.className = 'tile-notch';
|
||||||
@@ -333,19 +392,41 @@ function buildTile(tile) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tile.headHidden = !tile.headHidden;
|
tile.headHidden = !tile.headHidden;
|
||||||
el.classList.toggle('head-hidden', tile.headHidden);
|
applyHeadState(tile, el);
|
||||||
saveLayout();
|
saveLayout();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A folded-away bar is still reachable: point at the tile's top edge and it
|
||||||
|
// slides in over the content, where its buttons click and its whole width
|
||||||
|
// drags the tile exactly as when it is showing.
|
||||||
|
el.addEventListener('pointermove', (ev) => {
|
||||||
|
if (!headIsOff(tile)) return;
|
||||||
|
// Mid-drag the tile moves under the pointer, and mid-resize the edges do
|
||||||
|
// — neither is a moment to take the bar away.
|
||||||
|
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
|
||||||
|
const y = ev.clientY - el.getBoundingClientRect().top;
|
||||||
|
const shown = el.classList.contains('head-peek');
|
||||||
|
// It takes less to hold the bar open than to open it, so a bar that
|
||||||
|
// appears under the pointer cannot flicker straight back out.
|
||||||
|
if (!shown && y <= HEAD_REVEAL_PX) el.classList.add('head-peek');
|
||||||
|
else if (shown && y > HEAD_HOLD_PX) el.classList.remove('head-peek');
|
||||||
|
});
|
||||||
|
|
||||||
// A peeked title bar stays while the pointer is on its tile, and starts
|
// A peeked title bar stays while the pointer is on its tile, and starts
|
||||||
// counting down as soon as it leaves.
|
// counting down as soon as it leaves.
|
||||||
el.addEventListener('pointerenter', () => clearPeekTimer(tile.id));
|
el.addEventListener('pointerenter', () => clearPeekTimer(tile.id));
|
||||||
el.addEventListener('pointerleave', () => {
|
el.addEventListener('pointerleave', () => {
|
||||||
if (el.classList.contains('head-peek')) schedulePeekHide(tile.id, el);
|
if (!el.classList.contains('head-peek')) return;
|
||||||
|
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
|
||||||
|
// Windowed, the bar is a hover-reveal and goes the moment the pointer
|
||||||
|
// does. In fullscreen it was opened deliberately from the notch, so it
|
||||||
|
// lingers (PEEK_HIDE_MS) before folding away again.
|
||||||
|
if (document.body.classList.contains('fullscreen')) schedulePeekHide(tile.id, el);
|
||||||
|
else el.classList.remove('head-peek');
|
||||||
});
|
});
|
||||||
|
|
||||||
el.append(head, body, resize, resizeLeft, resizeBottom, notch);
|
el.append(head, body, resize, resizeLeft, resizeBottom, edgeTop, edgeRight, edgeLeft, notch);
|
||||||
el.classList.toggle('head-hidden', Boolean(tile.headHidden));
|
applyHeadState(tile, el);
|
||||||
grid.appendChild(el);
|
grid.appendChild(el);
|
||||||
|
|
||||||
const entry = { el, body, dot, titleEl: title, controls, instance: null };
|
const entry = { el, body, dot, titleEl: title, controls, instance: null };
|
||||||
@@ -356,6 +437,9 @@ function buildTile(tile) {
|
|||||||
wireResize(tile, el, resize, false);
|
wireResize(tile, el, resize, false);
|
||||||
wireResize(tile, el, resizeLeft, true);
|
wireResize(tile, el, resizeLeft, true);
|
||||||
wireBottomResize(tile, el, resizeBottom);
|
wireBottomResize(tile, el, resizeBottom);
|
||||||
|
wireEdgeResize(tile, el, edgeTop, 'n');
|
||||||
|
wireEdgeResize(tile, el, edgeRight, 'e');
|
||||||
|
wireEdgeResize(tile, el, edgeLeft, 'w');
|
||||||
|
|
||||||
mountModule(tile);
|
mountModule(tile);
|
||||||
return entry;
|
return entry;
|
||||||
@@ -486,6 +570,80 @@ function wireResize(tile, el, handle, fromLeft) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── drag the top, left or right edge ───────────────────────────────── */
|
||||||
|
|
||||||
|
/* One edge moves and the opposite one stays where it is, so the tile grows
|
||||||
|
or shrinks into the space beside it. Unlike the bottom-edge pill these do
|
||||||
|
not trade rows or columns with a neighbour: the edge simply stops when it
|
||||||
|
meets one, which is what "resize this tile" should mean. */
|
||||||
|
|
||||||
|
function wireEdgeResize(tile, el, handle, edge) {
|
||||||
|
handle.addEventListener('pointerdown', (ev) => {
|
||||||
|
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
|
||||||
|
if (document.body.classList.contains('fullscreen')) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
closeSettingsPopovers();
|
||||||
|
handle.setPointerCapture(ev.pointerId);
|
||||||
|
const m = cellMetrics();
|
||||||
|
const sx = ev.clientX;
|
||||||
|
const sy = ev.clientY;
|
||||||
|
const startX = tile.x;
|
||||||
|
const startY = tile.y;
|
||||||
|
const startW = tile.w;
|
||||||
|
const startH = tile.h;
|
||||||
|
const min = minSizeOf(tile);
|
||||||
|
el.classList.add('resizing');
|
||||||
|
grid.classList.add('no-dividers');
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
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));
|
||||||
|
let cand;
|
||||||
|
if (edge === 'n') {
|
||||||
|
// the top edge moves; the bottom edge stays put
|
||||||
|
const y = Math.max(0, Math.min(startY + startH - min.h, startY + dRows));
|
||||||
|
cand = { x: tile.x, y, w: tile.w, h: startY + startH - y };
|
||||||
|
} else if (edge === 'w') {
|
||||||
|
// the left edge moves; the right edge stays put
|
||||||
|
const x = Math.max(0, Math.min(startX + startW - min.w, startX + dCols));
|
||||||
|
cand = { x, y: tile.y, w: startX + startW - x, h: tile.h };
|
||||||
|
} else {
|
||||||
|
cand = {
|
||||||
|
x: tile.x,
|
||||||
|
y: tile.y,
|
||||||
|
w: Math.max(min.w, Math.min(COLS - tile.x, startW + dCols)),
|
||||||
|
h: tile.h,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (cand.x === tile.x && cand.y === tile.y && cand.w === tile.w && cand.h === tile.h) return;
|
||||||
|
if (!isFree(cand, tile.id)) return;
|
||||||
|
tile.x = cand.x;
|
||||||
|
tile.y = cand.y;
|
||||||
|
tile.w = cand.w;
|
||||||
|
tile.h = cand.h;
|
||||||
|
applyRect(tile);
|
||||||
|
changed = true;
|
||||||
|
notifyResize(tile);
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
handle.removeEventListener('pointermove', onMove);
|
||||||
|
handle.removeEventListener('pointerup', onUp);
|
||||||
|
handle.removeEventListener('pointercancel', onUp);
|
||||||
|
el.classList.remove('resizing');
|
||||||
|
grid.classList.remove('no-dividers');
|
||||||
|
if (changed) {
|
||||||
|
saveLayout();
|
||||||
|
rebuildDividers();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
handle.addEventListener('pointermove', onMove);
|
||||||
|
handle.addEventListener('pointerup', onUp);
|
||||||
|
handle.addEventListener('pointercancel', onUp);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* ── drag the bottom edge: height only ──────────────────────────────── */
|
/* ── drag the bottom edge: height only ──────────────────────────────── */
|
||||||
|
|
||||||
/* The pill on a tile's bottom edge moves that edge. When other tiles sit
|
/* The pill on a tile's bottom edge moves that edge. When other tiles sit
|
||||||
@@ -594,6 +752,29 @@ function rebuildDividers() {
|
|||||||
buildDivider(L, R, top, bottom);
|
buildDivider(L, R, top, bottom);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
markSeamSides();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Where two tiles meet, the seam pill between them already owns that
|
||||||
|
boundary — and it is the better tool there, since it moves both edges at
|
||||||
|
once and leaves no gap. A side grip on the same pixels would be a second
|
||||||
|
control doing something else, so it steps aside and the tile is resized
|
||||||
|
from that side by the pill. Free sides keep their grip. */
|
||||||
|
function markSeamSides() {
|
||||||
|
const rowsOverlap = (a, b) => Math.min(a.y + a.h, b.y + b.h) > Math.max(a.y, b.y);
|
||||||
|
for (const t of tiles) {
|
||||||
|
const el = tileEls.get(t.id)?.el;
|
||||||
|
if (!el) continue;
|
||||||
|
let left = false;
|
||||||
|
let right = false;
|
||||||
|
for (const o of tiles) {
|
||||||
|
if (o === t || !rowsOverlap(o, t)) continue;
|
||||||
|
if (o.x + o.w === t.x) left = true;
|
||||||
|
if (o.x === t.x + t.w) right = true;
|
||||||
|
}
|
||||||
|
el.classList.toggle('seam-left', left);
|
||||||
|
el.classList.toggle('seam-right', right);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every pair of tiles that meets at this column boundary, in any row. */
|
/** Every pair of tiles that meets at this column boundary, in any row. */
|
||||||
@@ -1712,9 +1893,13 @@ function remountTile(tile) {
|
|||||||
|
|
||||||
/* ── layout apply / reset ───────────────────────────────────────────── */
|
/* ── layout apply / reset ───────────────────────────────────────────── */
|
||||||
|
|
||||||
function renderLayout(list) {
|
function renderLayout(layout) {
|
||||||
|
// Callers pass a whole layout; an array on its own is still understood so
|
||||||
|
// "reset" can simply hand over no tiles.
|
||||||
|
const list = Array.isArray(layout) ? layout : (layout?.tiles || []);
|
||||||
clearAllTiles();
|
clearAllTiles();
|
||||||
tiles = list.map(sanitizeTile);
|
tiles = list.map(sanitizeTile);
|
||||||
|
if (!Array.isArray(layout)) setAutoHideHeads(Boolean(layout?.autoHideHeads), { save: false });
|
||||||
for (const tile of tiles) buildTile(tile);
|
for (const tile of tiles) buildTile(tile);
|
||||||
refreshEmptyState();
|
refreshEmptyState();
|
||||||
rebuildDividers();
|
rebuildDividers();
|
||||||
@@ -1794,7 +1979,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
|||||||
const res = await fetch('/api/layouts/' + encodeURIComponent(name), {
|
const res = await fetch('/api/layouts/' + encodeURIComponent(name), {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ layout: { tiles } }),
|
body: JSON.stringify({ layout: { autoHideHeads, tiles } }),
|
||||||
});
|
});
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')');
|
if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')');
|
||||||
@@ -1838,7 +2023,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
|||||||
if (!res.ok) throw new Error(body.error || 'Load failed (' + res.status + ')');
|
if (!res.ok) throw new Error(body.error || 'Load failed (' + res.status + ')');
|
||||||
// Loading copies the layout into this browser's own state — it
|
// Loading copies the layout into this browser's own state — it
|
||||||
// does not live-link browsers together.
|
// does not live-link browsers together.
|
||||||
renderLayout(body.layout.tiles || []);
|
renderLayout(body.layout);
|
||||||
saveLayout();
|
saveLayout();
|
||||||
toast(`Loaded “${l.name}”`);
|
toast(`Loaded “${l.name}”`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -2026,6 +2211,13 @@ function clearFullscreenFit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
autoHideBtn?.addEventListener('click', () => {
|
||||||
|
setAutoHideHeads(!autoHideHeads);
|
||||||
|
toast(autoHideHeads
|
||||||
|
? 'Title bars hide until you point at a tile\u2019s top edge'
|
||||||
|
: 'Title bars stay put');
|
||||||
|
});
|
||||||
|
|
||||||
fsBtn.addEventListener('click', () => {
|
fsBtn.addEventListener('click', () => {
|
||||||
if (!document.fullscreenElement) {
|
if (!document.fullscreenElement) {
|
||||||
document.documentElement.requestFullscreen?.().catch(() => {});
|
document.documentElement.requestFullscreen?.().catch(() => {});
|
||||||
@@ -2039,6 +2231,7 @@ document.addEventListener('fullscreenchange', () => {
|
|||||||
document.body.classList.toggle('fullscreen', active);
|
document.body.classList.toggle('fullscreen', active);
|
||||||
if (active) {
|
if (active) {
|
||||||
closeSettingsPopovers();
|
closeSettingsPopovers();
|
||||||
|
applyAllHeadStates(); // fullscreen has its own rule for the bars
|
||||||
menuHiddenBeforeFs = document.body.classList.contains('menu-hidden');
|
menuHiddenBeforeFs = document.body.classList.contains('menu-hidden');
|
||||||
setMenuHidden(true, { persist: false }); // the menu auto-hides; its notch brings it back
|
setMenuHidden(true, { persist: false }); // the menu auto-hides; its notch brings it back
|
||||||
// let the collapsed menubar's space settle before measuring
|
// let the collapsed menubar's space settle before measuring
|
||||||
@@ -2047,6 +2240,7 @@ document.addEventListener('fullscreenchange', () => {
|
|||||||
setMenuHidden(menuHiddenBeforeFs, { persist: false });
|
setMenuHidden(menuHiddenBeforeFs, { persist: false });
|
||||||
clearPeeks();
|
clearPeeks();
|
||||||
clearFullscreenFit();
|
clearFullscreenFit();
|
||||||
|
applyAllHeadStates();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+109
-6
@@ -126,6 +126,14 @@ body.menu-hidden #topbar {
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
.tb-btn.icon svg { display: block; }
|
.tb-btn.icon svg { display: block; }
|
||||||
|
/* A top-bar button that is a switch rather than an action lights up the same
|
||||||
|
way an open tile button does, so "on" reads the same everywhere. */
|
||||||
|
.tb-btn.is-on,
|
||||||
|
.tb-btn.is-on:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-text);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── dropdown menus ────────────────────────────────────────────────── */
|
/* ── dropdown menus ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -270,12 +278,37 @@ body.menu-hidden #topbar {
|
|||||||
transition: height 0.22s ease, border-bottom-width 0.22s ease;
|
transition: height 0.22s ease, border-bottom-width 0.22s ease;
|
||||||
}
|
}
|
||||||
.tile.dragging .tile-head { cursor: grabbing; }
|
.tile.dragging .tile-head { cursor: grabbing; }
|
||||||
/* hidden = collapsed, not removed, so the notch toggle animates */
|
|
||||||
.tile.head-hidden .tile-head {
|
/* Folded away: the bar leaves the flow and parks just above the tile, so the
|
||||||
height: 0;
|
module keeps the whole body and nothing reflows as the bar comes and goes.
|
||||||
border-bottom-width: 0;
|
Pointing at the tile's top edge slides it back in over the content
|
||||||
overflow: hidden;
|
(shell.js adds .head-peek) — and because it is the same element, its
|
||||||
|
buttons click and its width drags the tile just as they always did. */
|
||||||
|
body:not(.fullscreen) .tile.head-off > .tile-head {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 7;
|
||||||
|
background: color-mix(in srgb, var(--panel) 90%, transparent);
|
||||||
|
-webkit-backdrop-filter: blur(7px);
|
||||||
|
backdrop-filter: blur(7px);
|
||||||
|
transform: translateY(-100%);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: transform 0.18s ease, opacity 0.18s ease;
|
||||||
}
|
}
|
||||||
|
body:not(.fullscreen) .tile.head-off.head-peek > .tile-head {
|
||||||
|
transform: none;
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
body:not(.fullscreen) .tile.is-tinted.head-off > .tile-head {
|
||||||
|
background: color-mix(in srgb, var(--tile-tint) 20%, var(--panel));
|
||||||
|
}
|
||||||
|
/* Auto-hide already reveals on hover, so the notch would be a dead control;
|
||||||
|
a bar this tile hid by itself keeps its notch, which is how it comes back. */
|
||||||
|
body.autohide-heads .tile-notch { display: none; }
|
||||||
|
|
||||||
.tile-head .dot {
|
.tile-head .dot {
|
||||||
flex: none;
|
flex: none;
|
||||||
@@ -395,7 +428,7 @@ body.menu-hidden #topbar {
|
|||||||
}
|
}
|
||||||
.tile:hover .tile-notch { opacity: 0.6; }
|
.tile:hover .tile-notch { opacity: 0.6; }
|
||||||
.tile .tile-notch:hover { opacity: 1; }
|
.tile .tile-notch:hover { opacity: 1; }
|
||||||
.tile.head-hidden .tile-notch { top: 0; }
|
body:not(.fullscreen) .tile.head-off .tile-notch { top: 0; }
|
||||||
.tile-notch::after {
|
.tile-notch::after {
|
||||||
content: "";
|
content: "";
|
||||||
display: block;
|
display: block;
|
||||||
@@ -496,6 +529,72 @@ body.fullscreen .tile.head-peek .tile-notch { top: 32px; }
|
|||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
border-bottom: 2px solid currentColor;
|
border-bottom: 2px solid currentColor;
|
||||||
}
|
}
|
||||||
|
/* the other three edges: the same mark, turned to face its own side. Each
|
||||||
|
stops short of the corners so the corner grips keep their full 20px. */
|
||||||
|
.tile-resize-top,
|
||||||
|
.tile-resize-right,
|
||||||
|
.tile-resize-side {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 6;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
touch-action: none;
|
||||||
|
color: var(--muted);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.tile-resize-top {
|
||||||
|
top: 0;
|
||||||
|
left: 24px;
|
||||||
|
right: 24px;
|
||||||
|
height: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
cursor: ns-resize;
|
||||||
|
/* Above a revealed title bar: the bar slides in under the pointer here, and
|
||||||
|
this strip has to stay the top edge rather than become part of it. */
|
||||||
|
z-index: 8;
|
||||||
|
}
|
||||||
|
.tile-resize-right,
|
||||||
|
.tile-resize-side {
|
||||||
|
top: 24px;
|
||||||
|
bottom: 24px;
|
||||||
|
width: 10px;
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
.tile-resize-right { right: 0; justify-content: flex-end; }
|
||||||
|
.tile-resize-side { left: 0; justify-content: flex-start; }
|
||||||
|
.tile-resize-top::after,
|
||||||
|
.tile-resize-right::after,
|
||||||
|
.tile-resize-side::after {
|
||||||
|
content: "";
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.tile-resize-top::after {
|
||||||
|
width: 44px;
|
||||||
|
max-width: 60%;
|
||||||
|
height: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
border-top: 2px solid currentColor;
|
||||||
|
}
|
||||||
|
.tile-resize-right::after,
|
||||||
|
.tile-resize-side::after {
|
||||||
|
width: 0;
|
||||||
|
height: 44px;
|
||||||
|
max-height: 60%;
|
||||||
|
}
|
||||||
|
.tile-resize-right::after { margin-right: 4px; border-right: 2px solid currentColor; }
|
||||||
|
.tile-resize-side::after { margin-left: 4px; border-left: 2px solid currentColor; }
|
||||||
|
/* A side that meets another tile belongs to the seam pill between them. */
|
||||||
|
.tile.seam-left .tile-resize-side,
|
||||||
|
.tile.seam-right .tile-resize-right { display: none; }
|
||||||
|
.tile:hover .tile-resize-top,
|
||||||
|
.tile:hover .tile-resize-right,
|
||||||
|
.tile:hover .tile-resize-side { opacity: 0.6; }
|
||||||
|
.tile .tile-resize-top:hover,
|
||||||
|
.tile .tile-resize-right:hover,
|
||||||
|
.tile .tile-resize-side:hover { opacity: 1; color: var(--text); }
|
||||||
|
|
||||||
.tile:hover .tile-resize-bottom { opacity: 0.6; }
|
.tile:hover .tile-resize-bottom { opacity: 0.6; }
|
||||||
.tile .tile-resize-bottom:hover,
|
.tile .tile-resize-bottom:hover,
|
||||||
.tile.resizing .tile-resize-bottom { opacity: 1; color: var(--text); }
|
.tile.resizing .tile-resize-bottom { opacity: 1; color: var(--text); }
|
||||||
@@ -543,7 +642,11 @@ body.fullscreen .tile.head-peek .tile-notch { top: 32px; }
|
|||||||
body.fullscreen .tile-resize,
|
body.fullscreen .tile-resize,
|
||||||
body.fullscreen .tile-resize-left,
|
body.fullscreen .tile-resize-left,
|
||||||
body.fullscreen .tile-resize-bottom,
|
body.fullscreen .tile-resize-bottom,
|
||||||
|
body.fullscreen .tile-resize-top,
|
||||||
|
body.fullscreen .tile-resize-right,
|
||||||
|
body.fullscreen .tile-resize-side,
|
||||||
body.fullscreen .tile-divider,
|
body.fullscreen .tile-divider,
|
||||||
|
body.fullscreen #autohide-btn,
|
||||||
body.fullscreen #admin-link { display: none; }
|
body.fullscreen #admin-link { display: none; }
|
||||||
body.fullscreen #grid { padding-bottom: 0; }
|
body.fullscreen #grid { padding-bottom: 0; }
|
||||||
#gridwrap.no-scroll { overflow-y: hidden; }
|
#gridwrap.no-scroll { overflow-y: hidden; }
|
||||||
|
|||||||
@@ -1394,7 +1394,11 @@ function layoutPath(name) {
|
|||||||
function sanitizeLayout(raw) {
|
function sanitizeLayout(raw) {
|
||||||
const tiles = Array.isArray(raw?.tiles) ? raw.tiles.slice(0, 100) : [];
|
const tiles = Array.isArray(raw?.tiles) ? raw.tiles.slice(0, 100) : [];
|
||||||
return {
|
return {
|
||||||
tiles: tiles.map((t) => ({
|
// Whether tile title bars auto-hide is part of how a dashboard is
|
||||||
|
// arranged, so it travels with the layout like the tiles themselves.
|
||||||
|
autoHideHeads: Boolean(raw?.autoHideHeads),
|
||||||
|
tiles: tiles.map((t) => {
|
||||||
|
const tile = {
|
||||||
id: String(t?.id || ''),
|
id: String(t?.id || ''),
|
||||||
module: String(t?.module || ''),
|
module: String(t?.module || ''),
|
||||||
x: Number(t?.x) || 0,
|
x: Number(t?.x) || 0,
|
||||||
@@ -1402,7 +1406,18 @@ function sanitizeLayout(raw) {
|
|||||||
w: Number(t?.w) || 1,
|
w: Number(t?.w) || 1,
|
||||||
h: Number(t?.h) || 1,
|
h: Number(t?.h) || 1,
|
||||||
settings: t?.settings && typeof t.settings === 'object' ? t.settings : {},
|
settings: t?.settings && typeof t.settings === 'object' ? t.settings : {},
|
||||||
})),
|
// Which picker entry the tile came from, and what it was called.
|
||||||
|
// Without these a saved "Now" tile comes back as the full Now/Next
|
||||||
|
// card, and a solo timer comes back with a full tile's settings.
|
||||||
|
variant: String(t?.variant || ''),
|
||||||
|
title: String(t?.title || ''),
|
||||||
|
headHidden: Boolean(t?.headHidden),
|
||||||
|
};
|
||||||
|
if (Number(t?.minSize?.w) > 0 && Number(t?.minSize?.h) > 0) {
|
||||||
|
tile.minSize = { w: Number(t.minSize.w), h: Number(t.minSize.h) };
|
||||||
|
}
|
||||||
|
return tile;
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user