Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0be37796b |
@@ -189,17 +189,13 @@ 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; **✕** removes it.
|
- Drag a tile by its **title bar** to move it; drag either **bottom
|
||||||
- **Resize from any edge or bottom corner**, the way a window resizes:
|
corner** to resize; **✕** removes it. Hover a tile and a small pill
|
||||||
nothing is drawn on a tile to mark the spots — the cursor over an edge is
|
appears on its **bottom edge** — drag it to change just the height. If
|
||||||
what says it can be dragged. An edge moves only itself, so the tile grows
|
another tile sits directly below, the pill moves the shared edge instead,
|
||||||
or shrinks into the space beside it while the opposite edge stays put,
|
trading rows between the two so the column keeps its total height. Tiles
|
||||||
stopping at a neighbour or at the module's own minimum size. Two
|
sit flush — when two share a vertical edge, hover the seam and the same
|
||||||
exceptions, both of which move a *shared* edge instead: with another tile
|
kind of pill appears; drag it to trade width (macOS split-view style).
|
||||||
directly below, the bottom edge trades rows between the two so the column
|
|
||||||
keeps its total height; and where two tiles meet side by side, the seam
|
|
||||||
between them trades width (macOS split-view style), showing itself only
|
|
||||||
while you drag it.
|
|
||||||
- **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
|
||||||
@@ -209,21 +205,9 @@ below. Nothing is installed without someone clicking.
|
|||||||
gap; alone in its columns it stays as it is. Nothing is saved — the layout
|
gap; alone in its columns it stays as it is. Nothing is saved — the layout
|
||||||
reverts when you leave fullscreen. A layout deeper than the screen keeps
|
reverts when you leave fullscreen. A layout deeper than the screen keeps
|
||||||
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**;
|
||||||
- **Auto-hide bars** (the button left of ⛶) folds every title bar away, for
|
the small **notch** on the bar's bottom edge hides/shows the whole bar
|
||||||
a windowed screen that should read like a wall display. A folded bar is
|
for a clean wall-display look. The menu bar's handle is the **ProdDash
|
||||||
not a gone bar: 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.
|
|
||||||
The switch 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 every bar regardless, and
|
|
||||||
the same hover brings one back there too.
|
|
||||||
- The **pin** beside a tile's title keeps *that* tile's bar showing while
|
|
||||||
auto-hide is on — for the one or two tiles whose controls are wanted at
|
|
||||||
a glance. It turns the accent colour when it is on and saves with the
|
|
||||||
layout. Fullscreen still folds a pinned bar with the rest.
|
|
||||||
- The menu bar's own 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
|
||||||
|
|||||||
@@ -367,10 +367,9 @@ fallback), never a "Text size" field in `instanceSchema`; a timestamps or
|
|||||||
newest-first toggle in the header means no such switch in the gear. The
|
newest-first toggle in the header means no such switch in the gear. The
|
||||||
gear is for display choices that have no button of their own.
|
gear is for display choices that have no button of their own.
|
||||||
|
|
||||||
One more thing to know: the whole title bar folds away when the dashboard
|
One more thing to know: the user can hide the whole title bar with the
|
||||||
auto-hides it, and always in fullscreen (a clean-view mode for wall
|
small notch on its bottom edge (a clean-view mode for wall displays). Your
|
||||||
displays) — it comes back only while the pointer is at the tile's top edge.
|
controls disappear with it, so nothing essential — status, reconnects,
|
||||||
Your controls fold away with it, so nothing essential — status, reconnects,
|
|
||||||
live data — may exist *only* as a header control.
|
live data — may exist *only* as a header control.
|
||||||
|
|
||||||
`sse(path, handlers)` handlers:
|
`sse(path, handlers)` handlers:
|
||||||
|
|||||||
+23
-12
@@ -141,13 +141,17 @@ const sendTarget = (() => {
|
|||||||
/**
|
/**
|
||||||
* Where a send goes, given the configured channels in admin order: the
|
* Where a send goes, given the configured channels in admin order: the
|
||||||
* selection while it is still configured and its transcript is on the
|
* selection while it is still configured and its transcript is on the
|
||||||
* page; otherwise the first configured channel.
|
* page, else the first configured channel that IS on this page — with one
|
||||||
|
* transcript up, that one — and only if the page shows none, the first
|
||||||
|
* configured channel. A send must never quietly go to a channel the
|
||||||
|
* operator cannot see.
|
||||||
*/
|
*/
|
||||||
resolve(configured) {
|
resolve(configured) {
|
||||||
const list = Array.isArray(configured) ? configured : [];
|
const list = Array.isArray(configured) ? configured : [];
|
||||||
if (!list.length) return '';
|
if (!list.length) return '';
|
||||||
if (id && list.includes(id) && this.hasTranscript(id)) return id;
|
if (id && list.includes(id) && this.hasTranscript(id)) return id;
|
||||||
return list[0];
|
const onPage = list.find((c) => this.hasTranscript(c));
|
||||||
|
return onPage || list[0];
|
||||||
},
|
},
|
||||||
on(fn) {
|
on(fn) {
|
||||||
bus.addEventListener('change', fn);
|
bus.addEventListener('change', fn);
|
||||||
@@ -1100,7 +1104,7 @@ function createSendTile({ root, moduleApi, mode }) {
|
|||||||
if (stopped || !state) return;
|
if (stopped || !state) return;
|
||||||
if (token) {
|
if (token) {
|
||||||
if (composeEls) {
|
if (composeEls) {
|
||||||
composeEls.line.textContent = sendingLine();
|
if (composeEls.line) composeEls.line.textContent = sendingLine();
|
||||||
if (composeEls.input) composeEls.input.placeholder = placeholder();
|
if (composeEls.input) composeEls.input.placeholder = placeholder();
|
||||||
}
|
}
|
||||||
} else renderGate();
|
} else renderGate();
|
||||||
@@ -1177,14 +1181,21 @@ function createSendTile({ root, moduleApi, mode }) {
|
|||||||
let composeEls = null;
|
let composeEls = null;
|
||||||
function buildCompose() {
|
function buildCompose() {
|
||||||
compose.textContent = '';
|
compose.textContent = '';
|
||||||
const head = el('div', 'sl-compose-head');
|
// Send message names the channel in the box's own placeholder and locks
|
||||||
const line = el('span', 'sl-sending', sendingLine());
|
// from the title bar, so a line above the field would only say both
|
||||||
const lockLink = el('button', 'sl-lock-link', 'Lock');
|
// again. Quick replies has no placeholder — there it is the only place
|
||||||
lockLink.type = 'button';
|
// the target is named, so it stays.
|
||||||
lockLink.title = 'Lock now';
|
let line = null;
|
||||||
lockLink.addEventListener('click', () => lock('Locked'));
|
if (mode !== 'send') {
|
||||||
head.append(line, lockLink);
|
const head = el('div', 'sl-compose-head');
|
||||||
compose.appendChild(head);
|
line = el('span', 'sl-sending', sendingLine());
|
||||||
|
const lockLink = el('button', 'sl-lock-link', 'Lock');
|
||||||
|
lockLink.type = 'button';
|
||||||
|
lockLink.title = 'Lock now';
|
||||||
|
lockLink.addEventListener('click', () => lock('Locked'));
|
||||||
|
head.append(line, lockLink);
|
||||||
|
compose.appendChild(head);
|
||||||
|
}
|
||||||
|
|
||||||
const foot = el('div', 'sl-compose-foot');
|
const foot = el('div', 'sl-compose-foot');
|
||||||
const msg = el('div', 'sl-msg');
|
const msg = el('div', 'sl-msg');
|
||||||
@@ -1462,7 +1473,7 @@ function createSendTile({ root, moduleApi, mode }) {
|
|||||||
if (!s.sending?.enabled || !s.sending?.pinSet) return void lock('');
|
if (!s.sending?.enabled || !s.sending?.pinSet) return void lock('');
|
||||||
if (!composeEls) return void showCompose(); // the page was unlocked before this tile appeared
|
if (!composeEls) return void showCompose(); // the page was unlocked before this tile appeared
|
||||||
if (composeEls) {
|
if (composeEls) {
|
||||||
composeEls.line.textContent = sendingLine();
|
if (composeEls.line) composeEls.line.textContent = sendingLine();
|
||||||
if (composeEls.input) composeEls.input.placeholder = placeholder();
|
if (composeEls.input) composeEls.input.placeholder = placeholder();
|
||||||
}
|
}
|
||||||
} else renderGate();
|
} else renderGate();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "slack",
|
"id": "slack",
|
||||||
"name": "Slack",
|
"name": "Slack",
|
||||||
"version": "1.2.0",
|
"version": "1.2.1",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Live transcripts of this location's Slack channels, plus PIN-protected sending as the location",
|
"description": "Live transcripts of this location's Slack channels, plus PIN-protected sending as the location",
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
"help": "Create a Slack app in the workspace (api.slack.com/apps → Create New App → From scratch) and, under OAuth & Permissions → User Token Scopes, add channels:history, channels:read, groups:history, groups:read, chat:write, users:read, reactions:write (emoji reactions) and emoji:read (the workspace's custom emoji). Sign in to Slack as this location's account — the one named for the campus — open the app's OAuth & Permissions page and press Install to Workspace (or Reinstall to Workspace), then copy the User OAuth Token (xoxp-…) it shows and paste it here: messages from this machine are then literally from the location. A bot token (xoxb-…) works too; it posts as the app, with \"Send as\" naming the location."
|
"help": "Create a Slack app in the workspace (api.slack.com/apps → Create New App → From scratch) and, under OAuth & Permissions → User Token Scopes, add channels:history, channels:read, groups:history, groups:read, chat:write, users:read, reactions:write (emoji reactions) and emoji:read (the workspace's custom emoji). Sign in to Slack as this location's account — the one named for the campus — open the app's OAuth & Permissions page and press Install to Workspace (or Reinstall to Workspace), then copy the User OAuth Token (xoxp-…) it shows and paste it here: messages from this machine are then literally from the location. A bot token (xoxb-…) works too; it posts as the app, with \"Send as\" naming the location."
|
||||||
},
|
},
|
||||||
"Channels": {
|
"Channels": {
|
||||||
"help": "Every channel picked here becomes its own transcript tile in the + picker, titled with the channel's name. The Send message and Quick replies tiles post to whichever transcript was clicked last — its bottom edge lights up in the accent colour — and to the first channel here until one is clicked. Only channels the account has joined are listed; join one in Slack first. Each channel is read on its own schedule, all of them together kept inside Slack's rate limit."
|
"help": "Every channel picked here becomes its own transcript tile in the + picker, titled with the channel's name. The Send message and Quick replies tiles post to whichever transcript was clicked last — its bottom edge lights up in the accent colour — and until one is clicked, to the first channel here whose transcript is on that dashboard: with a single transcript up, that one. A send never goes to a channel the dashboard is not showing. Only channels the account has joined are listed; join one in Slack first. Each channel is read on its own schedule, all of them together kept inside Slack's rate limit."
|
||||||
},
|
},
|
||||||
"Sending": {
|
"Sending": {
|
||||||
"toggle": "sendEnabled",
|
"toggle": "sendEnabled",
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
"default": [],
|
"default": [],
|
||||||
"group": "Channels",
|
"group": "Channels",
|
||||||
"optionsRoute": "/channels",
|
"optionsRoute": "/channels",
|
||||||
"help": "One transcript tile per channel; the first one is where sends go until a transcript is clicked. Only channels the account has joined are listed — join one in Slack first."
|
"help": "One transcript tile per channel; until a transcript is clicked, sends go to the first channel here that the dashboard is actually showing. Only channels the account has joined are listed — join one in Slack first."
|
||||||
},
|
},
|
||||||
"pollSeconds": {
|
"pollSeconds": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proddash",
|
"name": "proddash",
|
||||||
"version": "1.6.0",
|
"version": "1.5.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,14 +19,6 @@
|
|||||||
|
|
||||||
<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"/>
|
||||||
|
|||||||
+92
-211
@@ -24,7 +24,6 @@ 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');
|
||||||
|
|
||||||
@@ -162,25 +161,18 @@ 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, autoHideHeads, tiles }));
|
localStorage.setItem(LS_LAYOUT, JSON.stringify({ v: 1, 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;
|
if (raw && Array.isArray(raw.tiles)) return raw.tiles;
|
||||||
} catch { /* corrupted — start fresh */ }
|
} catch { /* corrupted — start fresh */ }
|
||||||
return { tiles: [] };
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeTile(t) {
|
function sanitizeTile(t) {
|
||||||
@@ -193,7 +185,7 @@ function sanitizeTile(t) {
|
|||||||
w,
|
w,
|
||||||
h: Math.max(1, Number(t.h) || 2),
|
h: Math.max(1, Number(t.h) || 2),
|
||||||
settings: (t.settings && typeof t.settings === 'object') ? t.settings : {},
|
settings: (t.settings && typeof t.settings === 'object') ? t.settings : {},
|
||||||
headPinned: Boolean(t.headPinned),
|
headHidden: Boolean(t.headHidden),
|
||||||
// set when the tile was added from a module's tile list (multi-tile modules)
|
// set when the tile was added from a module's tile list (multi-tile modules)
|
||||||
variant: t.variant ? String(t.variant) : '',
|
variant: t.variant ? String(t.variant) : '',
|
||||||
title: t.title ? String(t.title) : '',
|
title: t.title ? String(t.title) : '',
|
||||||
@@ -257,48 +249,9 @@ function minSizeOf(tile) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── title bars that fold away ──────────────────────────────────────── */
|
|
||||||
|
|
||||||
/* A bar is "off" when it is folded out of sight: the dashboard has auto-hide
|
|
||||||
on and this tile is not pinned, or the screen is in fullscreen, which folds
|
|
||||||
every bar for watching whatever the tiles say. Off is never gone — the top
|
|
||||||
edge of the tile brings it back for as long as the pointer is there. */
|
|
||||||
|
|
||||||
/** 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 true;
|
|
||||||
return autoHideHeads && !tile.headPinned;
|
|
||||||
}
|
|
||||||
|
|
||||||
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>';
|
||||||
const PIN_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16.5V22"/><path class="pin-head" d="M8.5 3h7l-1.2 7 3.2 3.2H6.5L9.7 10 8.5 3z"/></svg>';
|
|
||||||
const GEAR_SVG = '<svg viewBox="0 0 90 90" fill="currentColor" aria-hidden="true"><path d="M 82.94 32.538 c -2.685 0 -5.093 -1.545 -6.327 -3.929 c -0.245 -0.476 -0.504 -0.948 -0.774 -1.416 c -0.27 -0.468 -0.55 -0.928 -0.84 -1.378 c -1.448 -2.26 -1.582 -5.117 -0.239 -7.443 c 1.951 -3.378 0.792 -7.696 -2.584 -9.645 l -9.352 -5.4 c -3.378 -1.951 -7.696 -0.792 -9.645 2.584 l -0.002 0.003 c -1.322 2.291 -3.815 3.641 -6.456 3.512 c -1.149 -0.056 -2.301 -0.057 -3.455 0 c -2.638 0.128 -5.124 -1.226 -6.444 -3.513 l -0.002 -0.002 c -1.951 -3.378 -6.269 -4.535 -9.645 -2.584 l -9.352 5.4 c -3.378 1.951 -4.535 6.269 -2.584 9.645 l 0.002 0.002 c 1.321 2.286 1.25 5.117 -0.18 7.336 c -0.625 0.972 -1.201 1.97 -1.726 2.993 c -1.209 2.353 -3.625 3.836 -6.269 3.836 H 7.063 c -3.9 0 -7.061 3.162 -7.061 7.061 v 10.801 c 0 3.9 3.162 7.061 7.061 7.061 l 0 0 c 2.685 0 5.093 1.545 6.327 3.929 c 0.245 0.475 0.504 0.948 0.774 1.416 c 0.27 0.468 0.55 0.928 0.84 1.378 c 1.448 2.26 1.582 5.117 0.239 7.443 c -1.951 3.378 -0.792 7.696 2.584 9.645 l 9.352 5.4 c 3.378 1.951 7.696 0.792 9.645 -2.584 l 0.002 -0.003 c 1.322 -2.291 3.815 -3.641 6.456 -3.512 c 1.149 0.056 2.301 0.057 3.455 0 c 2.638 -0.128 5.124 1.226 6.444 3.513 l 0.002 0.002 c 1.951 3.378 6.268 4.535 9.645 2.584 l 9.352 -5.4 c 3.378 -1.949 4.535 -6.268 2.584 -9.645 l -0.002 -0.002 c -1.321 -2.286 -1.25 -5.117 0.18 -7.336 c 0.625 -0.972 1.201 -1.97 1.726 -2.993 c 1.209 -2.353 3.625 -3.836 6.269 -3.836 h 0.003 c 3.9 0 7.061 -3.162 7.061 -7.061 V 39.599 C 90.001 35.7 86.84 32.538 82.94 32.538 z M 54.998 62.318 c -9.565 5.523 -21.796 2.245 -27.317 -7.32 c -5.523 -9.565 -2.245 -21.796 7.32 -27.317 s 21.796 -2.245 27.317 7.32 C 67.841 44.566 64.563 56.797 54.998 62.318 z"/></svg>';
|
const GEAR_SVG = '<svg viewBox="0 0 90 90" fill="currentColor" aria-hidden="true"><path d="M 82.94 32.538 c -2.685 0 -5.093 -1.545 -6.327 -3.929 c -0.245 -0.476 -0.504 -0.948 -0.774 -1.416 c -0.27 -0.468 -0.55 -0.928 -0.84 -1.378 c -1.448 -2.26 -1.582 -5.117 -0.239 -7.443 c 1.951 -3.378 0.792 -7.696 -2.584 -9.645 l -9.352 -5.4 c -3.378 -1.951 -7.696 -0.792 -9.645 2.584 l -0.002 0.003 c -1.322 2.291 -3.815 3.641 -6.456 3.512 c -1.149 -0.056 -2.301 -0.057 -3.455 0 c -2.638 0.128 -5.124 -1.226 -6.444 -3.513 l -0.002 -0.002 c -1.951 -3.378 -6.269 -4.535 -9.645 -2.584 l -9.352 5.4 c -3.378 1.951 -4.535 6.269 -2.584 9.645 l 0.002 0.002 c 1.321 2.286 1.25 5.117 -0.18 7.336 c -0.625 0.972 -1.201 1.97 -1.726 2.993 c -1.209 2.353 -3.625 3.836 -6.269 3.836 H 7.063 c -3.9 0 -7.061 3.162 -7.061 7.061 v 10.801 c 0 3.9 3.162 7.061 7.061 7.061 l 0 0 c 2.685 0 5.093 1.545 6.327 3.929 c 0.245 0.475 0.504 0.948 0.774 1.416 c 0.27 0.468 0.55 0.928 0.84 1.378 c 1.448 2.26 1.582 5.117 0.239 7.443 c -1.951 3.378 -0.792 7.696 2.584 9.645 l 9.352 5.4 c 3.378 1.951 7.696 0.792 9.645 -2.584 l 0.002 -0.003 c 1.322 -2.291 3.815 -3.641 6.456 -3.512 c 1.149 0.056 2.301 0.057 3.455 0 c 2.638 -0.128 5.124 1.226 6.444 3.513 l 0.002 0.002 c 1.951 3.378 6.268 4.535 9.645 2.584 l 9.352 -5.4 c 3.378 -1.949 4.535 -6.268 2.584 -9.645 l -0.002 -0.002 c -1.321 -2.286 -1.25 -5.117 0.18 -7.336 c 0.625 -0.972 1.201 -1.97 1.726 -2.993 c 1.209 -2.353 3.625 -3.836 6.269 -3.836 h 0.003 c 3.9 0 7.061 -3.162 7.061 -7.061 V 39.599 C 90.001 35.7 86.84 32.538 82.94 32.538 z M 54.998 62.318 c -9.565 5.523 -21.796 2.245 -27.317 -7.32 c -5.523 -9.565 -2.245 -21.796 7.32 -27.317 s 21.796 -2.245 27.317 7.32 C 67.841 44.566 64.563 56.797 54.998 62.318 z"/></svg>';
|
||||||
|
|
||||||
function buildTile(tile) {
|
function buildTile(tile) {
|
||||||
@@ -325,34 +278,7 @@ function buildTile(tile) {
|
|||||||
const controls = document.createElement('span');
|
const controls = document.createElement('span');
|
||||||
controls.className = 'tile-controls';
|
controls.className = 'tile-controls';
|
||||||
|
|
||||||
/* Pinned tiles keep their bar through auto-hide. The switch sits right
|
head.append(dot, title, controls);
|
||||||
beside the label rather than out with the actions, because it is about
|
|
||||||
this bar rather than about the tile's contents. */
|
|
||||||
const pin = document.createElement('button');
|
|
||||||
pin.className = 'tile-btn pin';
|
|
||||||
pin.innerHTML = PIN_SVG;
|
|
||||||
const paintPin = () => {
|
|
||||||
const on = Boolean(tile.headPinned);
|
|
||||||
pin.classList.toggle('is-pinned', on);
|
|
||||||
pin.setAttribute('aria-pressed', on ? 'true' : 'false');
|
|
||||||
// Instant tooltips read `title` the first time and `data-tip` after.
|
|
||||||
pin.title = on ? 'Unpin bar' : 'Pin bar';
|
|
||||||
pin.setAttribute('data-tip', pin.title);
|
|
||||||
};
|
|
||||||
paintPin();
|
|
||||||
pin.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
tile.headPinned = !tile.headPinned;
|
|
||||||
paintPin();
|
|
||||||
applyHeadState(tile, el);
|
|
||||||
saveLayout();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Keeps the label hugging the pin however wide the tile gets.
|
|
||||||
const gap = document.createElement('span');
|
|
||||||
gap.className = 'tile-head-gap';
|
|
||||||
|
|
||||||
head.append(dot, title, pin, gap, controls);
|
|
||||||
|
|
||||||
// Every tile has something to set: its own settings when the module
|
// Every tile has something to set: its own settings when the module
|
||||||
// offers any, and the tint either way.
|
// offers any, and the tint either way.
|
||||||
@@ -381,47 +307,45 @@ function buildTile(tile) {
|
|||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
body.className = 'tile-body';
|
body.className = 'tile-body';
|
||||||
|
|
||||||
/* The rim: both bottom corners resize in two directions, the bottom edge
|
const resize = document.createElement('div');
|
||||||
moves that edge (trading rows with tiles below), and the other three
|
resize.className = 'tile-resize';
|
||||||
move only themselves — the top edge changes where the tile starts, the
|
resize.title = 'Drag to resize';
|
||||||
sides how wide it is — each stopping where a neighbour or the tile's own
|
|
||||||
minimum says it must. None of them draw anything: the cursor over an
|
|
||||||
edge is what says it can be dragged, as on a window. */
|
|
||||||
const grip = (cls) => {
|
|
||||||
const gripEl = document.createElement('div');
|
|
||||||
gripEl.className = cls;
|
|
||||||
return gripEl;
|
|
||||||
};
|
|
||||||
const resize = grip('tile-resize');
|
|
||||||
const resizeLeft = grip('tile-resize-left');
|
|
||||||
const resizeBottom = grip('tile-resize-bottom');
|
|
||||||
const edgeTop = grip('tile-resize-top');
|
|
||||||
const edgeRight = grip('tile-resize-right');
|
|
||||||
const edgeLeft = grip('tile-resize-side');
|
|
||||||
|
|
||||||
// A folded-away bar is still reachable: point at the tile's top edge and it
|
const resizeLeft = document.createElement('div');
|
||||||
// slides in over the content, where its buttons click and its whole width
|
resizeLeft.className = 'tile-resize-left';
|
||||||
// drags the tile exactly as when it is showing.
|
resizeLeft.title = 'Drag to resize';
|
||||||
el.addEventListener('pointermove', (ev) => {
|
|
||||||
if (!headIsOff(tile)) return;
|
// pill on the bottom edge: moves that edge (trading rows with tiles below)
|
||||||
// Mid-drag the tile moves under the pointer, and mid-resize the edges do
|
const resizeBottom = document.createElement('div');
|
||||||
// — neither is a moment to take the bar away.
|
resizeBottom.className = 'tile-resize-bottom';
|
||||||
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
|
resizeBottom.title = 'Drag to adjust height';
|
||||||
const y = ev.clientY - el.getBoundingClientRect().top;
|
|
||||||
const shown = el.classList.contains('head-peek');
|
// notch on the title bar's bottom edge: hides/shows the bar
|
||||||
// It takes less to hold the bar open than to open it, so a bar that
|
const notch = document.createElement('button');
|
||||||
// appears under the pointer cannot flicker straight back out.
|
notch.className = 'tile-notch';
|
||||||
if (!shown && y <= HEAD_REVEAL_PX) el.classList.add('head-peek');
|
notch.title = 'Hide / show the title bar';
|
||||||
else if (shown && y > HEAD_HOLD_PX) el.classList.remove('head-peek');
|
notch.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// In fullscreen the bars are all folded away for watching; the notch
|
||||||
|
// peeks one open for a moment without touching what this tile saved.
|
||||||
|
if (document.body.classList.contains('fullscreen')) {
|
||||||
|
peekHead(tile, el, !el.classList.contains('head-peek'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tile.headHidden = !tile.headHidden;
|
||||||
|
el.classList.toggle('head-hidden', tile.headHidden);
|
||||||
|
saveLayout();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A peeked title bar stays while the pointer is on its tile, and starts
|
||||||
|
// counting down as soon as it leaves.
|
||||||
|
el.addEventListener('pointerenter', () => clearPeekTimer(tile.id));
|
||||||
el.addEventListener('pointerleave', () => {
|
el.addEventListener('pointerleave', () => {
|
||||||
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
|
if (el.classList.contains('head-peek')) schedulePeekHide(tile.id, el);
|
||||||
el.classList.remove('head-peek');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
el.append(head, body, resize, resizeLeft, resizeBottom, edgeTop, edgeRight, edgeLeft);
|
el.append(head, body, resize, resizeLeft, resizeBottom, notch);
|
||||||
applyHeadState(tile, el);
|
el.classList.toggle('head-hidden', Boolean(tile.headHidden));
|
||||||
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 };
|
||||||
@@ -432,9 +356,6 @@ 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;
|
||||||
@@ -448,7 +369,7 @@ function refreshEmptyState() {
|
|||||||
|
|
||||||
function wireDrag(tile, el, handle) {
|
function wireDrag(tile, el, handle) {
|
||||||
handle.addEventListener('pointerdown', (ev) => {
|
handle.addEventListener('pointerdown', (ev) => {
|
||||||
if (ev.target.closest('.tile-btn') || ev.target.closest('.tile-menu')) return;
|
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 (ev.button !== 0 && ev.pointerType === 'mouse') return;
|
||||||
if (document.body.classList.contains('fullscreen')) return;
|
if (document.body.classList.contains('fullscreen')) return;
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
@@ -565,80 +486,6 @@ 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
|
||||||
@@ -766,6 +613,7 @@ function seamPairsAt(boundary) {
|
|||||||
function buildDivider(L, R, top, bottom) {
|
function buildDivider(L, R, top, bottom) {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'tile-divider';
|
el.className = 'tile-divider';
|
||||||
|
el.title = 'Drag to resize this column';
|
||||||
|
|
||||||
const place = () => {
|
const place = () => {
|
||||||
const m = cellMetrics();
|
const m = cellMetrics();
|
||||||
@@ -778,6 +626,14 @@ function buildDivider(L, R, top, bottom) {
|
|||||||
|
|
||||||
const boundary = () => L.x + L.w;
|
const boundary = () => L.x + L.w;
|
||||||
const groupEls = () => dividers.filter((d) => d.boundary() === boundary()).map((d) => d.el);
|
const groupEls = () => dividers.filter((d) => d.boundary() === boundary()).map((d) => d.el);
|
||||||
|
const unlink = () => { for (const d of dividers) d.el.classList.remove('linked'); };
|
||||||
|
|
||||||
|
// Before anything is dragged, show which seams travel with this one.
|
||||||
|
el.addEventListener('pointerenter', () => {
|
||||||
|
if (document.body.classList.contains('fullscreen')) return;
|
||||||
|
for (const other of groupEls()) other.classList.add('linked');
|
||||||
|
});
|
||||||
|
el.addEventListener('pointerleave', unlink);
|
||||||
|
|
||||||
el.addEventListener('pointerdown', (ev) => {
|
el.addEventListener('pointerdown', (ev) => {
|
||||||
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
|
if (ev.button !== 0 && ev.pointerType === 'mouse') return;
|
||||||
@@ -833,6 +689,7 @@ function buildDivider(L, R, top, bottom) {
|
|||||||
el.removeEventListener('pointerup', onUp);
|
el.removeEventListener('pointerup', onUp);
|
||||||
el.removeEventListener('pointercancel', onUp);
|
el.removeEventListener('pointercancel', onUp);
|
||||||
for (const other of group) other.classList.remove('active');
|
for (const other of group) other.classList.remove('active');
|
||||||
|
unlink();
|
||||||
if (changed) saveLayout();
|
if (changed) saveLayout();
|
||||||
rebuildDividers(); // adjacencies may have changed
|
rebuildDividers(); // adjacencies may have changed
|
||||||
};
|
};
|
||||||
@@ -870,7 +727,7 @@ function addTile(moduleId, entry = null) {
|
|||||||
if (spec && 'default' in spec) settings[key] = spec.default;
|
if (spec && 'default' in spec) settings[key] = spec.default;
|
||||||
}
|
}
|
||||||
Object.assign(settings, entry?.settings || {});
|
Object.assign(settings, entry?.settings || {});
|
||||||
const tile = { id: newId(), module: moduleId, ...spot, ...size, settings, headPinned: false };
|
const tile = { id: newId(), module: moduleId, ...spot, ...size, settings };
|
||||||
if (entry) {
|
if (entry) {
|
||||||
tile.variant = String(entry.id);
|
tile.variant = String(entry.id);
|
||||||
tile.title = String(entry.name || '');
|
tile.title = String(entry.name || '');
|
||||||
@@ -886,6 +743,7 @@ function addTile(moduleId, entry = null) {
|
|||||||
function removeTile(id) {
|
function removeTile(id) {
|
||||||
const idx = tiles.findIndex((t) => t.id === id);
|
const idx = tiles.findIndex((t) => t.id === id);
|
||||||
if (idx < 0) return;
|
if (idx < 0) return;
|
||||||
|
clearPeekTimer(id);
|
||||||
unmountModule(tiles[idx]);
|
unmountModule(tiles[idx]);
|
||||||
tiles.splice(idx, 1);
|
tiles.splice(idx, 1);
|
||||||
const entry = tileEls.get(id);
|
const entry = tileEls.get(id);
|
||||||
@@ -1139,7 +997,7 @@ function buildModuleApi(tile, man, entry) {
|
|||||||
/**
|
/**
|
||||||
* The tile's title bar. Controls added here live next to the gear/close
|
* The tile's title bar. Controls added here live next to the gear/close
|
||||||
* buttons and are removed automatically when the instance stops. The
|
* buttons and are removed automatically when the instance stops. The
|
||||||
* whole bar folds away when the dashboard auto-hides it, so nothing
|
* whole bar can be hidden by the user via the notch, so nothing
|
||||||
* essential should exist ONLY as a header control.
|
* essential should exist ONLY as a header control.
|
||||||
*/
|
*/
|
||||||
header: {
|
header: {
|
||||||
@@ -1854,13 +1712,9 @@ function remountTile(tile) {
|
|||||||
|
|
||||||
/* ── layout apply / reset ───────────────────────────────────────────── */
|
/* ── layout apply / reset ───────────────────────────────────────────── */
|
||||||
|
|
||||||
function renderLayout(layout) {
|
function renderLayout(list) {
|
||||||
// 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();
|
||||||
@@ -1940,7 +1794,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: { autoHideHeads, tiles } }),
|
body: JSON.stringify({ layout: { 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 + ')');
|
||||||
@@ -1984,7 +1838,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);
|
renderLayout(body.layout.tiles || []);
|
||||||
saveLayout();
|
saveLayout();
|
||||||
toast(`Loaded “${l.name}”`);
|
toast(`Loaded “${l.name}”`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1996,7 +1850,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
|||||||
menu.insertBefore(block, reset);
|
menu.insertBefore(block, reset);
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ── menu bar show/hide (its own notch, at the top-left) ────────────── */
|
/* ── menu bar show/hide (notch, like the tile title bars) ───────────── */
|
||||||
|
|
||||||
const topbar = document.getElementById('topbar');
|
const topbar = document.getElementById('topbar');
|
||||||
const topbarNotch = document.getElementById('topbar-notch');
|
const topbarNotch = document.getElementById('topbar-notch');
|
||||||
@@ -2023,6 +1877,41 @@ try {
|
|||||||
if (localStorage.getItem(LS_MENU) === '1') setMenuHidden(true, { persist: false });
|
if (localStorage.getItem(LS_MENU) === '1') setMenuHidden(true, { persist: false });
|
||||||
} catch { /* fine */ }
|
} catch { /* fine */ }
|
||||||
|
|
||||||
|
/* ── fullscreen: peeking at a tile's title bar ──────────────────────── */
|
||||||
|
|
||||||
|
/** How long a peeked title bar stays after the pointer leaves its tile. */
|
||||||
|
const PEEK_HIDE_MS = 10000;
|
||||||
|
const peekTimers = new Map(); // tile id → timeout
|
||||||
|
|
||||||
|
function clearPeekTimer(id) {
|
||||||
|
const timer = peekTimers.get(id);
|
||||||
|
if (timer === undefined) return;
|
||||||
|
clearTimeout(timer);
|
||||||
|
peekTimers.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePeekHide(id, el) {
|
||||||
|
clearPeekTimer(id);
|
||||||
|
peekTimers.set(id, setTimeout(() => {
|
||||||
|
peekTimers.delete(id);
|
||||||
|
el.classList.remove('head-peek');
|
||||||
|
}, PEEK_HIDE_MS));
|
||||||
|
}
|
||||||
|
|
||||||
|
function peekHead(tile, el, on) {
|
||||||
|
el.classList.toggle('head-peek', on);
|
||||||
|
clearPeekTimer(tile.id);
|
||||||
|
// Opened from a click the pointer is on the tile, so the countdown waits
|
||||||
|
// for it to leave — unless it already has (a keyboard or touch open).
|
||||||
|
if (on && !el.matches(':hover')) schedulePeekHide(tile.id, el);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Back to a plain screen: no peeks left open, no timers left running. */
|
||||||
|
function clearPeeks() {
|
||||||
|
for (const id of [...peekTimers.keys()]) clearPeekTimer(id);
|
||||||
|
document.querySelectorAll('.tile.head-peek').forEach((el) => el.classList.remove('head-peek'));
|
||||||
|
}
|
||||||
|
|
||||||
/* ── fullscreen: auto-fit the layout to the screen ──────────────────── */
|
/* ── fullscreen: auto-fit the layout to the screen ──────────────────── */
|
||||||
|
|
||||||
/* Fullscreen is a viewing mode. The occupied columns stretch proportionally
|
/* Fullscreen is a viewing mode. The occupied columns stretch proportionally
|
||||||
@@ -2137,13 +2026,6 @@ 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(() => {});
|
||||||
@@ -2157,15 +2039,14 @@ 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
|
||||||
requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit));
|
requestAnimationFrame(() => requestAnimationFrame(applyFullscreenFit));
|
||||||
} else {
|
} else {
|
||||||
setMenuHidden(menuHiddenBeforeFs, { persist: false });
|
setMenuHidden(menuHiddenBeforeFs, { persist: false });
|
||||||
|
clearPeeks();
|
||||||
clearFullscreenFit();
|
clearFullscreenFit();
|
||||||
applyAllHeadStates();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+118
-115
@@ -126,14 +126,6 @@ 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 ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -278,33 +270,11 @@ 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 */
|
||||||
/* Folded away: the bar leaves the flow and parks just above the tile, so the
|
.tile.head-hidden .tile-head {
|
||||||
module keeps the whole body and nothing reflows as the bar comes and goes.
|
height: 0;
|
||||||
Pointing at the tile's top edge slides it back in over the content
|
border-bottom-width: 0;
|
||||||
(shell.js adds .head-peek) — and because it is the same element, its
|
overflow: hidden;
|
||||||
buttons click and its width drags the tile just as they always did. */
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
.tile.head-off.head-peek > .tile-head {
|
|
||||||
transform: none;
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
.tile.is-tinted.head-off > .tile-head {
|
|
||||||
background: color-mix(in srgb, var(--tile-tint) 20%, var(--panel));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile-head .dot {
|
.tile-head .dot {
|
||||||
@@ -320,7 +290,7 @@ body.menu-hidden #topbar {
|
|||||||
.tile-head .dot.error { background: var(--danger); }
|
.tile-head .dot.error { background: var(--danger); }
|
||||||
|
|
||||||
.tile-title {
|
.tile-title {
|
||||||
flex: 0 1 auto;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -351,33 +321,6 @@ body.menu-hidden #topbar {
|
|||||||
.tile-btn.is-open,
|
.tile-btn.is-open,
|
||||||
.tile-btn.is-open:hover { background: var(--accent); color: var(--accent-text); }
|
.tile-btn.is-open:hover { background: var(--accent); color: var(--accent-text); }
|
||||||
|
|
||||||
/* The pin: a state, not an action, so it stays quiet until it is on and
|
|
||||||
sits closer to the label than to the buttons at the other end. It fades
|
|
||||||
in with the rest of the tile's chrome so a wall of unpinned tiles is not
|
|
||||||
a wall of pins. */
|
|
||||||
.tile-btn.pin {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
margin-left: 2px;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.15s ease;
|
|
||||||
}
|
|
||||||
.tile-btn.pin svg { width: 12px; height: 12px; }
|
|
||||||
.tile:hover .tile-btn.pin,
|
|
||||||
.tile-btn.pin:focus-visible { opacity: 0.75; }
|
|
||||||
.tile-btn.pin:hover { opacity: 1; }
|
|
||||||
/* Pinned reads at a glance, whether or not anything is being pointed at. */
|
|
||||||
.tile-btn.pin.is-pinned,
|
|
||||||
.tile:hover .tile-btn.pin.is-pinned {
|
|
||||||
opacity: 1;
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
.tile-btn.pin.is-pinned .pin-head { fill: currentColor; }
|
|
||||||
.tile-btn.pin.is-pinned:hover { background: var(--border); }
|
|
||||||
|
|
||||||
/* Keeps the label hugging its pin however wide the tile is. */
|
|
||||||
.tile-head-gap { flex: 1 1 auto; min-width: 0; }
|
|
||||||
|
|
||||||
/* the shell's tooltip: shown at once under the button the pointer is on */
|
/* the shell's tooltip: shown at once under the button the pointer is on */
|
||||||
#tip {
|
#tip {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -431,6 +374,55 @@ body.menu-hidden #topbar {
|
|||||||
}
|
}
|
||||||
.tile-menu[hidden] { display: none; }
|
.tile-menu[hidden] { display: none; }
|
||||||
|
|
||||||
|
/* notch on the title bar's bottom edge: click to hide/show the bar
|
||||||
|
(same idea as the ProdCom Listener's pull-down menubar handle) */
|
||||||
|
.tile-notch {
|
||||||
|
position: absolute;
|
||||||
|
top: 32px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 46px;
|
||||||
|
height: 13px;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 10px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 6;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease, top 0.22s ease;
|
||||||
|
}
|
||||||
|
.tile:hover .tile-notch { opacity: 0.6; }
|
||||||
|
.tile .tile-notch:hover { opacity: 1; }
|
||||||
|
.tile.head-hidden .tile-notch { top: 0; }
|
||||||
|
.tile-notch::after {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
width: 18px;
|
||||||
|
height: 3px;
|
||||||
|
margin: 4px auto 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
.tile-notch:hover::after { background: var(--text); }
|
||||||
|
|
||||||
|
/* Fullscreen is for watching, not arranging: every title bar folds away, and
|
||||||
|
a tile's own hidden/shown preference is left untouched for when the screen
|
||||||
|
comes back. The notch still peeks one open, and it folds back once the
|
||||||
|
pointer has been off that tile for a while (PEEK_HIDE_MS in shell.js). */
|
||||||
|
body.fullscreen .tile:not(.head-peek) .tile-head {
|
||||||
|
height: 0;
|
||||||
|
border-bottom-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
body.fullscreen .tile:not(.head-peek) .tile-notch { top: 0; }
|
||||||
|
body.fullscreen .tile.head-peek .tile-head {
|
||||||
|
height: 32px;
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
}
|
||||||
|
body.fullscreen .tile.head-peek .tile-notch { top: 32px; }
|
||||||
|
|
||||||
.tile-body {
|
.tile-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -439,67 +431,80 @@ body.menu-hidden #topbar {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── resizing: the whole rim of a tile, drawn nowhere ────────────────
|
|
||||||
Every edge and both bottom corners resize, so there is nothing to point
|
|
||||||
at — the grips are invisible and the cursor says what each one does, the
|
|
||||||
way a window's own edges do. Together they tile the tile's outer 8px
|
|
||||||
without a gap: the top strip runs corner to corner, the sides run between
|
|
||||||
it and the bottom corners, and the bottom strip fills between those. */
|
|
||||||
|
|
||||||
.tile-resize,
|
|
||||||
.tile-resize-left,
|
|
||||||
.tile-resize-bottom,
|
|
||||||
.tile-resize-top,
|
|
||||||
.tile-resize-right,
|
|
||||||
.tile-resize-side {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 5;
|
|
||||||
touch-action: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* the two bottom corners: both directions at once */
|
|
||||||
.tile-resize,
|
.tile-resize,
|
||||||
.tile-resize-left {
|
.tile-resize-left {
|
||||||
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
touch-action: none;
|
||||||
|
z-index: 5;
|
||||||
|
color: var(--muted);
|
||||||
|
opacity: 0; /* corner indicators only show while hovering the tile */
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
}
|
}
|
||||||
.tile-resize { right: 0; cursor: nwse-resize; }
|
.tile-resize { right: 0; cursor: nwse-resize; }
|
||||||
.tile-resize-left { left: 0; cursor: nesw-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;
|
||||||
|
bottom: 4px;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/* top and bottom: height only */
|
/* bottom edge: drag to move the tile's bottom edge (height only). Drawn as
|
||||||
.tile-resize-top,
|
the same 2px line as the corner handles it sits between, on the same
|
||||||
|
4px inset, so the three read as one set of marks along the bottom. */
|
||||||
.tile-resize-bottom {
|
.tile-resize-bottom {
|
||||||
height: 8px;
|
position: absolute;
|
||||||
|
left: 24px;
|
||||||
|
right: 24px;
|
||||||
|
bottom: 0;
|
||||||
|
height: 10px;
|
||||||
|
z-index: 5;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
cursor: ns-resize;
|
cursor: ns-resize;
|
||||||
|
touch-action: none;
|
||||||
|
color: var(--muted);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
}
|
}
|
||||||
.tile-resize-top {
|
.tile-resize-bottom::after {
|
||||||
top: 0;
|
content: "";
|
||||||
left: 0;
|
width: 44px;
|
||||||
right: 0;
|
max-width: 60%;
|
||||||
/* Above a revealed title bar: the bar slides in under the pointer here,
|
height: 0;
|
||||||
and this strip has to stay the top edge rather than become part of it. */
|
margin-bottom: 4px;
|
||||||
z-index: 8;
|
border-bottom: 2px solid currentColor;
|
||||||
}
|
}
|
||||||
.tile-resize-bottom { bottom: 0; left: 20px; right: 20px; }
|
.tile:hover .tile-resize-bottom { opacity: 0.6; }
|
||||||
|
.tile .tile-resize-bottom:hover,
|
||||||
/* left and right: width only, between the top strip and the bottom corners */
|
.tile.resizing .tile-resize-bottom { opacity: 1; color: var(--text); }
|
||||||
.tile-resize-right,
|
|
||||||
.tile-resize-side {
|
|
||||||
top: 8px;
|
|
||||||
bottom: 20px;
|
|
||||||
width: 8px;
|
|
||||||
cursor: ew-resize;
|
|
||||||
}
|
|
||||||
.tile-resize-right { right: 0; }
|
|
||||||
.tile-resize-side { left: 0; }
|
|
||||||
|
|
||||||
/* ── seam divider between adjacent tiles ───────────────────────────── */
|
/* ── seam divider between adjacent tiles ───────────────────────────── */
|
||||||
|
|
||||||
/* When two tiles share a vertical edge, an invisible strip sits on the seam.
|
/* When two tiles share a vertical edge, an invisible strip sits on the seam;
|
||||||
Dragging it trades width between the two tiles rather than resizing one,
|
hovering reveals a small macOS-split-view-style grab pill. Dragging it
|
||||||
so it sits above their own edge grips. Like them it draws nothing at rest
|
trades width between the two tiles. */
|
||||||
— only while it is being dragged, to show which seam is moving. */
|
|
||||||
.tile-divider {
|
.tile-divider {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 14px;
|
width: 14px;
|
||||||
@@ -523,24 +528,22 @@ body.menu-hidden #topbar {
|
|||||||
transition: opacity 0.15s ease, background 0.15s ease;
|
transition: opacity 0.15s ease, background 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tile-divider:hover::after,
|
||||||
|
.tile-divider.linked::after { opacity: 0.7; }
|
||||||
.tile-divider.active::after { opacity: 1; background: var(--text); }
|
.tile-divider.active::after { opacity: 1; background: var(--text); }
|
||||||
|
|
||||||
/* Drop the seams while a tile itself is being moved or resized — they're
|
/* Hide the pills while a tile itself is being moved or corner-resized —
|
||||||
rebuilt for the new geometry on release. */
|
they're rebuilt for the new geometry on release. */
|
||||||
#grid.no-dividers .tile-divider { display: none; }
|
#grid.no-dividers .tile-divider { display: none; }
|
||||||
|
|
||||||
/* ── fullscreen mode ───────────────────────────────────────────────── */
|
/* ── fullscreen mode ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
/* Fullscreen is a viewing mode: the layout auto-fits the screen, so the
|
/* Fullscreen is a viewing mode: the layout auto-fits the screen, so the
|
||||||
editing affordances (the rim grips and the seams) are parked. */
|
editing affordances (corner handles, seam pills) are parked. */
|
||||||
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,30 +1394,15 @@ 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 {
|
||||||
// Whether tile title bars auto-hide is part of how a dashboard is
|
tiles: tiles.map((t) => ({
|
||||||
// arranged, so it travels with the layout like the tiles themselves.
|
id: String(t?.id || ''),
|
||||||
autoHideHeads: Boolean(raw?.autoHideHeads),
|
module: String(t?.module || ''),
|
||||||
tiles: tiles.map((t) => {
|
x: Number(t?.x) || 0,
|
||||||
const tile = {
|
y: Number(t?.y) || 0,
|
||||||
id: String(t?.id || ''),
|
w: Number(t?.w) || 1,
|
||||||
module: String(t?.module || ''),
|
h: Number(t?.h) || 1,
|
||||||
x: Number(t?.x) || 0,
|
settings: t?.settings && typeof t.settings === 'object' ? t.settings : {},
|
||||||
y: Number(t?.y) || 0,
|
})),
|
||||||
w: Number(t?.w) || 1,
|
|
||||||
h: Number(t?.h) || 1,
|
|
||||||
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 || ''),
|
|
||||||
headPinned: Boolean(t?.headPinned),
|
|
||||||
};
|
|
||||||
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