the project lead's rule, 4 October 2026: the mark sits in a black square, never in a circle, never on another colour, 20% clear space; the Mac app is the model. brand/master/igneum-mark-square.svg (1024, #0C0C0E, the exact header polygons at 62%) and igneum-mark-square-rounded.svg (Apple's 824-on-1024 grid, DMG volume icon only). make-icons.py now rasterises the masters (rsvg-convert if installed, else Pillow draws the polygons) into igneum.icns (plain square, 16 to 1024), igneum-volume.icns, igneum.ico (each size from the vector), the Inno art, the DMG background, site favicons (favicon.ico 16/32/48, favicon-32, apple-touch 180, 192, 512, maskable 512 + manifest entry), relay favicons, and brand/profile (400/512/1024, github-org-512, X banner). Every page head's inline SVG favicon and the relay header lose the ring. The .rc and Info.plist paths are unchanged (same file names). docs/brand/before holds the old set. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
331 lines
26 KiB
HTML
331 lines
26 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
<title>Igneum relay</title>
|
|
<meta name="robots" content="noindex, nofollow">
|
|
<meta name="theme-color" content="#0C0C0E">
|
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Crect width='1024' height='1024' fill='%230C0C0E'/%3E%3Cg transform='translate(166.95 166.95) scale(6.901)'%3E%3Cpolygon points='50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34' fill='%23F2541B'/%3E%3Cpolygon points='50,42 59,58 50,82 41,58' fill='%230C0C0E'/%3E%3C/g%3E%3C/svg%3E" type="image/svg+xml">
|
|
<link rel="icon" href="/favicon.ico" sizes="48x48">
|
|
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32">
|
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Unbounded:wght@700;900&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
|
|
<style>
|
|
:root{--obsidian:#0C0C0E;--graphite:#16161A;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;--ember-ink:#0C0C0E;--gutter:16px;--r:14px}
|
|
*{box-sizing:border-box}
|
|
html,body{margin:0;background:var(--obsidian);color:var(--bone)}
|
|
body{font-family:'IBM Plex Sans',system-ui,-apple-system,sans-serif;font-size:15px;line-height:1.5;-webkit-font-smoothing:antialiased;padding-bottom:env(safe-area-inset-bottom)}
|
|
a{color:inherit;text-decoration:none}
|
|
button{font:inherit;color:inherit;cursor:pointer}
|
|
.mono{font-family:'IBM Plex Mono',monospace}
|
|
.wrap{max-width:860px;margin:0 auto;padding-inline:var(--gutter)}
|
|
header{position:sticky;top:0;z-index:5;background:rgba(12,12,14,.88);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid var(--line)}
|
|
header .wrap{display:flex;align-items:center;justify-content:space-between;min-height:56px;gap:12px}
|
|
.brand{display:flex;align-items:center;gap:10px}
|
|
.brand .word{font-family:'Unbounded',sans-serif;font-weight:900;font-size:16px;letter-spacing:.06em}
|
|
.brand .sub{font-family:'IBM Plex Mono',monospace;font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--ash)}
|
|
.status{font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--ash);display:flex;align-items:center;gap:8px;white-space:nowrap}
|
|
.dot{width:8px;height:8px;border-radius:50%;background:var(--molten);display:inline-block}
|
|
.dot.off{background:var(--ash)}
|
|
main{padding-block:14px 48px;display:flex;flex-direction:column;gap:14px}
|
|
|
|
/* waiting strip */
|
|
.waiting{display:flex;gap:8px;overflow-x:auto;scrollbar-width:none;padding-bottom:2px}
|
|
.waiting::-webkit-scrollbar{display:none}
|
|
.wchip{flex:0 0 auto;display:inline-flex;align-items:center;gap:8px;border:1px solid var(--line-2);border-radius:999px;padding:6px 12px;background:var(--graphite);font-family:'IBM Plex Mono',monospace;font-size:12px;color:var(--ink-2)}
|
|
.wchip b{color:var(--bone);font-weight:500}
|
|
.wchip .n{background:var(--ember);color:var(--ember-ink);border-radius:999px;min-width:20px;height:20px;display:inline-flex;align-items:center;justify-content:center;font-weight:500;padding-inline:6px}
|
|
.wchip.zero .n{background:var(--line-2);color:var(--ash)}
|
|
.wchip.on{border-color:var(--ember)}
|
|
.wchip .role{color:var(--ash)}
|
|
.unknown{border:1px solid var(--molten);border-radius:var(--r);padding:12px 14px;background:var(--graphite);display:flex;flex-wrap:wrap;gap:10px;align-items:center;font-size:14px}
|
|
.unknown .mono{color:var(--molten)}
|
|
.unknown input{width:90px}
|
|
|
|
/* composer */
|
|
.card{background:var(--graphite);border:1px solid var(--line);border-radius:var(--r);padding:14px}
|
|
.composer{display:flex;flex-direction:column;gap:10px}
|
|
.row{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
|
|
.row label{font-family:'IBM Plex Mono',monospace;font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--ash)}
|
|
select,input[type=text],textarea{background:var(--obsidian);border:1px solid var(--line-2);color:var(--bone);border-radius:10px;padding:10px 12px;font:inherit;min-height:42px}
|
|
select{appearance:none;-webkit-appearance:none;padding-right:30px;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%239A9A9E' stroke-width='1.6' fill='none'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 11px center}
|
|
textarea{width:100%;min-height:96px;resize:vertical;font-family:'IBM Plex Mono',monospace;font-size:14px;line-height:1.45}
|
|
input[type=text]{width:100%}
|
|
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:42px;padding:9px 16px;border-radius:10px;font-weight:600;font-size:14px;border:1px solid var(--line-2);color:var(--bone);background:transparent;transition:border-color .15s,background .15s}
|
|
.btn:hover{border-color:var(--ash)}
|
|
.btn.primary{background:var(--ember);color:var(--ember-ink);border-color:var(--ember)}
|
|
.btn.primary:hover{background:#FF6A2B;border-color:#FF6A2B}
|
|
.btn.primary:disabled{opacity:.5;cursor:default}
|
|
.btn.small{min-height:32px;padding:5px 11px;font-size:12px;font-weight:500}
|
|
.drop{border:1px dashed var(--line-2);border-radius:10px;padding:12px;text-align:center;color:var(--ash);font-size:13px;cursor:pointer;transition:border-color .15s,color .15s}
|
|
.drop.over{border-color:var(--ember);color:var(--bone)}
|
|
.drop.has{border-style:solid;border-color:var(--molten);color:var(--bone)}
|
|
.drop input{display:none}
|
|
.seg{display:inline-flex;border:1px solid var(--line-2);border-radius:10px;overflow:hidden}
|
|
.seg button{background:transparent;border:0;padding:8px 12px;font-size:13px;color:var(--ash);min-height:40px}
|
|
.seg button.on{background:var(--line);color:var(--bone)}
|
|
.progress{height:3px;background:var(--line);border-radius:2px;overflow:hidden;display:none}
|
|
.progress i{display:block;height:100%;width:0;background:var(--ember);transition:width .2s}
|
|
.msg{font-family:'IBM Plex Mono',monospace;font-size:12px;color:var(--ash);min-height:16px}
|
|
.msg.err{color:var(--ember)}
|
|
|
|
/* filters */
|
|
.filters{display:flex;gap:8px;overflow-x:auto;scrollbar-width:none}
|
|
.filters::-webkit-scrollbar{display:none}
|
|
.chip{flex:0 0 auto;border:1px solid var(--line-2);background:transparent;color:var(--ink-2);border-radius:999px;padding:6px 12px;font-size:13px;min-height:34px}
|
|
.chip.on{background:var(--bone);color:var(--obsidian);border-color:var(--bone)}
|
|
|
|
/* feed */
|
|
.group{display:flex;flex-direction:column;gap:8px}
|
|
.ghead{display:flex;align-items:baseline;justify-content:space-between;gap:10px;padding:8px 2px 2px}
|
|
.ghead h2{font-family:'Unbounded',sans-serif;font-weight:700;font-size:14px;margin:0;letter-spacing:.02em}
|
|
.ghead .meta{font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--ash)}
|
|
.item{background:var(--graphite);border:1px solid var(--line);border-radius:var(--r);padding:12px 14px;display:flex;flex-direction:column;gap:8px;cursor:pointer;min-width:0}
|
|
.item.unread{border-color:var(--line-2);box-shadow:inset 3px 0 0 var(--ember)}
|
|
.item.done{opacity:.7}
|
|
.ihead{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
.badge{font-family:'IBM Plex Mono',monospace;font-size:10px;letter-spacing:.14em;text-transform:uppercase;border-radius:6px;padding:3px 7px;border:1px solid var(--line-2);color:var(--ink-2)}
|
|
.badge.text{color:var(--ink-2)}
|
|
.badge.file{color:var(--molten);border-color:rgba(255,179,92,.5)}
|
|
.badge.task{background:var(--ember);color:var(--ember-ink);border-color:var(--ember)}
|
|
.badge.run{background:var(--molten);color:var(--ember-ink);border-color:var(--molten)}
|
|
.badge.result{color:var(--molten);border-color:var(--molten)}
|
|
.ihead .route{font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--ash)}
|
|
.ihead .route b{color:var(--ink-2);font-weight:500}
|
|
.ihead .time{margin-left:auto;font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--ash);white-space:nowrap}
|
|
.title{font-weight:600;font-size:15px;line-height:1.35;overflow-wrap:anywhere}
|
|
.preview{color:var(--ink-2);font-family:'IBM Plex Mono',monospace;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
.body{display:none;flex-direction:column;gap:8px}
|
|
.item.open .body{display:flex}
|
|
.item.open .preview{display:none}
|
|
pre{position:relative;margin:0;background:var(--obsidian);border:1px solid var(--line);border-radius:10px;padding:12px 12px 12px;font-family:'IBM Plex Mono',monospace;font-size:12.5px;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--ink-2);max-height:60vh;overflow:auto}
|
|
pre.code{color:var(--bone)}
|
|
.block{position:relative}
|
|
.copy{position:absolute;top:6px;right:6px;z-index:1}
|
|
.copy.ok{border-color:var(--molten);color:var(--molten)}
|
|
.file{display:flex;align-items:center;gap:10px;flex-wrap:wrap;font-family:'IBM Plex Mono',monospace;font-size:12.5px}
|
|
.file .name{color:var(--molten);overflow-wrap:anywhere}
|
|
.file .size{color:var(--ash)}
|
|
.acts{display:flex;gap:8px;flex-wrap:wrap}
|
|
.flags{font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--ash)}
|
|
.empty{color:var(--ash);text-align:center;padding:32px 0;font-family:'IBM Plex Mono',monospace;font-size:12px}
|
|
.more{align-self:center}
|
|
@media (min-width:700px){:root{--gutter:24px}body{font-size:16px}.item{padding:14px 16px}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header><div class="wrap">
|
|
<div class="brand"><svg width="22" height="22" viewBox="0 0 100 100" aria-hidden="true"><polygon points="50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34" fill="#F2541B"/><polygon points="50,42 59,58 50,82 41,58" fill="#0C0C0E"/></svg><span class="word">IGNEUM</span><span class="sub">relay</span></div>
|
|
<div class="status"><span class="dot" id="dot"></span><span id="stat">connecting</span></div>
|
|
</div></header>
|
|
<main class="wrap">
|
|
<div id="unknown"></div>
|
|
<div class="waiting" id="waiting"></div>
|
|
<section class="card composer" id="composer">
|
|
<div class="row">
|
|
<label>from</label><select id="from"></select>
|
|
<label>to</label><select id="to"></select>
|
|
<div class="seg" id="kindseg"><button data-k="text" class="on">note</button><button data-k="task">task</button></div>
|
|
</div>
|
|
<input type="text" id="title" placeholder="title (optional for notes, the headline for tasks)">
|
|
<textarea id="text" placeholder="paste text, a command, a log, a path. Shift+Enter for a new line, Enter sends."></textarea>
|
|
<label class="drop" id="drop"><input type="file" id="file"><span id="droplabel">drop a file here or tap to pick one (up to 50 MB)</span></label>
|
|
<div class="progress" id="prog"><i></i></div>
|
|
<div class="row"><button class="btn primary" id="send">Send</button><button class="btn" id="clear">Clear</button><span class="msg" id="msg"></span></div>
|
|
</section>
|
|
<div class="filters" id="filters"></div>
|
|
<div id="feed"><div class="empty">loading</div></div>
|
|
<button class="btn more" id="more" style="display:none">older</button>
|
|
</main>
|
|
<template id="tpl-item"></template>
|
|
<script>
|
|
(() => {
|
|
const m = location.pathname.match(/^\/r\/([^/]+)/);
|
|
const TOKEN = m ? m[1] : '';
|
|
const API = `/r/${TOKEN}/api/`;
|
|
const $ = s => document.querySelector(s);
|
|
const DEFAULT_MACHINES = ['Mac', 'PC1', 'PC2', 'Phone'];
|
|
const state = { items: [], machines: [], filter: 'all', from: localStorage.getItem('relay.from') || guessFrom(), to: localStorage.getItem('relay.to') || 'all', kind: 'text', file: null, open: new Set(), maxId: 0, minId: 0, more: true };
|
|
function guessFrom() { return /iPhone|Android|iPad/i.test(navigator.userAgent) ? 'Phone' : 'Mac'; }
|
|
|
|
async function api(fn, opts = {}) {
|
|
const r = await fetch(API + fn + (opts.q ? '?' + new URLSearchParams(opts.q) : ''), opts.body !== undefined ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(opts.body) } : {});
|
|
const j = await r.json().catch(() => ({ ok: false, error: r.status }));
|
|
if (!r.ok || j.ok === false) throw new Error(j.error || ('http ' + r.status));
|
|
return j;
|
|
}
|
|
const fmtSize = n => n < 1024 ? n + ' B' : n < 1048576 ? (n / 1024).toFixed(1) + ' KB' : (n / 1048576).toFixed(1) + ' MB';
|
|
const ago = ts => { const s = (Date.now() - new Date(ts)) / 1000; if (s < 60) return 'now'; if (s < 3600) return Math.floor(s / 60) + 'm'; if (s < 86400) return Math.floor(s / 3600) + 'h'; return Math.floor(s / 86400) + 'd'; };
|
|
const when = ts => { const d = new Date(ts); return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short' }) + ' ' + d.toTimeString().slice(0, 5); };
|
|
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
|
|
function setStat(txt, on) { $('#stat').textContent = txt; $('#dot').classList.toggle('off', !on); }
|
|
|
|
function machineNames() {
|
|
const names = new Set(DEFAULT_MACHINES);
|
|
for (const mc of state.machines) names.add(mc.name);
|
|
return [...names];
|
|
}
|
|
function fillSelects() {
|
|
const names = machineNames();
|
|
const from = $('#from'); from.innerHTML = names.map(n => `<option ${n === state.from ? 'selected' : ''}>${esc(n)}</option>`).join('');
|
|
const to = $('#to'); to.innerHTML = ['all', ...names].map(n => `<option value="${esc(n)}" ${n === state.to ? 'selected' : ''}>${n === 'all' ? 'everyone' : esc(n)}</option>`).join('');
|
|
}
|
|
|
|
function renderWaiting() {
|
|
const w = $('#waiting');
|
|
const list = state.machines.filter(mc => mc.named !== false || mc.unread);
|
|
w.innerHTML = list.map(mc => `<button class="wchip ${mc.unread ? 'on' : 'zero'} ${state.filter === mc.name ? 'on' : ''}" data-m="${esc(mc.name)}"><b>${esc(mc.name)}</b>${mc.role ? `<span class="role">${esc(mc.role)}</span>` : ''}<span class="n">${mc.unread || 0}</span><span class="role">${mc.last_seen ? ago(mc.last_seen) : 'never'}</span></button>`).join('');
|
|
w.querySelectorAll('.wchip').forEach(b => b.onclick = () => { state.filter = state.filter === b.dataset.m ? 'all' : b.dataset.m; renderFilters(); renderFeed(); renderWaiting(); });
|
|
const unknown = state.machines.filter(mc => mc.named === false && mc.hostname);
|
|
$('#unknown').innerHTML = unknown.map(mc => `<div class="unknown"><span>New machine <span class="mono">${esc(mc.hostname)}</span>${mc.info && mc.info.gpus ? ` (${esc([].concat(mc.info.gpus).join(', '))})` : ''}. Name it:</span><input type="text" placeholder="PC2" data-h="${esc(mc.hostname)}"><button class="btn small primary" data-h="${esc(mc.hostname)}">Save</button></div>`).join('');
|
|
$('#unknown').querySelectorAll('button').forEach(b => b.onclick = async () => {
|
|
const inp = $('#unknown').querySelector(`input[data-h="${CSS.escape(b.dataset.h)}"]`);
|
|
const name = inp.value.trim(); if (!name) return inp.focus();
|
|
try { await api('name', { body: { hostname: b.dataset.h, name } }); await refresh(true); } catch (e) { flash(e.message, true); }
|
|
});
|
|
}
|
|
function renderFilters() {
|
|
const f = $('#filters');
|
|
const names = ['all', ...machineNames()];
|
|
f.innerHTML = names.map(n => `<button class="chip ${state.filter === n ? 'on' : ''}" data-f="${esc(n)}">${n === 'all' ? 'everything' : esc(n)}</button>`).join('');
|
|
f.querySelectorAll('.chip').forEach(b => b.onclick = () => { state.filter = b.dataset.f; renderFilters(); renderFeed(); renderWaiting(); });
|
|
}
|
|
|
|
function segments(body) {
|
|
// split ``` fences into code blocks so each gets its own copy button
|
|
const out = []; const re = /```[^\n]*\n([\s\S]*?)```/g; let last = 0, x;
|
|
while ((x = re.exec(body))) { if (x.index > last) out.push({ code: false, t: body.slice(last, x.index).trim() }); out.push({ code: true, t: x[1].replace(/\n$/, '') }); last = x.index + x[0].length; }
|
|
if (last < body.length) out.push({ code: false, t: body.slice(last).trim() });
|
|
return out.filter(s => s.t);
|
|
}
|
|
function itemHtml(it) {
|
|
const open = state.open.has(it.id);
|
|
const route = it.to === 'all' ? `<b>${esc(it.from)}</b> to everyone` : `<b>${esc(it.from)}</b> to <b>${esc(it.to)}</b>`;
|
|
const flags = Object.entries(it.flags || {}).filter(([k, v]) => v !== false && v !== null && v !== '').map(([k, v]) => v === true ? k : `${k}=${v}`).join(' ');
|
|
const unread = !it.read && (it.kind === 'task' || it.kind === 'run');
|
|
const segs = segments(it.body || '');
|
|
const bodyHtml = segs.map(s => `<div class="block"><button class="btn small copy" data-copy>${s.code ? 'copy' : 'copy'}</button><pre class="${s.code ? 'code' : ''}">${esc(s.t)}</pre></div>`).join('');
|
|
const file = it.has_file ? `<div class="file"><span class="name">${esc(it.file_name || 'file')}</span><span class="size">${fmtSize(it.size)}</span><a class="btn small" href="${API}file?id=${it.id}&download=1" download onclick="event.stopPropagation()">download</a><a class="btn small" href="${API}file?id=${it.id}" target="_blank" rel="noopener" onclick="event.stopPropagation()">open</a></div>` : '';
|
|
const acts = `<div class="acts">${unread ? `<button class="btn small" data-act="ack">mark read</button>` : ''}${(it.kind === 'task' || it.kind === 'run') && !it.done ? `<button class="btn small" data-act="done">done</button>` : ''}<button class="btn small" data-act="copyall">copy all</button><button class="btn small" data-act="del">delete</button></div>`;
|
|
const preview = (it.body || '').split('\n').find(l => l.trim()) || (it.has_file ? it.file_name : '') || '';
|
|
return `<article class="item ${open ? 'open' : ''} ${unread ? 'unread' : ''} ${it.done ? 'done' : ''}" data-id="${it.id}">
|
|
<div class="ihead"><span class="badge ${it.kind}">${it.kind}</span><span class="route">${route}</span><span class="time" title="${esc(it.ts)}">${when(it.ts)}</span></div>
|
|
${it.title ? `<div class="title">${esc(it.title)}</div>` : ''}
|
|
<div class="preview">${esc(preview)}</div>
|
|
<div class="body">${flags ? `<div class="flags">${esc(flags)}${it.task_id ? ` task #${it.task_id}` : ''}${it.done ? ' done' : it.read ? ' read' : ''} #${it.id}</div>` : `<div class="flags">#${it.id}${it.task_id ? ` for task #${it.task_id}` : ''}${it.done ? ' done' : it.read && (it.kind === 'task' || it.kind === 'run') ? ' read' : ''}</div>`}${bodyHtml}${file}${acts}</div>
|
|
</article>`;
|
|
}
|
|
function renderFeed() {
|
|
const feed = $('#feed');
|
|
const items = state.items.filter(it => state.filter === 'all' || it.from === state.filter || it.to === state.filter);
|
|
if (!items.length) { feed.innerHTML = `<div class="empty">nothing here yet</div>`; $('#more').style.display = 'none'; return; }
|
|
// group by the machine that posted it, groups ordered by their newest item, items newest first inside
|
|
const groups = new Map();
|
|
for (const it of items) { const k = it.from; if (!groups.has(k)) groups.set(k, []); groups.get(k).push(it); }
|
|
feed.innerHTML = [...groups.entries()].map(([name, list]) => `<section class="group"><div class="ghead"><h2>${esc(name)}</h2><span class="meta">${list.length} item${list.length === 1 ? '' : 's'}, latest ${ago(list[0].ts)} ago</span></div>${list.map(itemHtml).join('')}</section>`).join('');
|
|
$('#more').style.display = state.more && state.items.length >= 200 ? '' : 'none';
|
|
feed.querySelectorAll('.item').forEach(el => {
|
|
el.onclick = e => { if (e.target.closest('button,a,pre')) return; const id = Number(el.dataset.id); state.open.has(id) ? state.open.delete(id) : state.open.add(id); el.classList.toggle('open'); };
|
|
el.querySelectorAll('[data-copy]').forEach(b => b.onclick = async e => { e.stopPropagation(); await copy(b.nextElementSibling.textContent, b); });
|
|
el.querySelectorAll('[data-act]').forEach(b => b.onclick = async e => {
|
|
e.stopPropagation(); const id = Number(el.dataset.id); const it = state.items.find(i => i.id === id);
|
|
try {
|
|
if (b.dataset.act === 'ack') { await api('ack', { body: { ids: [id] } }); it.read = true; }
|
|
if (b.dataset.act === 'done') { await api('done', { body: { id } }); it.done = true; it.read = true; }
|
|
if (b.dataset.act === 'copyall') { await copy(it.body || '', b); return; }
|
|
if (b.dataset.act === 'del') { if (!confirm('Delete this item?')) return; await api('delete', { body: { id } }); state.items = state.items.filter(i => i.id !== id); }
|
|
renderFeed(); refresh(true);
|
|
} catch (err) { flash(err.message, true); }
|
|
});
|
|
});
|
|
}
|
|
async function copy(text, btn) {
|
|
try { await navigator.clipboard.writeText(text); } catch { const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); }
|
|
if (btn) { const old = btn.textContent; btn.textContent = 'copied'; btn.classList.add('ok'); setTimeout(() => { btn.textContent = old; btn.classList.remove('ok'); }, 1200); }
|
|
}
|
|
function flash(t, err) { const el = $('#msg'); el.textContent = t; el.classList.toggle('err', !!err); if (!err) setTimeout(() => { if (el.textContent === t) el.textContent = ''; }, 4000); }
|
|
|
|
async function refresh(full) {
|
|
try {
|
|
const q = full || !state.maxId ? { limit: 200 } : { since: state.maxId };
|
|
const j = await api('feed', { q });
|
|
state.machines = j.machines;
|
|
if (full || !state.maxId) { state.items = j.items; state.more = j.items.length >= 200; }
|
|
else if (j.items.length) { const ids = new Set(state.items.map(i => i.id)); state.items = [...j.items.filter(i => !ids.has(i.id)), ...state.items]; }
|
|
if (!full && state.maxId) {
|
|
// keep read/done flags of visible items fresh without refetching everything: cheap second call only when machines report unread
|
|
if (j.items.length === 0 && state.items.length) { const j2 = await api('feed', { q: { limit: 60 } }); for (const n of j2.items) { const o = state.items.find(i => i.id === n.id); if (o) Object.assign(o, n); } }
|
|
}
|
|
state.maxId = state.items.reduce((a, i) => Math.max(a, i.id), 0);
|
|
state.minId = state.items.reduce((a, i) => Math.min(a, i.id), Infinity);
|
|
fillSelects(); renderWaiting(); renderFilters(); renderFeed();
|
|
setStat('live ' + new Date().toTimeString().slice(0, 8), true);
|
|
} catch (e) { setStat('offline: ' + e.message, false); }
|
|
}
|
|
async function older() {
|
|
try { const j = await api('feed', { q: { before: state.minId, limit: 200 } }); state.items = [...state.items, ...j.items]; state.more = j.items.length >= 200; state.minId = state.items.reduce((a, i) => Math.min(a, i.id), Infinity); renderFeed(); } catch (e) { flash(e.message, true); }
|
|
}
|
|
|
|
// composer
|
|
$('#from').onchange = e => { state.from = e.target.value; localStorage.setItem('relay.from', state.from); };
|
|
$('#to').onchange = e => { state.to = e.target.value; localStorage.setItem('relay.to', state.to); };
|
|
$('#kindseg').querySelectorAll('button').forEach(b => b.onclick = () => { state.kind = b.dataset.k; $('#kindseg').querySelectorAll('button').forEach(x => x.classList.toggle('on', x === b)); $('#title').placeholder = state.kind === 'task' ? 'what the machine should do (the headline)' : 'title (optional for notes, the headline for tasks)'; });
|
|
const drop = $('#drop');
|
|
const setFile = f => { state.file = f || null; drop.classList.toggle('has', !!f); $('#droplabel').textContent = f ? `${f.name} (${fmtSize(f.size)})` : 'drop a file here or tap to pick one (up to 50 MB)'; };
|
|
$('#file').onchange = e => setFile(e.target.files[0]);
|
|
['dragenter', 'dragover'].forEach(ev => document.addEventListener(ev, e => { e.preventDefault(); drop.classList.add('over'); }));
|
|
['dragleave', 'drop'].forEach(ev => document.addEventListener(ev, e => { e.preventDefault(); drop.classList.remove('over'); }));
|
|
document.addEventListener('drop', e => { if (e.dataTransfer.files[0]) setFile(e.dataTransfer.files[0]); });
|
|
document.addEventListener('paste', e => { const f = [...(e.clipboardData.files || [])][0]; if (f && document.activeElement !== $('#text')) setFile(f); });
|
|
$('#text').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !/Mobi|Android/i.test(navigator.userAgent)) { e.preventDefault(); send(); } });
|
|
$('#clear').onclick = () => { $('#text').value = ''; $('#title').value = ''; setFile(null); $('#file').value = ''; flash(''); };
|
|
$('#send').onclick = send;
|
|
$('#more').onclick = older;
|
|
|
|
async function uploadFile(f) {
|
|
const t = await api('upload', { body: { name: f.name, size: f.size } });
|
|
if (f.size > t.max) throw new Error(`file over ${fmtSize(t.max)}`);
|
|
const prog = $('#prog'); prog.style.display = 'block'; const bar = prog.firstElementChild;
|
|
const url = await new Promise((resolve, reject) => {
|
|
const x = new XMLHttpRequest();
|
|
x.open('PUT', t.put_url);
|
|
// only headers the Blob API allows cross-origin; the client token already carries the pathname and the random suffix
|
|
x.setRequestHeader('authorization', 'Bearer ' + t.token);
|
|
x.setRequestHeader('x-api-version', t.api_version);
|
|
x.setRequestHeader('content-type', f.type || 'application/octet-stream');
|
|
x.upload.onprogress = e => { if (e.lengthComputable) bar.style.width = Math.round(e.loaded / e.total * 100) + '%'; };
|
|
x.onload = () => { try { const j = JSON.parse(x.responseText); if (x.status < 300 && j.url) resolve(j.url); else reject(new Error(j.error && j.error.message || ('upload http ' + x.status))); } catch { reject(new Error('upload http ' + x.status)); } };
|
|
x.onerror = () => reject(new Error('upload failed'));
|
|
x.send(f);
|
|
});
|
|
prog.style.display = 'none'; bar.style.width = '0';
|
|
return { file_name: f.name, file_url: url, size: f.size };
|
|
}
|
|
async function send() {
|
|
const text = $('#text').value; const title = $('#title').value.trim();
|
|
if (!text.trim() && !state.file && !title) return flash('nothing to send', true);
|
|
if (state.kind === 'task' && state.to === 'all' && false) return;
|
|
const btn = $('#send'); btn.disabled = true; flash(state.file ? 'uploading' : 'sending');
|
|
try {
|
|
const o = { from: state.from, to: state.to, kind: state.kind, title, body: text };
|
|
if (state.file) Object.assign(o, await uploadFile(state.file));
|
|
if (!o.body && !o.file_url) o.kind = 'text';
|
|
if (o.file_url && o.kind === 'text') o.kind = 'file';
|
|
await api(state.kind === 'task' ? 'task' : 'drop', { body: o });
|
|
$('#text').value = ''; $('#title').value = ''; setFile(null); $('#file').value = '';
|
|
flash('sent'); await refresh(false);
|
|
} catch (e) { flash(e.message, true); } finally { btn.disabled = false; }
|
|
}
|
|
|
|
if (!TOKEN) { setStat('no token in the URL', false); document.body.innerHTML += '<p class="empty">open the relay through its /r/<token>/ link</p>'; return; }
|
|
refresh(true);
|
|
setInterval(() => { if (document.visibilityState === 'visible') refresh(false); }, 15000);
|
|
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') refresh(false); });
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|