460 lines
24 KiB
HTML
460 lines
24 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Stackchain Dashboard</title>
|
|
<style>
|
|
:root { color-scheme: dark; --bg:#050c15; --panel:#0b1526; --line:#1b2d45; --text:#e5e7eb; --accent:#60a5fa; }
|
|
* { box-sizing: border-box; }
|
|
html, body { height: 100%; margin: 0; background: var(--bg); color: var(--text); font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; overflow-x: hidden; }
|
|
#bg { position: fixed; inset: 0; z-index: 0; pointer-events: none; opacity: .35; }
|
|
header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex; gap:16px; align-items:center; justify-content:space-between; background: linear-gradient(180deg, rgba(11,21,38,.95), rgba(11,21,38,.55), transparent); backdrop-filter: blur(4px); border-bottom: 1px solid #1b2d45; }
|
|
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
|
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
|
|
button:hover { filter: brightness(1.15); }
|
|
.panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); }
|
|
.panel > summary { cursor: pointer; list-style-position: inside; }
|
|
.panel > summary h2 { display: inline-block; margin-left: 4px; }
|
|
.panel[open] > summary { margin-bottom: 8px; }
|
|
aside, section { position: relative; z-index: 1; }
|
|
main { position: relative; z-index: 1; display: grid; grid-template-columns: 300px 1fr 340px; gap: 14px; padding: 14px; }
|
|
@media (max-width: 1100px) { main { grid-template-columns: 1fr; } }
|
|
.sidebar { display:flex; flex-direction:column; gap: 12px; }
|
|
.stack { display:flex; flex-direction:column; gap:10px; }
|
|
h2 { font-size: 13px; margin: 8px 0; text-transform: uppercase; letter-spacing: .08em; color: #94a3b8; }
|
|
a { color: #60a5fa; text-decoration: none; }
|
|
.muted { color: #94a3b8; }
|
|
.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; }
|
|
.kv .label { color: #94a3b8; }
|
|
.kv .value { color: #e5e7eb; }
|
|
.suggestion { padding: 10px; border-radius: 12px; border: 1px solid #1b2d45; background: #0f1d33; margin-bottom: 8px; animation: fadein .35s ease; }
|
|
.suggestion.high { border-color: #fca5a5; background: #2a1216; }
|
|
.suggestion.medium { border-color: #fcd34d; background: #2a2012; }
|
|
.suggestion.low { border-color: #d1fae5; background: #122a1e; }
|
|
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items:center; }
|
|
.pill { display:inline-block; padding: 2px 8px; border-radius: 999px; background:#0f2237; color:#9ab3d4; border:1px solid #1f3a5f; font-size: 12px; }
|
|
canvas { display: block; width: 100%; height: 260px; background: linear-gradient(180deg,#0a1525,#060c16); border: 1px solid #1f3a5f; border-radius: 10px; }
|
|
pre { margin: 0; padding: 8px; background: #0b1220; color: #dbe7f2; border-radius: 8px; overflow: auto; max-height: 220px; font-size: 12px; border:1px solid #1f3a5f }
|
|
input[type=\"text\"], textarea { width: 100%; padding: 8px; border-radius: 8px; border: 1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
|
textarea { resize: vertical; min-height: 120px; }
|
|
#cmd-palette { position: fixed; left: 50%; top: 10%; transform: translateX(-50%); width: min(900px, 94vw); background: rgba(11,21,38,.96); border: 1px solid #2a496e; border-radius: 12px; box-shadow: 0 20px 70px rgba(0,0,0,.55); padding: 10px; z-index: 30; display: none; backdrop-filter: blur(12px); }
|
|
#cmd-palette.open { display: block; }
|
|
.cmd-item { padding: 10px; cursor: pointer; border-radius: 10px; color:#e5e7eb; }
|
|
.cmd-item:hover { background: #10233a; }
|
|
#whiteboard-modal, #markdown-modal { position: fixed; inset: 0; background: rgba(5,12,21,.55); display: none; align-items: center; justify-content: center; z-index: 40; backdrop-filter: blur(6px); }
|
|
#whiteboard-modal.open, #markdown-modal.open { display: flex; }
|
|
.modal { background: #0b1526; border: 1px solid #2a496e; border-radius: 14px; padding: 14px; width: min(1100px, 94vw); max-height: 92vh; overflow: auto; }
|
|
.modal-actions { display: flex; gap: 10px; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
|
.badge { font-size: 11px; padding: 4px 8px; border-radius: 999px; background:#0f2237; color:#9ab3d4; border:1px solid #1f3a5f; }
|
|
.small { font-size: 12px; color: #94a3b8; }
|
|
.status { display:inline-flex; gap:6px; align-items:center; }
|
|
.dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 8px #22c55e; }
|
|
.widget { border: 1px solid #1b2d45; border-radius: 12px; padding: 10px; background: linear-gradient(180deg,#0f1d33,#0b1526); }
|
|
.widget h3 { margin: 4px 0 8px; font-size: 13px; color: #7aa1c9; }
|
|
.event { padding: 8px 0; border-bottom: 1px solid #1b2d45; }
|
|
.event:last-child { border-bottom: 0; }
|
|
.my-work { grid-column: 1 / -1; }
|
|
.my-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
|
|
.work-filters { display:flex; gap:8px; flex-wrap:wrap; }
|
|
.work-filter { min-height: 44px; }
|
|
.work-filter[aria-pressed="true"] { border-color:var(--accent); background:#1d4f7a; }
|
|
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
|
|
.my-work-card { min-height: 44px; display:block; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
|
|
.my-work-card:hover { border-color:var(--accent); }
|
|
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
|
.my-work[data-stale="true"] { border-color:#fcd34d; }
|
|
@media (max-width: 600px) {
|
|
header { align-items:flex-start; }
|
|
.my-work { margin:0; }
|
|
.my-work-list { grid-template-columns:1fr; }
|
|
.work-filters { width:100%; }
|
|
.work-filter { flex:1 1 calc(50% - 8px); }
|
|
}
|
|
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
|
|
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
|
|
@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<canvas id="bg"></canvas>
|
|
<header>
|
|
<div><span class="obi"></span> <strong style="margin-left:6px;">Stackchain</strong> <span class="muted">Dashboard</span></div>
|
|
<div class="toolbar">
|
|
<div class="status"><span class="dot"></span><span class="small" id="status">Live</span></div>
|
|
<button id="refresh">Refresh</button>
|
|
<button id="open-palette">Command</button>
|
|
<span class="small muted" id="clock"></span>
|
|
</div>
|
|
</header>
|
|
|
|
<main>
|
|
<section class="panel my-work" id="my-work">
|
|
<div class="my-work-header">
|
|
<div>
|
|
<h2>My Work</h2>
|
|
<div class="small" id="my-work-status" aria-live="polite">Loading assigned work…</div>
|
|
</div>
|
|
<div class="work-filters" aria-label="Filter My Work">
|
|
<button class="work-filter" data-work-filter="all" aria-pressed="true">All <span data-work-count="all">0</span></button>
|
|
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
|
|
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
|
|
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews <span data-work-count="review">0</span></button>
|
|
</div>
|
|
</div>
|
|
<div class="my-work-list" id="my-work-list"></div>
|
|
</section>
|
|
<aside class="sidebar">
|
|
<details class="panel stack" data-panel-key="context" open>
|
|
<summary><h2>Context & view</h2></summary>
|
|
<div>
|
|
<h2>Context</h2>
|
|
<div class="muted" id="context">Loading…</div>
|
|
</div>
|
|
<div>
|
|
<h2>View</h2>
|
|
<div class="muted" id="view-hint">—</div>
|
|
</div>
|
|
</details>
|
|
<details class="panel stack" data-panel-key="ai-stream" open>
|
|
<summary><h2>AI Stream</h2></summary>
|
|
<div id="ai" class="muted">Loading…</div>
|
|
</details>
|
|
</aside>
|
|
|
|
<section class="stack" id="work">
|
|
<details class="panel" id="issues" data-panel-key="issues" open>
|
|
<summary><h2>Issues</h2></summary>
|
|
<div id="issues-content" class="muted">Loading…</div>
|
|
</details>
|
|
<details class="panel" id="prs" data-panel-key="pull-requests" open>
|
|
<summary><h2>Pull Requests</h2></summary>
|
|
<div id="prs-content" class="muted">Loading…</div>
|
|
</details>
|
|
<details class="panel" data-panel-key="gitea-events" open>
|
|
<summary><h2>Gitea event stream</h2></summary>
|
|
<div id="gitea-events" class="muted" aria-live="polite">Loading…</div>
|
|
<div id="gitea-events-status" class="small" aria-live="polite">Waiting for activity…</div>
|
|
</details>
|
|
<details class="panel" data-panel-key="widgets" open>
|
|
<summary><h2>Live Widgets</h2></summary>
|
|
<div id="widgets" class="stack">
|
|
<div class="widget">
|
|
<h3>Live clock</h3>
|
|
<div class="small" id="widget-clock">—</div>
|
|
</div>
|
|
<div class="widget">
|
|
<h3>Repo mix</h3>
|
|
<div class="small" id="repo-mix">Loading…</div>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
</section>
|
|
|
|
<aside class="sidebar">
|
|
<details class="panel stack" data-panel-key="layout" open>
|
|
<summary><h2>Layout</h2></summary>
|
|
<div class="muted" id="layout-hint">—</div>
|
|
</details>
|
|
<details class="panel stack" data-panel-key="markdown" open>
|
|
<summary><h2>Markdown</h2></summary>
|
|
<textarea id="md-input" placeholder="# Write markdown here...
|
|
- bullet
|
|
- **bold**
|
|
- `code`"></textarea>
|
|
<div id="md-preview" style="margin-top:8px;"></div>
|
|
</details>
|
|
</aside>
|
|
</main>
|
|
|
|
<div id="cmd-palette" role="dialog" aria-label="Command palette">
|
|
<input id="cmd-input" type="text" placeholder="Type a command, e.g. whiteboard, markdown..." />
|
|
<div id="cmd-results" class="stack" style="margin-top:8px;"></div>
|
|
</div>
|
|
|
|
<div id="whiteboard-modal">
|
|
<div class="modal">
|
|
<div class="modal-actions">
|
|
<strong>Whiteboard</strong>
|
|
<div>
|
|
<span class="badge">Click & drag to draw</span>
|
|
<button id="close-whiteboard">Close</button>
|
|
</div>
|
|
</div>
|
|
<canvas id="wb"></canvas>
|
|
<div class="row" style="margin-top:8px;">
|
|
<button id="wb-clear">Clear</button>
|
|
<button id="wb-save">Save PNG</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
|
|
|
<script src="static/markdown.js"></script>
|
|
<script src="static/commands.js"></script>
|
|
<script src="static/widgets.js"></script>
|
|
<script src="static/my-work.js"></script>
|
|
<script src="static/context-poller.js"></script>
|
|
<script>
|
|
(function(){
|
|
const qs = (s, el=document) => el.querySelector(s);
|
|
const fmt = (d) => new Date(d).toLocaleString();
|
|
const PANEL_STATE_KEY = "stackchain.panel-state.v1";
|
|
const panels = Array.from(document.querySelectorAll('details[data-panel-key]'));
|
|
let savedState = {};
|
|
try {
|
|
savedState = JSON.parse(localStorage.getItem(PANEL_STATE_KEY) || '{}');
|
|
} catch (e) {
|
|
savedState = {};
|
|
}
|
|
panels.forEach((panel) => {
|
|
if (Object.prototype.hasOwnProperty.call(savedState, panel.dataset.panelKey)) {
|
|
panel.open = savedState[panel.dataset.panelKey];
|
|
}
|
|
panel.addEventListener('toggle', () => {
|
|
const state = Object.fromEntries(panels.map((item) => [item.dataset.panelKey, item.open]));
|
|
try {
|
|
localStorage.setItem(PANEL_STATE_KEY, JSON.stringify(state));
|
|
} catch (e) {
|
|
console.warn('Could not persist panel state', e);
|
|
}
|
|
});
|
|
});
|
|
let liveMode = true;
|
|
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
|
|
let selectedWorkFilter = 'all';
|
|
try {
|
|
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
|
|
if (['all', 'issue', 'pull', 'review'].includes(savedFilter)) selectedWorkFilter = savedFilter;
|
|
} catch (e) {
|
|
console.warn('Could not restore My Work filter', e);
|
|
}
|
|
let lastMyWork = [];
|
|
let hasContextSnapshot = false;
|
|
|
|
function setStatus(msg) { qs('#status').textContent = msg || 'Live'; }
|
|
function setClock() { qs('#clock').textContent = fmt(new Date()); }
|
|
setClock(); setInterval(setClock, 1000);
|
|
|
|
async function fetchContextSnapshot() {
|
|
const res = await fetch('api/v1/context', { headers: { Accept: 'application/json' } });
|
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
return res.json();
|
|
}
|
|
|
|
function renderContextSnapshot(data) {
|
|
liveMode = true;
|
|
hasContextSnapshot = true;
|
|
if (data.error && lastMyWork.length) markMyWorkStale();
|
|
else paintMyWork(data);
|
|
qs('#context').innerHTML = '<div class="kv"><div class="label">User</div><div class="value">' + escapeHtml(data.user?.full_name || data.user?.login || '—') + '</div>' +
|
|
'<div class="label">Repos</div><div class="value">' + (data.repos?.length || 0) + '</div>' +
|
|
'<div class="label">Issues</div><div class="value">' + (data.issues?.length || 0) + '</div>' +
|
|
'<div class="label">PRs</div><div class="value">' + (data.pull_requests?.length || 0) + '</div></div>';
|
|
qs('#view-hint').textContent = 'Active view: ' + (data.view || 'dashboard');
|
|
|
|
const issuesBox = qs('#issues-content');
|
|
const openIssues = (data.issues || []).filter(i => i.state === 'open').slice(0, 12);
|
|
issuesBox.innerHTML = (openIssues.length ? openIssues.map(i => '<div style="margin:6px 0;"><a href="' + escAttr(i.url) + '" target="_blank">#' + i.number + ' ' + escapeHtml(i.title) + '</a>' +
|
|
'<div class="muted">' + (i.labels || []).map(l => '<span class="pill">' + escapeHtml(String(l)) + '</span>').join(' ') + '</div></div>').join('') : '<div class="muted">No open issues.</div>');
|
|
|
|
const prsBox = qs('#prs-content');
|
|
const openPrs = (data.pull_requests || []).slice(0, 12);
|
|
prsBox.innerHTML = (openPrs.length ? openPrs.map(p => '<div style="margin:6px 0;"><a href="' + escAttr(p.url) + '" target="_blank">#' + p.number + ' ' + escapeHtml(p.title) + '</a>' +
|
|
'<div class="muted">' + escapeHtml(p.state) + ' by ' + escapeHtml(String(p.user || '')) + '</div></div>').join('') : '<div class="muted">No PRs.</div>');
|
|
|
|
paintDeltas(data.deltas || []);
|
|
qs('#layout-hint').innerHTML = '<div class="kv"><div class="label">Active view</div><div class="value">' + escapeHtml(data.view || 'dashboard') + '</div><div class="label">Deltas</div><div class="value">' + (data.deltas||[]).length + '</div></div>';
|
|
renderRepoMix(qs('#repo-mix'), data);
|
|
setStatus(data.error ? 'Degraded · ' + data.error : 'Live · updated just now');
|
|
setClock();
|
|
}
|
|
|
|
function handleContextError(e) {
|
|
console.error('context failed', e);
|
|
liveMode = false;
|
|
setStatus(hasContextSnapshot ? 'Update failed · showing last snapshot' : 'Unavailable');
|
|
if (!hasContextSnapshot) {
|
|
qs('#context').innerHTML = '<div class="muted">Context unavailable.</div>';
|
|
qs('#view-hint').textContent = 'Active view unavailable.';
|
|
qs('#issues-content').innerHTML = '<div class="muted">Work items unavailable.</div>';
|
|
qs('#prs-content').innerHTML = '<div class="muted">Work items unavailable.</div>';
|
|
paintDeltas([]);
|
|
}
|
|
markMyWorkStale();
|
|
}
|
|
|
|
function paintMyWork(data) {
|
|
lastMyWork = buildMyWork(data);
|
|
const counts = countMyWork(lastMyWork);
|
|
Object.entries(counts).forEach(([filter, count]) => {
|
|
const element = qs('[data-work-count="' + filter + '"]');
|
|
if (element) element.textContent = count;
|
|
});
|
|
qs('#my-work').removeAttribute('data-stale');
|
|
qs('#my-work-status').textContent = lastMyWork.length ?
|
|
summarizeMyWork(lastMyWork) : 'No assigned work or review requests.';
|
|
renderMyWork();
|
|
}
|
|
|
|
function renderMyWork() {
|
|
const visible = filterMyWork(lastMyWork, selectedWorkFilter);
|
|
qs('#my-work-list').innerHTML = visible.length ? visible.map(item =>
|
|
'<a class="my-work-card" href="' + escAttr(item.url) + '" target="_blank" rel="noopener noreferrer">' +
|
|
'<span class="small">' + escapeHtml(item.key) + ' · ' + escapeHtml(item.kind === 'pull' ? 'PR' : 'Issue') + '</span>' +
|
|
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
|
|
'<span class="pill">' + escapeHtml(item.reason) + '</span>' +
|
|
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '') +
|
|
'</a>'
|
|
).join('') : '<div class="muted">No ' + (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items')) + '.</div>';
|
|
}
|
|
|
|
function markMyWorkStale() {
|
|
qs('#my-work').setAttribute('data-stale', 'true');
|
|
qs('#my-work-status').textContent = lastMyWork.length ?
|
|
'Update failed · showing last known work' : 'Work inbox unavailable.';
|
|
}
|
|
|
|
function paintDeltas(deltas) {
|
|
const el = qs('#ai');
|
|
el.innerHTML = deltas.length ? deltas.map(d => '<div class="suggestion ' + d.priority + '"><span class="pill">' + escapeHtml(d.priority) + '</span> <strong>' + escapeHtml(d.action) + '</strong> ' + escapeHtml(d.target || '') + '<div class="muted">' + escapeHtml(d.panel) + '</div></div>').join('') : '<div class="muted">No suggestions yet.</div>';
|
|
}
|
|
|
|
function paintEventStream(events) {
|
|
const el = qs('#gitea-events');
|
|
el.innerHTML = events.length ? events.slice(0, 12).map(event =>
|
|
'<div class="event"><strong>' + escapeHtml(event.actor?.login || 'Gitea') + '</strong> ' +
|
|
escapeHtml(event.type || 'activity') +
|
|
'<div class="small">' + escapeHtml(event.repo?.full_name || '') +
|
|
(event.created_at ? ' · ' + escapeHtml(fmt(event.created_at)) : '') + '</div></div>'
|
|
).join('') : '<div class="muted">No recent Gitea activity.</div>';
|
|
}
|
|
|
|
function setEventStreamStatus(message) {
|
|
qs('#gitea-events-status').textContent = message;
|
|
}
|
|
|
|
async function loadEventStream() {
|
|
setEventStreamStatus('Updating…');
|
|
try {
|
|
const res = await fetch('api/v1/events', { headers: { Accept: 'application/json' } });
|
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
paintEventStream(await res.json());
|
|
setEventStreamStatus('Updated ' + fmt(new Date()));
|
|
} catch (e) {
|
|
setEventStreamStatus('Update failed · showing last activity');
|
|
}
|
|
}
|
|
|
|
|
|
function escapeHtml(s) { return String(s || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
|
function escAttr(s) { return escapeHtml(s); }
|
|
function openModal(id) { qs('#' + id).classList.add('open'); }
|
|
function closeModal(id) { qs('#' + id).classList.remove('open'); }
|
|
|
|
/* Creative ambient background */
|
|
(function bg(){
|
|
const c=qs('#bg'),ctx=c.getContext('2d');
|
|
let w,h,time=0;
|
|
const resize=()=>{ w=c.width=innerWidth; h=c.height=innerHeight; };
|
|
resize(); addEventListener('resize', resize);
|
|
const draw=()=>{
|
|
time+=0.012;
|
|
ctx.clearRect(0,0,w,h);
|
|
ctx.strokeStyle='#1f3a5f'; ctx.lineWidth=1;
|
|
const step=60;
|
|
for(let x=0;x<w;x+=step){ ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,h); ctx.stroke(); }
|
|
for(let y=0;y<h;y+=step){ ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.stroke(); }
|
|
const cx=w/2+Math.sin(time*0.31)*90, cy=h/2+Math.cos(time*0.37)*70;
|
|
const grad=ctx.createRadialGradient(cx,cy,20,cx,cy,260);
|
|
grad.addColorStop(0,'rgba(96,165,250,.28)'); grad.addColorStop(1,'rgba(96,165,250,0)');
|
|
ctx.fillStyle=grad; ctx.fillRect(0,0,w,h);
|
|
requestAnimationFrame(draw);
|
|
};
|
|
draw();
|
|
})();
|
|
|
|
/* Whiteboard */
|
|
function initWhiteboard() {
|
|
const canvas = qs('#wb'), ctx = canvas.getContext('2d');
|
|
let drawing = false;
|
|
function resize() { const dpr = window.devicePixelRatio||1; const r=canvas.getBoundingClientRect(); canvas.width=r.width*dpr; canvas.height=r.height*dpr; ctx.setTransform(dpr,0,0,dpr,0,0); ctx.lineCap='round'; ctx.lineJoin='round'; ctx.strokeStyle='#e5e7eb'; ctx.lineWidth=2; }
|
|
resize(); addEventListener('resize', resize);
|
|
const p = (e) => ({ x: e.clientX - canvas.getBoundingClientRect().left, y: e.clientY - canvas.getBoundingClientRect().top });
|
|
canvas.addEventListener('pointerdown', (e)=>{ drawing=true; const pos=p(e); ctx.beginPath(); ctx.moveTo(pos.x, pos.y); });
|
|
canvas.addEventListener('pointermove', (e)=>{ if (!drawing) return; const pos=p(e); ctx.lineTo(pos.x, pos.y); ctx.stroke(); });
|
|
canvas.addEventListener('pointerup', () => drawing=false);
|
|
canvas.addEventListener('pointerleave', () => drawing=false);
|
|
qs('#wb-clear').addEventListener('click', () => { const r=canvas.getBoundingClientRect(); ctx.clearRect(0,0,r.width,r.height); });
|
|
qs('#wb-save').addEventListener('click', () => { const a=document.createElement('a'); a.href=canvas.toDataURL(); a.download='whiteboard.png'; a.click(); });
|
|
}
|
|
|
|
/* Markdown */
|
|
qs('#md-input').addEventListener('input', renderMD);
|
|
function renderMD() {
|
|
const raw = qs('#md-input').value || '';
|
|
qs('#md-preview').innerHTML = '<pre>' + escapeHtml(raw) + '</pre><div style="margin-top:8px;">' + renderMarkdown(raw) + '</div>';
|
|
}
|
|
|
|
/* Commands */
|
|
const commands = [
|
|
{ name: 'Open whiteboard', run: () => { openModal('whiteboard-modal'); initWhiteboard(); } },
|
|
{ name: 'Open markdown widget', run: () => { qs('#md-input').focus(); } },
|
|
{ name: 'Refresh now', run: load },
|
|
{ name: 'Scroll issues', run: () => qs('#work').scrollIntoView({ behavior:'smooth', block:'start' }) },
|
|
];
|
|
function renderCommands(filter) {
|
|
const el = qs('#cmd-results');
|
|
const items = filterCommands(commands, filter);
|
|
el.innerHTML = items.map((c, idx) => '<div class="cmd-item" data-idx="' + idx + '">' + escapeHtml(c.name) + '</div>').join('');
|
|
el.querySelectorAll('.cmd-item').forEach((item) => {
|
|
item.addEventListener('click', () => { items[Number(item.dataset.idx)].run(); qs('#cmd-palette').classList.remove('open'); qs('#cmd-input').value=''; });
|
|
});
|
|
}
|
|
qs('#open-palette').addEventListener('click', () => { qs('#cmd-palette').classList.add('open'); qs('#cmd-input').focus(); renderCommands(''); });
|
|
qs('#cmd-input').addEventListener('input', (e) => renderCommands(e.target.value));
|
|
document.addEventListener('keydown', (e) => { if ((e.metaKey||e.ctrlKey) && e.key==='k') { e.preventDefault(); qs('#cmd-palette').classList.toggle('open'); if(qs('#cmd-palette').classList.contains('open')){ qs('#cmd-input').focus(); renderCommands(''); } } });
|
|
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
|
|
|
|
const contextPoller = createContextPoller({
|
|
fetchContext: fetchContextSnapshot,
|
|
onSnapshot: renderContextSnapshot,
|
|
onError: handleContextError,
|
|
isHidden: () => document.hidden,
|
|
intervalMs: 8000,
|
|
});
|
|
function load() { return contextPoller.refresh(); }
|
|
|
|
qs('#refresh').addEventListener('click', load);
|
|
document.querySelectorAll('[data-work-filter]').forEach(button => {
|
|
button.setAttribute('aria-pressed', String(button.dataset.workFilter === selectedWorkFilter));
|
|
button.addEventListener('click', () => {
|
|
selectedWorkFilter = button.dataset.workFilter;
|
|
try {
|
|
sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter);
|
|
} catch (e) {
|
|
console.warn('Could not persist My Work filter', e);
|
|
}
|
|
document.querySelectorAll('[data-work-filter]').forEach(item =>
|
|
item.setAttribute('aria-pressed', String(item === button))
|
|
);
|
|
renderMyWork();
|
|
});
|
|
});
|
|
contextPoller.start();
|
|
loadEventStream();
|
|
setInterval(() => { if (!document.hidden) loadEventStream(); }, 5000);
|
|
document.addEventListener('visibilitychange', () => {
|
|
contextPoller.setVisible(!document.hidden);
|
|
if (!document.hidden) loadEventStream();
|
|
});
|
|
|
|
/* Widgets */
|
|
function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); }
|
|
setInterval(widgetTick, 1000);
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|