summaryrefslogtreecommitdiff
path: root/static
diff options
context:
space:
mode:
Diffstat (limited to 'static')
-rw-r--r--static/css/styles.css55
-rw-r--r--static/js/canvas.js214
-rw-r--r--static/js/dns-wizard.js43
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();
+ });
+ });
+})();