@@ -311,26 +319,28 @@
let state = null;
function defaultState() {
return {
- version: 5,
+ version: 6,
iconSize: "medium",
background: { kind: "preset", value: "dark" },
sections: {
sm: [
- { title: "Theseus.X", url: "https://theseus.x/", brand: "theseus" },
- { title: "SilentMode.X", url: "https://silentmode.x/", brand: "silentmode" },
- { title: "Sirius.X", url: "https://sirius.x/", brand: "sirius" },
- { title: "Hephaestus.X", url: "https://hephaestus.x/", brand: "hephaestus" },
- { title: "Prometheus.X", url: "https://prometheus.x/", brand: "prometheus" },
- { title: "Hermes.X", url: "https://hermes.x/", brand: "hermes" },
- { title: "Helios.X", url: "https://helios.x/", brand: "helios" },
- { title: "Ariadne.X", url: "https://ariadne.x/", brand: "ariadne" },
- { title: "Aegis.X", url: "https://aegis.x/", brand: "aegis" },
+ { title: "Theseus.X", url: "https://theseus.x/", brand: "theseus", sub: "The Web Navigator — this browser's home." },
+ { title: "SilentMode.X", url: "https://silentmode.x/", brand: "silentmode", sub: "Infrastructure for a decentralized web." },
+ { title: "Sirius.X", url: "https://sirius.x/", brand: "sirius", sub: "Register and manage BCDN names." },
+ { title: "Hephaestus.X", url: "https://hephaestus.x/", brand: "hephaestus", sub: "The forge — Silent Mode's code host." },
+ { title: "Prometheus.X", url: "https://prometheus.x/", brand: "prometheus", sub: "Decentralized app marketplace." },
+ { title: "Hermes.X", url: "https://hermes.x/", brand: "hermes", sub: "E2E-encrypted messaging over Nostr." },
+ { title: "Helios.X", url: "https://helios.x/", brand: "helios", sub: "Search engine for the decentralized web." },
+ { title: "Ariadne.X", url: "https://ariadne.x/", brand: "ariadne", sub: "BNS resolver — read .x and .bch names." },
+ { title: "Aegis.X", url: "https://aegis.x/", brand: "aegis", sub: "Multi-chain wallet, derived from the vault." },
],
+ // Your-links icons are free-positioned (per-item x,y, 20px snap grid).
+ // Defaults seed a tidy row across the top of the left area; drag anywhere.
q: [
- { title: "hello.bch", url: "https://hello.bch/", brand: "hellobch" },
- { title: "sia.coin", url: "https://sia.coin/", brand: "siacoin" },
- { title: "Pithos.sia", url: "https://pithos.sia/", brand: "pithos" },
- { title: "Bergamot.X", url: "https://bergamot.x/", brand: "bergamot" },
+ { title: "hello.bch", url: "https://hello.bch/", brand: "hellobch", x: 10, y: 10 },
+ { title: "sia.coin", url: "https://sia.coin/", brand: "siacoin", x: 110, y: 10 },
+ { title: "Pithos.sia", url: "https://pithos.sia/", brand: "pithos", x: 210, y: 10 },
+ { title: "Bergamot.X", url: "https://bergamot.x/", brand: "bergamot", x: 10, y: 120 },
],
},
};
@@ -387,6 +397,8 @@
function iconUsesBrand(icon) { return !!(icon.brand && BRAND[icon.brand]); }
function render() {
+ // LEFT — free-positioned icons. Each stores its own x,y; legacy items
+ // without x,y fall back to a tidy auto-layout at the top of the area.
const q = state.sections.q || [];
const list = $("slotsList");
list.innerHTML = "";
@@ -394,6 +406,9 @@
const el = document.createElement("a");
el.className = `slot size-${state.iconSize}`;
el.href = ic.url; el.dataset.sec = "q"; el.dataset.i = i;
+ const autoX = 10 + (i % 3) * 100, autoY = 10 + Math.floor(i / 3) * 110;
+ el.style.left = (ic.x != null ? ic.x : autoX) + "px";
+ el.style.top = (ic.y != null ? ic.y : autoY) + "px";
if (!iconUsesBrand(ic)) {
el.style.setProperty("--iconBg", ic.color || "#d6ff3d");
el.style.setProperty("--iconFg", isLightColor(ic.color || "#d6ff3d") ? "#0b0e14" : "#ffffff");
@@ -402,7 +417,7 @@
el.style.setProperty("--iconFg", "#e7eaf1");
}
el.innerHTML = `
${iconArt(ic)}
${esc(ic.title || ic.url)}
`;
- wireItem(el, "q", i);
+ wireSlot(el, i);
list.appendChild(el);
});
const slotAdd = document.createElement("button");
@@ -411,6 +426,7 @@
slotAdd.oncontextmenu = (e) => e.preventDefault();
list.appendChild(slotAdd);
+ // RIGHT — Silent Mode cards with sub-description under the title.
const sm = state.sections.sm || [];
const grid = $("cardsGrid");
grid.innerHTML = "";
@@ -422,8 +438,9 @@
el.style.setProperty("--iconBg", ic.color || "#d6ff3d");
el.style.setProperty("--iconFg", isLightColor(ic.color || "#d6ff3d") ? "#0b0e14" : "#ffffff");
}
- el.innerHTML = `
${iconArt(ic)}
${styledTitle(ic.title)}
`;
- wireItem(el, "sm", i);
+ const sub = ic.sub ? `
${esc(ic.sub)}
` : "";
+ el.innerHTML = `
${iconArt(ic)}
${styledTitle(ic.title)}
${sub}`;
+ wireCard(el, i);
grid.appendChild(el);
});
const add = document.createElement("button");
@@ -435,25 +452,107 @@
$("ctxIconSizeVal").textContent = state.iconSize;
}
- // Drag-reorder: left column is a vertical stack, right column is a 3-wide
- // grid. We use the HTML5 drag-and-drop API because it's simple and the
- // user explicitly asked for drag-and-drop. A tiny move-threshold would
- // require pointer events; keeping HTML5 DnD means plain clicks navigate.
- function wireItem(el, sec, i) {
- el.draggable = true;
+ // LEFT slots use pointer events for free-drag: live movement with snap to
+ // a 20px grid, final x,y committed on pointerup. A short move threshold
+ // keeps plain clicks working for navigation. Dropping over the right
+ // card grid re-parents the item (removes x,y, appends to sm).
+ const SNAP = 20, DRAG_THRESHOLD = 4;
+ let drag = null;
+
+ function wireSlot(el, i) {
el.addEventListener("click", (e) => {
+ if (drag && drag.moved) { e.preventDefault(); return; }
e.preventDefault();
- const url = state.sections[sec][i].url;
+ const url = state.sections.q[i].url;
if (window.home && window.home.navigate) window.home.navigate(url);
else window.location.href = url;
});
el.addEventListener("contextmenu", (e) => {
e.preventDefault(); e.stopPropagation();
- openCtxItem(e.clientX, e.clientY, sec, i);
+ openCtxItem(e.clientX, e.clientY, "q", i);
+ });
+ el.addEventListener("pointerdown", (e) => startSlotDrag(e, i, el));
+ }
+ function startSlotDrag(e, i, el) {
+ if (e.button !== 0) return;
+ if (e.target.closest(".ctx-menu")) return;
+ const rect = el.getBoundingClientRect();
+ drag = {
+ kind: "slot", i, el,
+ grabX: e.clientX - rect.left, grabY: e.clientY - rect.top,
+ startX: e.clientX, startY: e.clientY, moved: false,
+ origLeft: el.style.left, origTop: el.style.top,
+ };
+ el.setPointerCapture(e.pointerId);
+ hideAllCtx();
+ }
+ window.addEventListener("pointermove", (e) => {
+ if (!drag || drag.kind !== "slot") return;
+ if (!drag.moved) {
+ if (Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < DRAG_THRESHOLD) return;
+ drag.moved = true;
+ drag.el.classList.add("dragging");
+ document.body.classList.add("dragging-any");
+ }
+ const slotsList = $("slotsList");
+ const target = document.elementFromPoint(e.clientX, e.clientY);
+ const overSlots = target && target.closest("#slotsList");
+ const overCards = target && target.closest("#cardsGrid");
+ document.querySelectorAll(".drop-target").forEach((g) => g.classList.remove("drop-target"));
+ if (overCards) overCards.classList.add("drop-target");
+ if (overSlots || !overCards) {
+ // Live-move within the free area.
+ const r = slotsList.getBoundingClientRect();
+ const w = drag.el.offsetWidth, h = drag.el.offsetHeight;
+ const nx = Math.max(0, Math.min(r.width - w, e.clientX - r.left - drag.grabX));
+ const ny = Math.max(0, Math.min(r.height - h, e.clientY - r.top - drag.grabY));
+ drag.el.style.left = Math.round(nx / SNAP) * SNAP + "px";
+ drag.el.style.top = Math.round(ny / SNAP) * SNAP + "px";
+ }
+ });
+ window.addEventListener("pointerup", (e) => {
+ if (!drag || drag.kind !== "slot") return;
+ const el = drag.el;
+ el.classList.remove("dragging");
+ document.body.classList.remove("dragging-any");
+ document.querySelectorAll(".drop-target").forEach((g) => g.classList.remove("drop-target"));
+ if (drag.moved) {
+ const target = document.elementFromPoint(e.clientX, e.clientY);
+ const overCards = target && target.closest("#cardsGrid");
+ if (overCards) {
+ // Re-parent into the right-side grid (drop x,y — cards flow).
+ const moved = state.sections.q[drag.i];
+ delete moved.x; delete moved.y;
+ state.sections.q.splice(drag.i, 1);
+ state.sections.sm.push(moved);
+ persist(); render();
+ } else {
+ // Commit the final x,y to state and persist.
+ const moved = state.sections.q[drag.i];
+ moved.x = parseInt(el.style.left, 10);
+ moved.y = parseInt(el.style.top, 10);
+ persist();
+ }
+ }
+ drag = null;
+ });
+
+ // RIGHT cards keep HTML5 DnD for grid reorder with before/after indicators.
+ function wireCard(el, i) {
+ el.draggable = true;
+ el.addEventListener("click", (e) => {
+ e.preventDefault();
+ const url = state.sections.sm[i].url;
+ if (window.home && window.home.navigate) window.home.navigate(url);
+ else window.location.href = url;
+ });
+ el.addEventListener("contextmenu", (e) => {
+ e.preventDefault(); e.stopPropagation();
+ openCtxItem(e.clientX, e.clientY, "sm", i);
});
el.addEventListener("dragstart", (e) => {
e.dataTransfer.effectAllowed = "move";
- e.dataTransfer.setData("application/x-slot", JSON.stringify({ sec, i }));
+ e.dataTransfer.setData("application/x-slot", JSON.stringify({ sec: "sm", i }));
el.classList.add("dragging");
hideAllCtx();
});
@@ -465,7 +564,6 @@
el.addEventListener("dragover", (e) => {
e.preventDefault(); e.dataTransfer.dropEffect = "move";
const r = el.getBoundingClientRect();
- // Both sections are 2D grids now — horizontal mid-split picks insert side.
const before = e.clientX < r.left + r.width / 2;
el.classList.toggle("drag-before", before);
el.classList.toggle("drag-after", !before);
@@ -480,20 +578,36 @@
const r = el.getBoundingClientRect();
const side = e.clientX < r.left + r.width / 2 ? -1 : 1;
let targetIdx = i + (side === 1 ? 1 : 0);
- moveItem(payload.sec, payload.i, sec, targetIdx);
+ moveItem(payload.sec, payload.i, "sm", targetIdx);
});
}
- // The slots-list and cards-grid themselves accept drops at the end — this
- // handles cross-column moves when the pointer lands on empty space.
+ // Container-level drops. Slot→card re-parenting is handled by the slot
+ // pointer flow; this handles card→slot (seeds x,y) and card reorder onto
+ // empty cards-grid space.
function wireContainer(container, sec) {
container.addEventListener("dragover", (e) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; });
container.addEventListener("drop", (e) => {
- if (e.target !== container) return;
e.preventDefault();
const payload = tryParse(e.dataTransfer.getData("application/x-slot"));
if (!payload) return;
- moveItem(payload.sec, payload.i, sec, state.sections[sec].length);
+ if (sec === "q") {
+ // Card → slot. Pull it out of sm, seed x,y under the drop point.
+ if (payload.sec !== "sm") return;
+ const r = container.getBoundingClientRect();
+ const moved = state.sections.sm[payload.i];
+ if (!moved) return;
+ state.sections.sm.splice(payload.i, 1);
+ const nx = Math.round(Math.max(0, e.clientX - r.left - 36) / SNAP) * SNAP;
+ const ny = Math.round(Math.max(0, e.clientY - r.top - 36) / SNAP) * SNAP;
+ moved.x = nx; moved.y = ny;
+ state.sections.q.push(moved);
+ persist(); render();
+ return;
+ }
+ // sec === "sm" — card reorder onto empty space (append).
+ if (e.target !== container) return;
+ moveItem(payload.sec, payload.i, "sm", state.sections.sm.length);
});
}
function tryParse(s) { try { return JSON.parse(s); } catch { return null; } }
@@ -648,10 +762,11 @@
editKey = { sec, i };
const isNew = i < 0;
$("modalTitle").textContent = isNew ? "Add link" : "Edit link";
- const c = isNew ? { title: "", url: "", color: "#d6ff3d", letter: "" }
+ const c = isNew ? { title: "", url: "", sub: "", color: "#d6ff3d", letter: "" }
: state.sections[sec][i];
$("mTitle").value = c.title || "";
$("mUrl").value = c.url || "";
+ $("mSub").value = c.sub || "";
$("mSection").value = sec;
$("mColor").value = c.color || "#d6ff3d";
$("mLetter").value = c.letter || "";
@@ -668,6 +783,7 @@
if (!/^[a-z]+:\/\//i.test(url) && !/^[a-z]+:/.test(url)) url = "https://" + url;
const next = {
title, url,
+ sub: $("mSub").value.trim() || null,
color: $("mColor").value,
letter: $("mLetter").value.trim() || null,
};
diff --git a/package.json b/package.json
index 193dfbfb..5006b903 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "theseus-navigator",
- "version": "0.3.93",
+ "version": "0.3.94",
"license": "MPL-2.0",
"description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
"author": "Silent Mode",