Shell 1.6.0: bars that tuck away, and every edge resizes #6
@@ -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