feat(sacred-valley): hybrid free/snap canvas + blank & blackflame cards (2.8.0)
Replace masonry grid with an absolute-positioned 12-col canvas: drag to move, corner to resize, per-card free/overlap toggle (Alt = no-snap). Geometry persisted (migration 027: dashboard_layout.geom + extras). Two new addable decorative cards: blank spacer + animated blackflame (canvas particle flame). Old layout auto-migrates by flow-placement. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
150
docs/mockups/blackflame-card.html
Normal file
150
docs/mockups/blackflame-card.html
Normal file
@@ -0,0 +1,150 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Blackflame card — mockup</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0a0a0e; --panel:#14141c; --panel-2:#1c1c26; --border:#2a2a36;
|
||||
--text:#e8e6ed; --muted:#888094;
|
||||
--accent:#ff4f2e; --accent-dim:#7a2716; --accent-soft:#3a1610;
|
||||
--font-display:'Cinzel',serif; --font-mono:'JetBrains Mono',monospace;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:#06060a;min-height:100vh;display:grid;place-items:center;
|
||||
font-family:var(--font-mono);color:var(--text);gap:18px;padding:40px}
|
||||
/* a Sacred-Valley-style card shell */
|
||||
.sv-card{
|
||||
width:440px;height:440px;position:relative;border-radius:14px;
|
||||
background:radial-gradient(120% 120% at 50% 18%, #14131b 0%, #0b0a10 60%, #08070c 100%);
|
||||
border:1px solid var(--border);overflow:hidden;
|
||||
box-shadow:0 0 0 1px #00000060, 0 24px 60px -20px #000,
|
||||
inset 0 0 70px -30px var(--accent-soft);
|
||||
}
|
||||
.sv-card::after{ /* faint inner ring */
|
||||
content:"";position:absolute;inset:0;border-radius:14px;pointer-events:none;
|
||||
box-shadow:inset 0 0 0 1px #ff4f2e12;
|
||||
}
|
||||
canvas{position:absolute;inset:0;width:100%;height:100%}
|
||||
.label{
|
||||
position:absolute;left:0;right:0;bottom:22px;text-align:center;z-index:3;
|
||||
font-family:var(--font-display);letter-spacing:.42em;text-transform:uppercase;
|
||||
font-size:15px;color:#f4ece9;text-indent:.42em;
|
||||
text-shadow:0 0 18px #ff4f2e88, 0 0 4px #000;
|
||||
}
|
||||
.sub{
|
||||
position:absolute;left:0;right:0;bottom:8px;text-align:center;z-index:3;
|
||||
font-family:var(--font-mono);font-size:9.5px;letter-spacing:.32em;
|
||||
text-transform:uppercase;color:#6a6475;
|
||||
}
|
||||
.crest{ /* the still, dark heart of the flame */
|
||||
position:absolute;top:50%;left:50%;width:84px;height:84px;z-index:2;
|
||||
transform:translate(-50%,-58%);border-radius:50%;
|
||||
background:radial-gradient(circle at 50% 45%, #000 38%, #0a0a10 60%, transparent 72%);
|
||||
box-shadow:0 0 26px 10px #000, 0 0 60px 18px #ff4f2e22;
|
||||
}
|
||||
.hint{font-family:var(--font-mono);font-size:11px;color:#6a6475;letter-spacing:.04em}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="sv-card" id="card">
|
||||
<canvas id="fx"></canvas>
|
||||
<div class="crest"></div>
|
||||
<div class="label">The Void</div>
|
||||
<div class="sub">Sacred Valley · core</div>
|
||||
</div>
|
||||
<div class="hint">Blackflame · canvas particle flame (dark heart, crimson licks, rising embers)</div>
|
||||
|
||||
<script>
|
||||
const cv = document.getElementById('fx');
|
||||
const ctx = cv.getContext('2d');
|
||||
let W,H,DPR;
|
||||
function resize(){
|
||||
DPR = Math.min(2, window.devicePixelRatio||1);
|
||||
const r = cv.getBoundingClientRect();
|
||||
W = r.width; H = r.height;
|
||||
cv.width = W*DPR; cv.height = H*DPR;
|
||||
ctx.setTransform(DPR,0,0,DPR,0,0);
|
||||
}
|
||||
resize(); addEventListener('resize', resize);
|
||||
|
||||
// ---- rising flame: stacked soft blobs along oscillating "tongues" + embers ----
|
||||
const TAU = Math.PI*2;
|
||||
const tongues = Array.from({length:5}, (_,i)=>({
|
||||
phase: Math.random()*TAU, speed: .6+Math.random()*.5,
|
||||
x: 0.5 + (i-2)*0.085, sway: 0.03+Math.random()*0.04, w: 0.34-Math.abs(i-2)*0.05
|
||||
}));
|
||||
const embers = Array.from({length:46}, ()=>spawn());
|
||||
function spawn(){ return {
|
||||
x: 0.5 + (Math.random()-0.5)*0.5, y: 1+Math.random()*0.2,
|
||||
vy: 0.0016+Math.random()*0.0030, vx:(Math.random()-0.5)*0.0012,
|
||||
r: 0.6+Math.random()*1.8, life: 0, max: 120+Math.random()*120,
|
||||
hot: Math.random()<0.5 };
|
||||
}
|
||||
|
||||
function blob(x,y,r,c0,c1){
|
||||
const g = ctx.createRadialGradient(x,y,0,x,y,r);
|
||||
g.addColorStop(0,c0); g.addColorStop(1,c1);
|
||||
ctx.fillStyle=g; ctx.beginPath(); ctx.arc(x,y,r,0,TAU); ctx.fill();
|
||||
}
|
||||
|
||||
let t=0;
|
||||
function frame(){
|
||||
t += 0.016;
|
||||
// base wash
|
||||
ctx.globalCompositeOperation='source-over';
|
||||
ctx.clearRect(0,0,W,H);
|
||||
|
||||
// flame body — additive so overlaps glow
|
||||
ctx.globalCompositeOperation='lighter';
|
||||
const baseY = H*0.92;
|
||||
for(const tg of tongues){
|
||||
const cx = W*(tg.x + Math.sin(t*tg.speed+tg.phase)*tg.sway);
|
||||
const height = H*(0.62 + 0.08*Math.sin(t*1.7+tg.phase));
|
||||
const steps = 26;
|
||||
for(let s=0;s<steps;s++){
|
||||
const f = s/steps; // 0 base → 1 tip
|
||||
const y = baseY - f*height;
|
||||
const flick = Math.sin(t*3.2 + tg.phase + f*7)*W*0.012*(0.4+f);
|
||||
const x = cx + flick + Math.sin(t*tg.speed*1.3+f*4)*W*tg.sway*0.6;
|
||||
const r = W*tg.w*(1-f*0.78)*(0.9+0.1*Math.sin(t*5+f*9));
|
||||
// colour ramps dark-red → crimson → fades at the tip (black-flame: dark heart)
|
||||
const a = (1-f)*0.5;
|
||||
if(f<0.18){ blob(x,y,r*1.1, `rgba(60,12,6,${a*0.7})`, 'rgba(60,12,6,0)'); }
|
||||
else if(f<0.62){ blob(x,y,r, `rgba(255,79,46,${a*0.5})`, 'rgba(122,39,22,0)'); }
|
||||
else { blob(x,y,r*0.8, `rgba(255,150,90,${a*0.5})`, 'rgba(255,79,46,0)'); }
|
||||
}
|
||||
}
|
||||
|
||||
// embers
|
||||
for(const e of embers){
|
||||
e.life++; e.y -= e.vy; e.x += e.vx + Math.sin(t*2+e.y*8)*0.0004;
|
||||
if(e.y<0.18 || e.life>e.max){ Object.assign(e, spawn()); }
|
||||
const px=e.x*W, py=e.y*H, fade=1-e.life/e.max;
|
||||
const col = e.hot? `rgba(255,170,110,${fade*0.9})` : `rgba(255,79,46,${fade*0.8})`;
|
||||
ctx.shadowBlur=8; ctx.shadowColor='#ff4f2e';
|
||||
blob(px,py,e.r*1.6, col, 'rgba(255,79,46,0)');
|
||||
}
|
||||
ctx.shadowBlur=0;
|
||||
|
||||
// carve the dark heart back in (the "black" of black-flame)
|
||||
ctx.globalCompositeOperation='source-over';
|
||||
const cx=W*0.5, cy=H*0.46, cr=W*0.20;
|
||||
const core = ctx.createRadialGradient(cx,cy,0,cx,cy,cr);
|
||||
core.addColorStop(0,'rgba(4,4,8,0.96)');
|
||||
core.addColorStop(0.55,'rgba(6,5,10,0.7)');
|
||||
core.addColorStop(1,'rgba(8,7,12,0)');
|
||||
ctx.fillStyle=core; ctx.beginPath(); ctx.arc(cx,cy,cr,0,TAU); ctx.fill();
|
||||
|
||||
// base glow pool
|
||||
ctx.globalCompositeOperation='lighter';
|
||||
blob(W*0.5, H*0.95, W*0.4, 'rgba(255,79,46,0.10)', 'rgba(255,79,46,0)');
|
||||
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
frame();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -8,11 +8,23 @@ import * as repo from '../../db/repos/dashboard_layout.js';
|
||||
export const router = Router();
|
||||
router.use(requireOwner);
|
||||
|
||||
const geomCell = z.object({
|
||||
x: z.number(), y: z.number(),
|
||||
w: z.number().positive(), h: z.number().positive(),
|
||||
free: z.boolean().optional()
|
||||
});
|
||||
const layoutSchema = z.object({
|
||||
card_order: z.array(z.string()).default([]),
|
||||
hidden: z.array(z.string()).default([]),
|
||||
// Per-card width: an integer column span 1–12 (legacy 's'|'m'|'l' still accepted).
|
||||
sizes: z.record(z.union([z.number().int().min(1).max(12), z.enum(['s', 'm', 'l'])])).default({})
|
||||
sizes: z.record(z.union([z.number().int().min(1).max(12), z.enum(['s', 'm', 'l'])])).default({}),
|
||||
// Hybrid-canvas geometry, keyed by card id → {x,y,w,h,free} in 12-col grid units.
|
||||
geom: z.record(geomCell).default({}),
|
||||
// User-added decorative card instances that must survive reloads.
|
||||
extras: z.array(z.object({
|
||||
id: z.string().min(1).max(64),
|
||||
type: z.enum(['blank', 'blackflame'])
|
||||
})).default([])
|
||||
});
|
||||
|
||||
router.get('/layout', asyncWrap(async (_req, res) => {
|
||||
|
||||
7
lib/db/migrations/027_dashboard_geom.sql
Normal file
7
lib/db/migrations/027_dashboard_geom.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- 027_dashboard_geom.sql
|
||||
-- Sacred Valley hybrid canvas: free/snap geometry per card + decorative card
|
||||
-- instances (blank spacers, blackflame). geom is keyed by card id →
|
||||
-- {x,y,w,h,free} in (fractional) 12-col grid units; extras lists the
|
||||
-- user-added decorative cards so they survive reloads.
|
||||
ALTER TABLE dashboard_layout ADD COLUMN IF NOT EXISTS geom jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||
ALTER TABLE dashboard_layout ADD COLUMN IF NOT EXISTS extras jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||
@@ -1,24 +1,28 @@
|
||||
import { pool } from '../pool.js';
|
||||
|
||||
const DEFAULTS = { card_order: [], hidden: [], sizes: {} };
|
||||
const DEFAULTS = { card_order: [], hidden: [], sizes: {}, geom: {}, extras: [] };
|
||||
|
||||
export async function get() {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT card_order, hidden, sizes FROM dashboard_layout WHERE owner_key = 'owner'`
|
||||
`SELECT card_order, hidden, sizes, geom, extras
|
||||
FROM dashboard_layout WHERE owner_key = 'owner'`
|
||||
);
|
||||
return rows[0] || { ...DEFAULTS };
|
||||
}
|
||||
|
||||
export async function put({ card_order = [], hidden = [], sizes = {} }) {
|
||||
export async function put({ card_order = [], hidden = [], sizes = {}, geom = {}, extras = [] }) {
|
||||
await pool.query(
|
||||
`INSERT INTO dashboard_layout (owner_key, card_order, hidden, sizes, updated_at)
|
||||
VALUES ('owner', $1::jsonb, $2::jsonb, $3::jsonb, now())
|
||||
`INSERT INTO dashboard_layout (owner_key, card_order, hidden, sizes, geom, extras, updated_at)
|
||||
VALUES ('owner', $1::jsonb, $2::jsonb, $3::jsonb, $4::jsonb, $5::jsonb, now())
|
||||
ON CONFLICT (owner_key) DO UPDATE
|
||||
SET card_order = EXCLUDED.card_order,
|
||||
hidden = EXCLUDED.hidden,
|
||||
sizes = EXCLUDED.sizes,
|
||||
geom = EXCLUDED.geom,
|
||||
extras = EXCLUDED.extras,
|
||||
updated_at = now()`,
|
||||
[JSON.stringify(card_order), JSON.stringify(hidden), JSON.stringify(sizes)]
|
||||
[JSON.stringify(card_order), JSON.stringify(hidden), JSON.stringify(sizes),
|
||||
JSON.stringify(geom), JSON.stringify(extras)]
|
||||
);
|
||||
return get();
|
||||
}
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "void-server",
|
||||
"version": "2.7.0",
|
||||
"version": "2.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "void-server",
|
||||
"version": "2.7.0",
|
||||
"version": "2.8.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "void-server",
|
||||
"version": "2.7.0",
|
||||
"version": "2.8.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { el } from '../dom.js';
|
||||
|
||||
// Builds the refined-B chrome shell and returns { root, body }. The card module
|
||||
// fills `body` in its mount(); start()/stop() own its refresh timer.
|
||||
// fills `body` in its mount(); start()/stop() own its refresh timer. Position +
|
||||
// size are set by the Sacred Valley canvas (absolute geometry), not here.
|
||||
// Decorative cards (blank / blackflame) carry no title bar.
|
||||
export function svCard(def) {
|
||||
const body = el('div', { class: 'sv-card-body' });
|
||||
const root = el('div', {
|
||||
class: 'sv-card', dataset: { cardId: def.id },
|
||||
style: { gridColumn: 'span ' + (def.span || 6) } // 12-col grid; per-card width
|
||||
},
|
||||
el('div', { class: 'sv-card-title' }, def.title),
|
||||
const root = el('div', { class: 'sv-card', dataset: { cardId: def.id } },
|
||||
def.title ? el('div', { class: 'sv-card-title' }, def.title) : null,
|
||||
body
|
||||
);
|
||||
return { root, body };
|
||||
|
||||
@@ -382,14 +382,9 @@ ul.plain li:last-child { border-bottom: none; }
|
||||
/* reserved for a future agent-output phase — unused now:
|
||||
--hue-dross: #ff4f2e; --hue-yerin: #c45a4a; --hue-orthos: #6fa86a; */
|
||||
}
|
||||
#sv-cards { display: grid; grid-template-columns: repeat(12, 1fr); gap: 16px; align-items: start; grid-auto-rows: 8px; grid-auto-flow: row dense; }
|
||||
.sv-card { grid-column: span 6; } /* fallback; svCard sets an inline span (1–12) */
|
||||
@media (max-width: 700px) { #sv-cards { grid-template-columns: 1fr; } .sv-card { grid-column: 1 / -1 !important; } }
|
||||
.sv-ed-span { display: inline-flex; align-items: center; gap: 3px; }
|
||||
.sv-ed-step { width: 18px; height: 20px; border: 1px solid var(--border); background: transparent; color: var(--muted);
|
||||
border-radius: 3px; font-size: 14px; line-height: 1; cursor: pointer; padding: 0; }
|
||||
.sv-ed-step:hover { color: var(--accent); border-color: var(--accent-dim); }
|
||||
.sv-span-val { font-family: var(--font-mono); font-size: 12px; color: var(--text); min-width: 14px; text-align: center; }
|
||||
/* Hybrid canvas: cards are absolutely placed (JS sets left/top/width/height in
|
||||
12-col grid units); the board grows to fit its content. See sacred_valley.js. */
|
||||
#sv-cards { position: relative; width: 100%; min-height: 200px; }
|
||||
|
||||
.sv-card {
|
||||
position: relative; border: 1px solid #2c242a; border-radius: 10px; padding: 16px 18px;
|
||||
@@ -407,8 +402,11 @@ ul.plain li:last-child { border-bottom: none; }
|
||||
border-color: #4a2c28; transform: translateY(-2px);
|
||||
box-shadow: 0 8px 28px rgba(0,0,0,.45), inset 0 0 46px rgba(255,79,46,.06), 0 0 0 1px rgba(255,79,46,.10);
|
||||
}
|
||||
.sv-card.dragging { opacity: .5; }
|
||||
.sv-card.drag-over { border-color: var(--accent); }
|
||||
.sv-card.dragging { transition: none; box-shadow: 0 16px 44px -12px #000, 0 0 0 1px var(--accent-dim); }
|
||||
.sv-card.free { box-shadow: 0 0 0 1px var(--accent-dim), 0 10px 30px -12px #000; }
|
||||
#sv-cards.editing .sv-card { transition: none; }
|
||||
#sv-cards.editing .sv-card:hover { transform: none; }
|
||||
.sv-card-body { height: 100%; overflow: auto; }
|
||||
.sv-card-title {
|
||||
font-family: var(--font-display); font-size: 13px; letter-spacing: .16em; text-transform: uppercase;
|
||||
color: var(--text); padding-bottom: 7px; margin-bottom: 12px;
|
||||
@@ -626,18 +624,38 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
|
||||
background: rgba(10,10,14,.88); border: 1px solid var(--border); border-radius: 6px; padding: 3px 5px; }
|
||||
#sv-cards.editing .sv-card-edit { display: flex; }
|
||||
#sv-cards.editing .sv-card { outline: 1px dashed var(--accent-dim); }
|
||||
.sv-grip { cursor: grab; color: var(--muted); font-size: 14px; padding: 0 2px; user-select: none; }
|
||||
.sv-grip { cursor: grab; color: var(--muted); font-size: 14px; padding: 0 2px; user-select: none; touch-action: none; }
|
||||
.sv-grip:active { cursor: grabbing; }
|
||||
.sv-ed-sizes { display: flex; gap: 2px; }
|
||||
.sv-ed-size, .sv-ed-hide { width: 20px; height: 20px; border: 1px solid var(--border); background: transparent;
|
||||
border-radius: 3px; font-size: 11px; cursor: pointer; padding: 0; line-height: 1; }
|
||||
.sv-ed-size { color: var(--muted); }
|
||||
.sv-ed-size:hover { color: var(--accent); border-color: var(--accent-dim); }
|
||||
.sv-card[data-size="s"] .sv-ed-size[data-s="s"],
|
||||
.sv-card[data-size="m"] .sv-ed-size[data-s="m"],
|
||||
.sv-card[data-size="l"] .sv-ed-size[data-s="l"] { background: var(--accent-dim); color: var(--text); border-color: var(--accent); }
|
||||
.sv-ed-hide { color: var(--bad); font-size: 12px; }
|
||||
.sv-ed-free, .sv-ed-hide { width: 20px; height: 20px; border: 1px solid var(--border); background: transparent;
|
||||
border-radius: 3px; font-size: 12px; cursor: pointer; padding: 0; line-height: 1; }
|
||||
.sv-ed-free { color: var(--muted); }
|
||||
.sv-ed-free:hover { color: var(--accent); border-color: var(--accent-dim); }
|
||||
.sv-card.free .sv-ed-free { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
|
||||
.sv-ed-hide { color: var(--bad); }
|
||||
.sv-ed-hide:hover { background: var(--bad); color: var(--bg); }
|
||||
/* resize handle — bottom-right corner, edit mode only */
|
||||
.sv-resize { display: none; position: absolute; right: 3px; bottom: 3px; width: 16px; height: 16px; z-index: 4;
|
||||
cursor: nwse-resize; touch-action: none; border-radius: 0 0 9px 0;
|
||||
background: linear-gradient(135deg, transparent 45%, var(--muted) 45% 55%, transparent 55%,
|
||||
transparent 62%, var(--muted) 62% 72%, transparent 72%); opacity: .6; }
|
||||
.sv-resize:hover { opacity: 1; }
|
||||
#sv-cards.editing .sv-resize { display: block; }
|
||||
.sv-tray-hint { font-family: var(--font-ui); font-size: 11px; margin-left: 6px; }
|
||||
|
||||
/* decorative cards (blank spacer / blackflame) — no chrome padding, full-bleed body */
|
||||
.sv-card-decor { padding: 0; overflow: hidden; }
|
||||
.sv-card-decor .sv-card-body { position: absolute; inset: 0; padding: 0; overflow: hidden; }
|
||||
.sv-card-decor:hover { transform: none; }
|
||||
.sv-blank { width: 100%; height: 100%;
|
||||
background-image: repeating-linear-gradient(45deg, rgba(255,255,255,.025) 0 9px, transparent 9px 18px); }
|
||||
.sv-flame-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; pointer-events: none; }
|
||||
.sv-flame-crest { position: absolute; top: 46%; left: 50%; width: 22%; aspect-ratio: 1; transform: translate(-50%,-50%);
|
||||
border-radius: 50%; pointer-events: none;
|
||||
background: radial-gradient(circle at 50% 45%, #000 38%, #0a0a10 60%, transparent 72%);
|
||||
box-shadow: 0 0 26px 10px #000, 0 0 60px 18px rgba(255,79,46,.13); }
|
||||
.sv-flame-label { position: absolute; left: 0; right: 0; bottom: 14px; text-align: center; pointer-events: none;
|
||||
font-family: var(--font-display); letter-spacing: .42em; text-indent: .42em; text-transform: uppercase;
|
||||
font-size: 14px; color: #f4ece9; text-shadow: 0 0 18px rgba(255,79,46,.55), 0 0 4px #000; }
|
||||
|
||||
#sv-tray { flex-wrap: wrap; align-items: center; gap: 8px; margin: 2px 0 18px; padding: 10px 12px;
|
||||
border: 1px dashed var(--border); border-radius: 8px; }
|
||||
|
||||
114
public/views/cards/blackflame.js
Normal file
114
public/views/cards/blackflame.js
Normal file
@@ -0,0 +1,114 @@
|
||||
// Decorative blackflame card — the animated centrepiece (dark heart, crimson
|
||||
// licks, rising embers). Canvas particle flame ported from the approved mock.
|
||||
// Instanceable via the factory; stop() tears down the rAF loop + observer.
|
||||
import { el } from '../../dom.js';
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
|
||||
function startFlame(canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
let W = 0, H = 0, DPR = 1, raf = 0, t = 0;
|
||||
|
||||
function resize() {
|
||||
DPR = Math.min(2, window.devicePixelRatio || 1);
|
||||
const r = canvas.getBoundingClientRect();
|
||||
W = r.width; H = r.height;
|
||||
canvas.width = Math.max(1, Math.round(W * DPR));
|
||||
canvas.height = Math.max(1, Math.round(H * DPR));
|
||||
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
|
||||
}
|
||||
|
||||
const tongues = Array.from({ length: 5 }, (_, i) => ({
|
||||
phase: Math.random() * TAU, speed: 0.6 + Math.random() * 0.5,
|
||||
x: 0.5 + (i - 2) * 0.085, sway: 0.03 + Math.random() * 0.04, w: 0.34 - Math.abs(i - 2) * 0.05
|
||||
}));
|
||||
const spawn = () => ({
|
||||
x: 0.5 + (Math.random() - 0.5) * 0.5, y: 1 + Math.random() * 0.2,
|
||||
vy: 0.0016 + Math.random() * 0.0030, vx: (Math.random() - 0.5) * 0.0012,
|
||||
r: 0.6 + Math.random() * 1.8, life: 0, max: 120 + Math.random() * 120, hot: Math.random() < 0.5
|
||||
});
|
||||
const embers = Array.from({ length: 46 }, spawn);
|
||||
|
||||
function blob(x, y, r, c0, c1) {
|
||||
if (r <= 0) return;
|
||||
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
|
||||
g.addColorStop(0, c0); g.addColorStop(1, c1);
|
||||
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, r, 0, TAU); ctx.fill();
|
||||
}
|
||||
|
||||
function frame() {
|
||||
t += 0.016;
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
const baseY = H * 0.92;
|
||||
for (const tg of tongues) {
|
||||
const cx = W * (tg.x + Math.sin(t * tg.speed + tg.phase) * tg.sway);
|
||||
const height = H * (0.62 + 0.08 * Math.sin(t * 1.7 + tg.phase));
|
||||
const steps = 26;
|
||||
for (let s = 0; s < steps; s++) {
|
||||
const f = s / steps;
|
||||
const y = baseY - f * height;
|
||||
const flick = Math.sin(t * 3.2 + tg.phase + f * 7) * W * 0.012 * (0.4 + f);
|
||||
const x = cx + flick + Math.sin(t * tg.speed * 1.3 + f * 4) * W * tg.sway * 0.6;
|
||||
const r = W * tg.w * (1 - f * 0.78) * (0.9 + 0.1 * Math.sin(t * 5 + f * 9));
|
||||
const a = (1 - f) * 0.5;
|
||||
if (f < 0.18) blob(x, y, r * 1.1, `rgba(60,12,6,${a * 0.7})`, 'rgba(60,12,6,0)');
|
||||
else if (f < 0.62) blob(x, y, r, `rgba(255,79,46,${a * 0.5})`, 'rgba(122,39,22,0)');
|
||||
else blob(x, y, r * 0.8, `rgba(255,150,90,${a * 0.5})`, 'rgba(255,79,46,0)');
|
||||
}
|
||||
}
|
||||
|
||||
for (const e of embers) {
|
||||
e.life++; e.y -= e.vy; e.x += e.vx + Math.sin(t * 2 + e.y * 8) * 0.0004;
|
||||
if (e.y < 0.18 || e.life > e.max) Object.assign(e, spawn());
|
||||
const px = e.x * W, py = e.y * H, fade = 1 - e.life / e.max;
|
||||
const col = e.hot ? `rgba(255,170,110,${fade * 0.9})` : `rgba(255,79,46,${fade * 0.8})`;
|
||||
ctx.shadowBlur = 8; ctx.shadowColor = '#ff4f2e';
|
||||
blob(px, py, e.r * 1.6, col, 'rgba(255,79,46,0)');
|
||||
}
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// carve the dark heart back in (the "black" of black-flame)
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
const cx = W * 0.5, cy = H * 0.46, cr = W * 0.20;
|
||||
const core = ctx.createRadialGradient(cx, cy, 0, cx, cy, cr);
|
||||
core.addColorStop(0, 'rgba(4,4,8,0.96)');
|
||||
core.addColorStop(0.55, 'rgba(6,5,10,0.7)');
|
||||
core.addColorStop(1, 'rgba(8,7,12,0)');
|
||||
ctx.fillStyle = core; ctx.beginPath(); ctx.arc(cx, cy, cr, 0, TAU); ctx.fill();
|
||||
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
blob(W * 0.5, H * 0.95, W * 0.4, 'rgba(255,79,46,0.10)', 'rgba(255,79,46,0)');
|
||||
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
resize();
|
||||
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null;
|
||||
ro?.observe(canvas);
|
||||
raf = requestAnimationFrame(frame);
|
||||
return () => { cancelAnimationFrame(raf); ro?.disconnect(); };
|
||||
}
|
||||
|
||||
export function blackflameCard(id) {
|
||||
let teardown = null;
|
||||
return {
|
||||
id,
|
||||
type: 'blackflame',
|
||||
title: '',
|
||||
decorative: true,
|
||||
size: 'l',
|
||||
mount(body) {
|
||||
const canvas = el('canvas', { class: 'sv-flame-canvas' });
|
||||
const crest = el('div', { class: 'sv-flame-crest' });
|
||||
const label = el('div', { class: 'sv-flame-label' }, 'The Void');
|
||||
body.append(canvas, crest, label);
|
||||
// defer one frame so the canvas has a measured size
|
||||
requestAnimationFrame(() => { teardown = startFlame(canvas); });
|
||||
},
|
||||
start() {},
|
||||
stop() { teardown && teardown(); teardown = null; }
|
||||
};
|
||||
}
|
||||
16
public/views/cards/blank.js
Normal file
16
public/views/cards/blank.js
Normal file
@@ -0,0 +1,16 @@
|
||||
// A decorative blank spacer card — deliberate empty space / grouping on the
|
||||
// Sacred Valley canvas. Instanceable: each gets a unique id via the factory.
|
||||
import { el } from '../../dom.js';
|
||||
|
||||
export function blankCard(id) {
|
||||
return {
|
||||
id,
|
||||
type: 'blank',
|
||||
title: '',
|
||||
decorative: true,
|
||||
size: 'm',
|
||||
mount(body) { body.appendChild(el('div', { class: 'sv-blank' })); },
|
||||
start() {},
|
||||
stop() {}
|
||||
};
|
||||
}
|
||||
@@ -3,8 +3,6 @@ import { api } from '../api.js';
|
||||
import { renderHealthBand, stopHealthBand } from './health_band.js';
|
||||
import { renderDevicesBand, stopDevicesBand } from './devices_band.js';
|
||||
import { svCard } from '../components/sv_card.js';
|
||||
import { moveId } from '../components/sv_reorder.js';
|
||||
import { orderCards } from './cards/registry.js';
|
||||
import clock from './cards/clock.js';
|
||||
import weather from './cards/weather.js';
|
||||
import hostPerf from './cards/host_perf.js';
|
||||
@@ -16,146 +14,228 @@ import aiUsage from './cards/ai_usage.js';
|
||||
import cluster from './cards/cluster.js';
|
||||
import storage from './cards/storage.js';
|
||||
import backups from './cards/backups.js';
|
||||
import { blankCard } from './cards/blank.js';
|
||||
import { blackflameCard } from './cards/blackflame.js';
|
||||
|
||||
const CARD_MODULES = [clock, weather, hostPerf, cluster, storage, backups, jobs, inbox, search, speedtest, aiUsage];
|
||||
const BY_ID = new Map(CARD_MODULES.map(d => [d.id, d]));
|
||||
const BUILTIN_BY_ID = new Map(CARD_MODULES.map(d => [d.id, d]));
|
||||
|
||||
// ---- hybrid canvas geometry ----
|
||||
// Cards are absolutely placed on a 12-column grid. {x,y,w,h} are in grid units
|
||||
// (x,w in columns; y,h in rows of ROW_H px). Snap mode keeps them integer; a
|
||||
// per-card `free` flag (or holding Alt while dragging) allows fractional
|
||||
// placement + overlap. Everything scales with board width, so x/w stay relative.
|
||||
const COLS = 12;
|
||||
const ROW_H = 28; // px per grid row
|
||||
const GUTTER = 12; // visual gap baked into each card's rendered size
|
||||
const SIZE_W = { s: 3, m: 4, l: 6 };
|
||||
const SIZE_H = { s: 6, m: 8, l: 10 };
|
||||
|
||||
let active = []; // mounted cards needing stop()
|
||||
let renderGen = 0; // guards overlapping async renders
|
||||
let editing = false;
|
||||
let layout = { card_order: [], hidden: [], sizes: {} };
|
||||
let mainEl;
|
||||
let layout = { hidden: [], geom: {}, extras: [] };
|
||||
|
||||
const grid = () => document.getElementById('sv-cards');
|
||||
|
||||
// ---- masonry packing: cards keep their column span (width) but pack vertically by
|
||||
// content height (via grid-row span over small auto-rows), so mismatched heights no
|
||||
// longer leave gaps / rigid rows. ResizeObserver re-packs as async cards fill in.
|
||||
const ROW_UNIT = 8, GRID_GAP = 16;
|
||||
function packCard(node) {
|
||||
if (!node || !node.isConnected) return;
|
||||
const h = node.getBoundingClientRect().height;
|
||||
if (h) node.style.gridRowEnd = 'span ' + Math.max(1, Math.ceil((h + GRID_GAP) / (ROW_UNIT + GRID_GAP)));
|
||||
function defFor(extra) {
|
||||
if (extra.type === 'blank') return blankCard(extra.id);
|
||||
if (extra.type === 'blackflame') return blackflameCard(extra.id);
|
||||
return null;
|
||||
}
|
||||
const ro = typeof ResizeObserver !== 'undefined'
|
||||
? new ResizeObserver(entries => entries.forEach(e => packCard(e.target))) : null;
|
||||
let repackRaf;
|
||||
function repackAll() {
|
||||
cancelAnimationFrame(repackRaf);
|
||||
repackRaf = requestAnimationFrame(() => grid()?.querySelectorAll('.sv-card').forEach(packCard));
|
||||
|
||||
function visibleDefs() {
|
||||
const hidden = new Set(layout.hidden || []);
|
||||
const builtins = CARD_MODULES.filter(d => !hidden.has(d.id));
|
||||
const extras = (layout.extras || []).map(defFor).filter(Boolean);
|
||||
return [...builtins, ...extras];
|
||||
}
|
||||
|
||||
function defaultSize(def) {
|
||||
if (def.type === 'blackflame') return { w: 6, h: 10 };
|
||||
if (def.type === 'blank') return { w: 3, h: 4 };
|
||||
return { w: SIZE_W[def.size] || 4, h: SIZE_H[def.size] || 8 };
|
||||
}
|
||||
|
||||
function geomOf(def) {
|
||||
const g = (layout.geom || {})[def.id];
|
||||
if (g) return g;
|
||||
const { w, h } = defaultSize(def);
|
||||
return { x: 0, y: 0, w, h };
|
||||
}
|
||||
if (typeof window !== 'undefined') window.addEventListener('resize', repackAll);
|
||||
|
||||
async function saveLayout() {
|
||||
try { await api.put('/api/dashboard/layout', layout); }
|
||||
catch (e) { console.error('save layout', e); }
|
||||
try {
|
||||
await api.put('/api/dashboard/layout', {
|
||||
card_order: [], sizes: {},
|
||||
hidden: layout.hidden || [], geom: layout.geom || {}, extras: layout.extras || []
|
||||
});
|
||||
} catch (e) { console.error('save layout', e); }
|
||||
}
|
||||
|
||||
// ---- per-card edit controls (drag grip + size + hide), shown only in edit mode via CSS
|
||||
const STR_SPAN = { s: 2, m: 6, l: 12 }; // legacy size → 12-col span (s = 1/6, m = 1/2, l = full)
|
||||
function spanOf(def) {
|
||||
const v = layout.sizes?.[def.id];
|
||||
if (typeof v === 'number') return Math.max(1, Math.min(12, v));
|
||||
if (typeof v === 'string') return STR_SPAN[v] || 6;
|
||||
return STR_SPAN[def.size] || 6;
|
||||
}
|
||||
function curSpan(id) {
|
||||
const node = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
|
||||
const m = node && (node.style.gridColumn || '').match(/span (\d+)/);
|
||||
return m ? +m[1] : spanOf(BY_ID.get(id) || {});
|
||||
}
|
||||
function setSpan(id, delta) {
|
||||
const span = Math.max(1, Math.min(12, curSpan(id) + delta));
|
||||
layout.sizes = { ...layout.sizes, [id]: span };
|
||||
const node = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
|
||||
if (node) {
|
||||
node.style.gridColumn = 'span ' + span;
|
||||
const lbl = node.querySelector('.sv-span-val');
|
||||
if (lbl) lbl.textContent = span;
|
||||
// Backfill geometry for any visible card lacking it (first load / migration /
|
||||
// a newly-shipped built-in). New cards flow below whatever already has a spot.
|
||||
function autoPlaceMissing(defs) {
|
||||
const geom = { ...(layout.geom || {}) };
|
||||
let baseY = 0;
|
||||
for (const id in geom) baseY = Math.max(baseY, geom[id].y + geom[id].h);
|
||||
let cx = 0, cy = baseY, rowH = 0;
|
||||
for (const d of defs) {
|
||||
if (geom[d.id]) continue;
|
||||
const { w, h } = defaultSize(d);
|
||||
if (cx + w > COLS) { cx = 0; cy += rowH; rowH = 0; }
|
||||
geom[d.id] = { x: cx, y: cy, w, h };
|
||||
cx += w; rowH = Math.max(rowH, h);
|
||||
}
|
||||
layout.geom = geom;
|
||||
}
|
||||
|
||||
function cellW() { return grid().clientWidth / COLS; }
|
||||
|
||||
function applyGeom(node, g) {
|
||||
const cw = cellW();
|
||||
node.style.position = 'absolute';
|
||||
node.style.left = (g.x * cw) + 'px';
|
||||
node.style.top = (g.y * ROW_H) + 'px';
|
||||
node.style.width = Math.max(40, g.w * cw - GUTTER) + 'px';
|
||||
node.style.height = Math.max(40, g.h * ROW_H - GUTTER) + 'px';
|
||||
node.style.zIndex = g.free ? 5 : 1;
|
||||
node.classList.toggle('free', !!g.free);
|
||||
}
|
||||
|
||||
function fitBoard() {
|
||||
let max = 0;
|
||||
grid().querySelectorAll('.sv-card').forEach(n => { max = Math.max(max, n.offsetTop + n.offsetHeight); });
|
||||
grid().style.height = (max + GUTTER) + 'px';
|
||||
}
|
||||
|
||||
function relayout() {
|
||||
active.forEach(def => {
|
||||
const n = grid().querySelector(`.sv-card[data-card-id="${def.id}"]`);
|
||||
if (n) applyGeom(n, geomOf(def));
|
||||
});
|
||||
fitBoard();
|
||||
}
|
||||
|
||||
// ---- drag / resize (pointer-based; snap unless free or Alt held) ----
|
||||
function beginDrag(ev, def, mode) {
|
||||
if (!editing) return;
|
||||
ev.preventDefault(); ev.stopPropagation();
|
||||
const node = grid().querySelector(`.sv-card[data-card-id="${def.id}"]`);
|
||||
if (!node) return;
|
||||
const g = { ...geomOf(def) };
|
||||
const start = { px: ev.clientX, py: ev.clientY, x: g.x, y: g.y, w: g.w, h: g.h };
|
||||
node.classList.add('dragging');
|
||||
const cw = cellW();
|
||||
|
||||
function moveTo(e) {
|
||||
const dxc = (e.clientX - start.px) / cw;
|
||||
const dyc = (e.clientY - start.py) / ROW_H;
|
||||
const freeNow = g.free || e.altKey;
|
||||
if (mode === 'move') {
|
||||
let nx = start.x + dxc, ny = start.y + dyc;
|
||||
if (!freeNow) { nx = Math.round(nx); ny = Math.round(ny); }
|
||||
g.x = Math.max(0, Math.min(COLS - g.w, nx));
|
||||
g.y = Math.max(0, ny);
|
||||
} else {
|
||||
let nw = start.w + dxc, nh = start.h + dyc;
|
||||
if (!freeNow) { nw = Math.round(nw); nh = Math.round(nh); }
|
||||
g.w = Math.max(2, Math.min(COLS - g.x, nw));
|
||||
g.h = Math.max(2, nh);
|
||||
}
|
||||
applyGeom(node, g); fitBoard();
|
||||
}
|
||||
function end() {
|
||||
document.removeEventListener('pointermove', moveTo);
|
||||
document.removeEventListener('pointerup', end);
|
||||
node.classList.remove('dragging');
|
||||
layout.geom = { ...layout.geom, [def.id]: g };
|
||||
applyGeom(node, g);
|
||||
saveLayout();
|
||||
}
|
||||
document.addEventListener('pointermove', moveTo);
|
||||
document.addEventListener('pointerup', end);
|
||||
}
|
||||
|
||||
function toggleFree(id) {
|
||||
const g = { ...(layout.geom[id] || geomOf({ id })) };
|
||||
g.free = !g.free;
|
||||
layout.geom = { ...layout.geom, [id]: g };
|
||||
const n = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
|
||||
if (n) applyGeom(n, g);
|
||||
saveLayout();
|
||||
}
|
||||
|
||||
function editOverlay(def) {
|
||||
const grip = el('span', { class: 'sv-grip', draggable: true, title: 'Drag to reorder' }, '⠿');
|
||||
const stepper = el('span', { class: 'sv-ed-span' },
|
||||
el('button', { class: 'sv-ed-step', title: 'Narrower', onclick: () => setSpan(def.id, -1) }, '−'),
|
||||
el('span', { class: 'sv-span-val', title: 'Width (of 12)' }, String(spanOf(def))),
|
||||
el('button', { class: 'sv-ed-step', title: 'Wider', onclick: () => setSpan(def.id, +1) }, '+'));
|
||||
const hide = el('button', { class: 'sv-ed-hide', title: 'Hide card', onclick: () => hideCard(def.id) }, '✕');
|
||||
return el('div', { class: 'sv-card-edit' }, grip, stepper, hide);
|
||||
const grip = el('span', { class: 'sv-grip', title: 'Drag to move' }, '⠿');
|
||||
grip.addEventListener('pointerdown', e => beginDrag(e, def, 'move'));
|
||||
const free = el('button', { class: 'sv-ed-free', title: 'Free / snap placement', onclick: () => toggleFree(def.id) }, '⤢');
|
||||
const hide = el('button', { class: 'sv-ed-hide', title: def.decorative ? 'Delete card' : 'Hide card', onclick: () => removeCard(def) }, '✕');
|
||||
const resize = el('span', { class: 'sv-resize', title: 'Drag to resize' });
|
||||
resize.addEventListener('pointerdown', e => beginDrag(e, def, 'resize'));
|
||||
const frag = document.createDocumentFragment();
|
||||
frag.append(el('div', { class: 'sv-card-edit' }, grip, free, hide), resize);
|
||||
return frag;
|
||||
}
|
||||
|
||||
function mountOne(def) {
|
||||
const span = spanOf(def);
|
||||
const { root, body } = svCard({ ...def, span });
|
||||
const { root, body } = svCard(def);
|
||||
if (def.decorative) root.classList.add('sv-card-decor');
|
||||
root.appendChild(editOverlay(def));
|
||||
applyGeom(root, geomOf(def));
|
||||
grid().appendChild(root);
|
||||
ro?.observe(root); packCard(root);
|
||||
try { def.mount(body); def.start && def.start(); active.push(def); }
|
||||
catch (e) { body.appendChild(el('span', { class: 'muted' }, 'card failed')); console.error(def.id, e); }
|
||||
}
|
||||
|
||||
function setSize(id, s) {
|
||||
layout.sizes = { ...layout.sizes, [id]: s };
|
||||
const node = grid().querySelector(`.sv-card[data-card-id="${id}"]`);
|
||||
if (node) node.dataset.size = s;
|
||||
saveLayout();
|
||||
function placeNew(def) {
|
||||
let maxBottom = 0;
|
||||
for (const id in layout.geom) { const g = layout.geom[id]; maxBottom = Math.max(maxBottom, g.y + g.h); }
|
||||
const { w, h } = defaultSize(def);
|
||||
layout.geom = { ...layout.geom, [def.id]: { x: 0, y: maxBottom, w, h } };
|
||||
}
|
||||
|
||||
function hideCard(id) {
|
||||
if (!layout.hidden.includes(id)) layout.hidden = [...layout.hidden, id];
|
||||
const def = BY_ID.get(id);
|
||||
if (def?.stop) def.stop();
|
||||
active = active.filter(d => d.id !== id);
|
||||
grid().querySelector(`.sv-card[data-card-id="${id}"]`)?.remove();
|
||||
renderTray();
|
||||
saveLayout();
|
||||
function addBuiltin(id) {
|
||||
layout.hidden = (layout.hidden || []).filter(x => x !== id);
|
||||
const def = BUILTIN_BY_ID.get(id);
|
||||
if (!def) return;
|
||||
placeNew(def); mountOne(def); relayout(); renderTray(); saveLayout();
|
||||
}
|
||||
|
||||
function showCard(id) {
|
||||
layout.hidden = layout.hidden.filter(x => x !== id);
|
||||
const def = BY_ID.get(id);
|
||||
if (def) { mountOne(def); wireDrag(); }
|
||||
renderTray();
|
||||
saveLayout();
|
||||
function addDecor(type) {
|
||||
const id = `${type}-${Date.now().toString(36)}`;
|
||||
const def = defFor({ id, type });
|
||||
if (!def) return;
|
||||
layout.extras = [...(layout.extras || []), { id, type }];
|
||||
placeNew(def); mountOne(def); relayout(); renderTray(); saveLayout();
|
||||
}
|
||||
|
||||
function onReorder(newOrder) {
|
||||
const frag = document.createDocumentFragment();
|
||||
newOrder.forEach(id => { const n = grid().querySelector(`.sv-card[data-card-id="${id}"]`); if (n) frag.appendChild(n); });
|
||||
grid().appendChild(frag);
|
||||
layout.card_order = newOrder;
|
||||
saveLayout();
|
||||
}
|
||||
|
||||
// Drag from the grip only; the rest of the card is inert so the search box etc. work.
|
||||
function wireDrag() {
|
||||
let dragId = null;
|
||||
grid().querySelectorAll('.sv-card').forEach(card => {
|
||||
if (card._wired) return; card._wired = true;
|
||||
const g = card.querySelector('.sv-grip');
|
||||
if (g) {
|
||||
g.addEventListener('dragstart', (e) => { dragId = card.dataset.cardId; card.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; });
|
||||
g.addEventListener('dragend', () => { card.classList.remove('dragging'); dragId = null; });
|
||||
}
|
||||
card.addEventListener('dragover', (e) => { if (dragId) { e.preventDefault(); card.classList.add('drag-over'); } });
|
||||
card.addEventListener('dragleave', () => card.classList.remove('drag-over'));
|
||||
card.addEventListener('drop', (e) => {
|
||||
e.preventDefault(); card.classList.remove('drag-over');
|
||||
if (!dragId || dragId === card.dataset.cardId) return;
|
||||
const ids = [...grid().querySelectorAll('.sv-card')].map(c => c.dataset.cardId);
|
||||
onReorder(moveId(ids, dragId, card.dataset.cardId));
|
||||
});
|
||||
});
|
||||
function removeCard(def) {
|
||||
const d = active.find(a => a.id === def.id);
|
||||
if (d && d.stop) d.stop();
|
||||
active = active.filter(a => a.id !== def.id);
|
||||
grid().querySelector(`.sv-card[data-card-id="${def.id}"]`)?.remove();
|
||||
if (def.decorative) {
|
||||
layout.extras = (layout.extras || []).filter(e => e.id !== def.id);
|
||||
const g = { ...layout.geom }; delete g[def.id]; layout.geom = g;
|
||||
} else if (!(layout.hidden || []).includes(def.id)) {
|
||||
layout.hidden = [...(layout.hidden || []), def.id];
|
||||
}
|
||||
renderTray(); fitBoard(); saveLayout();
|
||||
}
|
||||
|
||||
function renderTray() {
|
||||
const tray = document.getElementById('sv-tray');
|
||||
if (!tray) return;
|
||||
const hidden = layout.hidden.map(id => BY_ID.get(id)).filter(Boolean);
|
||||
const hidden = (layout.hidden || []).map(id => BUILTIN_BY_ID.get(id)).filter(Boolean);
|
||||
mount(tray,
|
||||
hidden.length ? el('span', { class: 'sv-tray-label' }, 'Hidden:') : el('span', { class: 'muted' }, 'No hidden cards'),
|
||||
...hidden.map(def => el('button', { class: 'sv-tray-chip', onclick: () => showCard(def.id) }, '+ ' + def.title)));
|
||||
el('span', { class: 'sv-tray-label' }, 'Add card:'),
|
||||
el('button', { class: 'sv-tray-chip', onclick: () => addDecor('blank') }, '+ Blank'),
|
||||
el('button', { class: 'sv-tray-chip', onclick: () => addDecor('blackflame') }, '+ Blackflame'),
|
||||
hidden.length ? el('span', { class: 'sv-tray-label', style: { marginLeft: '8px' } }, 'Restore:') : null,
|
||||
...hidden.map(def => el('button', { class: 'sv-tray-chip', onclick: () => addBuiltin(def.id) }, '+ ' + def.title)),
|
||||
el('span', { class: 'sv-tray-hint muted' }, 'drag ⠿ to move · corner to resize · ⤢ = free/overlap · Alt = no-snap'));
|
||||
tray.style.display = editing ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
@@ -169,9 +249,8 @@ function toggleEdit() {
|
||||
renderTray();
|
||||
}
|
||||
|
||||
let mainEl;
|
||||
async function resetLayout() {
|
||||
layout = { card_order: [], hidden: [], sizes: {} };
|
||||
layout = { hidden: [], geom: {}, extras: [] };
|
||||
await saveLayout();
|
||||
render(mainEl);
|
||||
}
|
||||
@@ -179,7 +258,7 @@ async function resetLayout() {
|
||||
export async function render(main) {
|
||||
mainEl = main;
|
||||
const myGen = ++renderGen;
|
||||
active.forEach(c => c.stop && c.stop()); active = []; ro?.disconnect(); stopHealthBand(); stopDevicesBand();
|
||||
active.forEach(c => c.stop && c.stop()); active = []; stopHealthBand(); stopDevicesBand();
|
||||
editing = false;
|
||||
mount(main,
|
||||
el('h1', { class: 'view-h1' }, 'Sacred Valley'),
|
||||
@@ -194,12 +273,21 @@ export async function render(main) {
|
||||
el('div', { id: 'sv-devices' })
|
||||
);
|
||||
|
||||
layout = { card_order: [], hidden: [], sizes: {} };
|
||||
try { layout = await api.get('/api/dashboard/layout'); } catch { /* defaults */ }
|
||||
layout = { hidden: [], geom: {}, extras: [] };
|
||||
try {
|
||||
const l = await api.get('/api/dashboard/layout');
|
||||
layout = { hidden: l.hidden || [], geom: l.geom || {}, extras: l.extras || [] };
|
||||
} catch { /* defaults */ }
|
||||
if (myGen !== renderGen) return;
|
||||
|
||||
for (const def of orderCards(CARD_MODULES, layout)) mountOne(def);
|
||||
wireDrag();
|
||||
const defs = visibleDefs();
|
||||
autoPlaceMissing(defs);
|
||||
for (const def of defs) mountOne(def);
|
||||
relayout();
|
||||
renderTray();
|
||||
window.removeEventListener('resize', relayout);
|
||||
window.addEventListener('resize', relayout);
|
||||
|
||||
renderHealthBand(document.getElementById('sv-health'));
|
||||
renderDevicesBand(document.getElementById('sv-devices'));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user