diff options
Diffstat (limited to 'static')
| -rw-r--r-- | static/css/styles.css | 55 | ||||
| -rw-r--r-- | static/js/canvas.js | 214 | ||||
| -rw-r--r-- | static/js/dns-wizard.js | 43 |
3 files changed, 274 insertions, 38 deletions
diff --git a/static/css/styles.css b/static/css/styles.css index 53f7abd..d8316bb 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -112,17 +112,68 @@ pre { .dot { display: inline-block; width: .6rem; height: .6rem; border-radius: 50%; background: var(--muted); vertical-align: middle; } .dot.chat { background: var(--ok); } .dot.network { background: var(--accent-2); } +.dot.online { background: var(--ok); } .dot.offline { background: var(--muted); } .whoami { color: var(--accent-2); font-weight: bold; } .whoami:hover { color: var(--accent); } .muted { color: var(--muted); } code { background: var(--panel); padding: .1rem .3rem; } +/* profile: devices + dns */ +.lan-table { width: 100%; border-collapse: collapse; margin: .5rem 0; } +.lan-table th { text-align: left; color: var(--muted); font-weight: normal; border-bottom: 1px dashed var(--muted); padding: .3rem .5rem; } +.lan-table td { padding: .3rem .5rem; border-bottom: 1px solid var(--panel); vertical-align: middle; } +.lan-table form { margin: 0; } +.lan-table button { padding: .15rem .5rem; font-size: .85rem; } +.set-primary { background: var(--panel); color: var(--ink); border-color: var(--muted); } +.set-primary:hover { background: var(--accent); color: var(--bg); border-color: var(--accent); } +.wizard-btn { margin: .25rem 0 .5rem; } +.dns-form { margin: .75rem 0; } +.dns-form h4 { margin: .5rem 0 .35rem; } +.dns-fields { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; } +.dns-fields input[name="type"] { width: 6rem; } +.dns-fields input[name="ttl"] { width: 5rem; } +.dns-fields input[name="name"], .dns-fields input[name="content"] { flex: 1 1 12rem; } + /* canvas / r-place */ .palette { display: flex; flex-wrap: wrap; gap: 4px; margin: .5rem 0; } .swatch { width: 26px; height: 26px; border: 2px solid var(--panel); border-radius: 4px; cursor: pointer; } .swatch.selected { border-color: var(--ink); } -.canvas-wrap { overflow: auto; max-width: 100%; } -#canvas { image-rendering: pixelated; cursor: crosshair; border: 2px solid var(--muted); touch-action: none; } +.canvas-viewport { + position: relative; + width: 100%; + height: 65vh; + min-height: 300px; + overflow: hidden; + background: var(--bg); + border: 2px solid var(--muted); + cursor: crosshair; + user-select: none; + touch-action: none; +} +.canvas-viewport.dragging { cursor: grabbing; } +#canvas { + position: absolute; + top: 0; left: 0; + image-rendering: pixelated; + transform-origin: 0 0; +} +.pixel-cursor { + position: absolute; + top: 0; left: 0; + pointer-events: none; + display: none; + border: 1px solid rgba(255,255,255,0.9); + box-shadow: 0 0 0 1px rgba(0,0,0,0.6); + box-sizing: border-box; +} +.canvas-coords { + position: absolute; + bottom: 6px; + left: 8px; + font-size: .75rem; + color: var(--muted); + pointer-events: none; +} footer { color: var(--muted); } diff --git a/static/js/canvas.js b/static/js/canvas.js index f164803..3c4947c 100644 --- a/static/js/canvas.js +++ b/static/js/canvas.js @@ -2,34 +2,41 @@ const cfg = window.CANVAS; const canvas = document.getElementById("canvas"); const ctx = canvas.getContext("2d"); - canvas.style.width = cfg.cols * cfg.scale + "px"; - canvas.style.height = cfg.rows * cfg.scale + "px"; + const viewport = document.getElementById("viewport"); + const coordsEl = document.getElementById("coords"); + const cursorEl = document.getElementById("pixel-cursor"); + const cdEl = document.getElementById("cooldown"); - // palette picker - let selected = 0; - const paletteEl = document.getElementById("palette"); - cfg.palette.forEach((hex, i) => { - const sw = document.createElement("div"); - sw.className = "swatch" + (i === 0 ? " selected" : ""); - sw.style.background = hex; - sw.title = hex; - sw.addEventListener("click", () => { - selected = i; - [...paletteEl.children].forEach((c) => c.classList.remove("selected")); - sw.classList.add("selected"); - }); - paletteEl.appendChild(sw); - }); + const ZOOM_MIN = 1, ZOOM_MAX = 64, DEFAULT_ZOOM = 16; + let cam = { x: 0, y: 0, zoom: DEFAULT_ZOOM }; + + function clamp() { + const vw = viewport.clientWidth, vh = viewport.clientHeight; + const w = cfg.cols * cam.zoom, h = cfg.rows * cam.zoom; + const margin = Math.min(vw, vh) * 0.5; + cam.x = Math.min(margin, Math.max(vw - w - margin, cam.x)); + cam.y = Math.min(margin, Math.max(vh - h - margin, cam.y)); + } + + function applyTransform() { + clamp(); + canvas.style.transform = `translate(${cam.x}px,${cam.y}px) scale(${cam.zoom})`; + } + + function resetView() { + const vw = viewport.clientWidth, vh = viewport.clientHeight; + cam.zoom = DEFAULT_ZOOM; + cam.x = Math.round(vw / 2 - (cfg.cols / 2) * cam.zoom); + cam.y = Math.round(vh / 2 - (cfg.rows / 2) * cam.zoom); + applyTransform(); + } + + resetView(); + window.addEventListener("resize", applyTransform); const hexFromInt = (c) => "#" + c.toString(16).padStart(6, "0"); - const drawPixel = (x, y, hex) => { - ctx.fillStyle = hex; - ctx.fillRect(x, y, 1, 1); - }; + const drawPixel = (x, y, hex) => { ctx.fillStyle = hex; ctx.fillRect(x, y, 1, 1); }; - // The server is authoritative: we only paint pixels it broadcasts back. Open - // the socket first and queue messages until the initial state is painted, so - // nothing placed during the load is lost. let loaded = false; const queue = []; function apply(msg) { @@ -41,10 +48,7 @@ function connect() { const proto = location.protocol === "https:" ? "wss" : "ws"; ws = new WebSocket(`${proto}://${location.host}/canvas/ws`); - ws.onmessage = (e) => { - const msg = JSON.parse(e.data); - loaded ? apply(msg) : queue.push(msg); - }; + ws.onmessage = (e) => { const msg = JSON.parse(e.data); loaded ? apply(msg) : queue.push(msg); }; ws.onclose = () => setTimeout(connect, 1000); ws.onerror = () => ws.close(); } @@ -56,7 +60,7 @@ const bytes = new Uint8Array(buf); const img = ctx.createImageData(cfg.cols, cfg.rows); for (let p = 0; p < cfg.cols * cfg.rows; p++) { - img.data[p * 4] = bytes[p * 3]; + img.data[p * 4] = bytes[p * 3]; img.data[p * 4 + 1] = bytes[p * 3 + 1]; img.data[p * 4 + 2] = bytes[p * 3 + 2]; img.data[p * 4 + 3] = 255; @@ -67,9 +71,22 @@ queue.length = 0; }); - // cooldown indicator (server enforces; this is UI feedback) + let selected = 6; + const paletteEl = document.getElementById("palette"); + cfg.palette.forEach((hex, i) => { + const sw = document.createElement("div"); + sw.className = "swatch" + (i === selected ? " selected" : ""); + sw.style.background = hex; + sw.title = hex; + sw.addEventListener("click", () => { + selected = i; + [...paletteEl.children].forEach((c) => c.classList.remove("selected")); + sw.classList.add("selected"); + }); + paletteEl.appendChild(sw); + }); + let cooldownUntil = 0; - const cdEl = document.getElementById("cooldown"); function startCooldown(ms) { cooldownUntil = Math.max(cooldownUntil, Date.now() + ms); tick(); @@ -84,14 +101,139 @@ } } - canvas.addEventListener("click", (e) => { + function toCanvas(vx, vy) { + return { x: Math.floor((vx - cam.x) / cam.zoom), y: Math.floor((vy - cam.y) / cam.zoom) }; + } + + function inBounds(x, y) { + return x >= 0 && x < cfg.cols && y >= 0 && y < cfg.rows; + } + + function place(vx, vy) { if (Date.now() < cooldownUntil) return; if (!ws || ws.readyState !== WebSocket.OPEN) return; - const rect = canvas.getBoundingClientRect(); - const x = Math.floor(((e.clientX - rect.left) / rect.width) * cfg.cols); - const y = Math.floor(((e.clientY - rect.top) / rect.height) * cfg.rows); - if (x < 0 || x >= cfg.cols || y < 0 || y >= cfg.rows) return; + const { x, y } = toCanvas(vx, vy); + if (!inBounds(x, y)) return; ws.send(JSON.stringify({ type: "place", x, y, i: selected })); startCooldown(cfg.cooldownMs); + } + + function updateCursor(vx, vy) { + const { x, y } = toCanvas(vx, vy); + if (inBounds(x, y) && cam.zoom >= 3) { + const px = cam.x + x * cam.zoom; + const py = cam.y + y * cam.zoom; + cursorEl.style.cssText = `display:block;width:${cam.zoom}px;height:${cam.zoom}px;transform:translate(${px}px,${py}px)`; + coordsEl.textContent = `${x}, ${y}`; + } else { + cursorEl.style.display = "none"; + coordsEl.textContent = inBounds(x, y) ? `${x}, ${y}` : ""; + } + } + + function zoomAt(vx, vy, factor) { + const z = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, cam.zoom * factor)); + cam.x = vx - (vx - cam.x) * (z / cam.zoom); + cam.y = vy - (vy - cam.y) * (z / cam.zoom); + cam.zoom = z; + applyTransform(); + } + + let drag = { on: false, sx: 0, sy: 0, cx: 0, cy: 0, moved: false }; + + viewport.addEventListener("mousedown", (e) => { + if (e.button !== 0) return; + drag = { on: true, sx: e.clientX, sy: e.clientY, cx: cam.x, cy: cam.y, moved: false }; + viewport.classList.add("dragging"); + e.preventDefault(); }); + + window.addEventListener("mousemove", (e) => { + if (drag.on) { + const dx = e.clientX - drag.sx, dy = e.clientY - drag.sy; + if (Math.abs(dx) > 3 || Math.abs(dy) > 3) drag.moved = true; + cam.x = drag.cx + dx; + cam.y = drag.cy + dy; + applyTransform(); + } + const rect = viewport.getBoundingClientRect(); + updateCursor(e.clientX - rect.left, e.clientY - rect.top); + }); + + window.addEventListener("mouseup", (e) => { + if (!drag.on) return; + if (!drag.moved) { + const rect = viewport.getBoundingClientRect(); + place(e.clientX - rect.left, e.clientY - rect.top); + } + drag.on = false; + viewport.classList.remove("dragging"); + }); + + viewport.addEventListener("mouseleave", () => { + cursorEl.style.display = "none"; + coordsEl.textContent = ""; + }); + + viewport.addEventListener("wheel", (e) => { + e.preventDefault(); + const rect = viewport.getBoundingClientRect(); + zoomAt(e.clientX - rect.left, e.clientY - rect.top, e.deltaY < 0 ? 1.15 : 1 / 1.15); + }, { passive: false }); + + let pinchDist = null; + let tapOk = false; + + function tdist(a, b) { + const dx = a.clientX - b.clientX, dy = a.clientY - b.clientY; + return Math.sqrt(dx * dx + dy * dy); + } + function tmid(a, b) { + return { x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2 }; + } + + viewport.addEventListener("touchstart", (e) => { + e.preventDefault(); + if (e.touches.length === 1) { + const t = e.touches[0]; + drag = { on: true, sx: t.clientX, sy: t.clientY, cx: cam.x, cy: cam.y, moved: false }; + pinchDist = null; + tapOk = true; + } else if (e.touches.length === 2) { + drag.on = false; + tapOk = false; + pinchDist = tdist(e.touches[0], e.touches[1]); + } + }, { passive: false }); + + viewport.addEventListener("touchmove", (e) => { + e.preventDefault(); + const rect = viewport.getBoundingClientRect(); + if (e.touches.length === 1 && drag.on) { + const t = e.touches[0]; + const dx = t.clientX - drag.sx, dy = t.clientY - drag.sy; + if (Math.abs(dx) > 4 || Math.abs(dy) > 4) { drag.moved = true; tapOk = false; } + cam.x = drag.cx + dx; + cam.y = drag.cy + dy; + applyTransform(); + } else if (e.touches.length === 2 && pinchDist !== null) { + const d = tdist(e.touches[0], e.touches[1]); + const m = tmid(e.touches[0], e.touches[1]); + zoomAt(m.x - rect.left, m.y - rect.top, d / pinchDist); + pinchDist = d; + } + }, { passive: false }); + + viewport.addEventListener("touchend", (e) => { + e.preventDefault(); + if (tapOk && e.changedTouches.length === 1) { + const t = e.changedTouches[0]; + const rect = viewport.getBoundingClientRect(); + place(t.clientX - rect.left, t.clientY - rect.top); + } + drag.on = false; + tapOk = false; + if (e.touches.length < 2) pinchDist = null; + }, { passive: false }); + })(); diff --git a/static/js/dns-wizard.js b/static/js/dns-wizard.js new file mode 100644 index 0000000..2ee7162 --- /dev/null +++ b/static/js/dns-wizard.js @@ -0,0 +1,43 @@ +(function () { + const cfg = window.DNS; + if (!cfg) return; + + const form = (obj) => { + const body = new URLSearchParams(obj); + return { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body }; + }; + const addRecord = (type, name, content, ttl) => + fetch("/dns", form({ type, name, content, ttl: String(ttl || 300) })); + const setPrimary = (mac) => fetch("/profile/primary", form({ mac })); + + // The wizard seeds the basics: <user>.<zone> -> primary device, plus a starter + // alias per device you can rename or delete later. The server replaces an + // existing CNAME at a name, so re-running this is safe. + const wiz = document.getElementById("dns-wizard"); + if (wiz) { + wiz.addEventListener("click", async () => { + wiz.disabled = true; + wiz.textContent = "wiring things up…"; + const primary = cfg.devices.find((d) => d.mac === cfg.primaryMac) || cfg.devices[0]; + if (primary) await addRecord("CNAME", cfg.userDomain, primary.name, 300); + let n = 1; + for (const d of cfg.devices) { + await addRecord("CNAME", "device-" + n + "." + cfg.userDomain, d.name, 300); + n++; + } + location.reload(); + }); + } + + // Picking a primary updates the stored choice and repoints <user>.<zone> at it + // in one go, all client-side. + document.querySelectorAll(".set-primary").forEach((btn) => { + btn.addEventListener("click", async (e) => { + e.preventDefault(); + btn.disabled = true; + await setPrimary(btn.dataset.mac); + await addRecord("CNAME", cfg.userDomain, btn.dataset.name, 300); + location.reload(); + }); + }); +})(); |
