From 2821453278d671d5979ec09e83e79944d25f61b4 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Wed, 21 Oct 2026 00:27:21 +0300 Subject: [PATCH] =?UTF-8?q?Theseus=200.3.94:=20home=20=E2=80=94=20free-dra?= =?UTF-8?q?g=20left=20area,=20card=20sub-descriptions=20back?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The left column goes back to free positioning: each icon stores its own x,y and snaps to a 20px grid; a faint grid shows underneath while dragging so placement feels OS-desktop. Live-move via pointer events (clicks still navigate thanks to a 4px move threshold). Dropping a left icon over the right card grid re-parents it as a card; dropping a card onto the left area turns it into a free-positioned icon. Silent Mode cards get their one-line description back under the title. Add/edit modal gains a Description field. Defaults seed sub-text on every Silent Mode card and x,y on every Your-links icon. --- home.html | 230 ++++++++++++++++++++++++++++++++++++++------------- package.json | 2 +- 2 files changed, 174 insertions(+), 58 deletions(-) diff --git a/home.html b/home.html index 29befc30..72ebb1b0 100644 --- a/home.html +++ b/home.html @@ -35,14 +35,23 @@ gap: 36px; max-width: 1400px; margin: 2.4rem auto 0; padding: 0 32px 48px; align-items: start; } @media (max-width: 900px) { .homepage { grid-template-columns: 1fr; gap: 20px; padding: 0 14px 32px; } } - /* LEFT AREA — 3-column grid of slots, with the "add" button flowing in - the grid alongside them. Icons fill left-to-right, top-to-bottom; the - container is wide enough that a few icons still read as a tidy row. */ + /* LEFT AREA — free-positioning canvas. Each icon stores its own x,y and + snaps to a 20px grid. A faint grid shows underneath while dragging to + make placement feel OS-desktop-like. */ .left-column { display: flex; flex-direction: column; gap: 14px; } - .slots-list { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; width: 100%; } - @media (max-width: 900px) { .slots-list { grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); } } + .slots-list { position: relative; width: 100%; min-height: 480px; padding: 8px 4px; border-radius: 10px; } + .slots-list::before { + content: ""; position: absolute; inset: 0; opacity: 0; + background-image: linear-gradient(to right, rgba(255,255,255,.04) 1px, transparent 1px), + linear-gradient(to bottom, rgba(255,255,255,.04) 1px, transparent 1px); + background-size: 20px 20px; pointer-events: none; transition: opacity .15s; + border-radius: 10px; + } + body.dragging-any .slots-list::before { opacity: 1; } + .slots-list.drop-target, .cards-grid.drop-target { outline: 2px dashed var(--acid); outline-offset: 4px; border-radius: 14px; } + .slots-list .slot { position: absolute; } - .slot { position: relative; display: flex; flex-direction: column; align-items: center; + .slot { display: flex; flex-direction: column; align-items: center; cursor: pointer; user-select: none; text-decoration: none; color: inherit; transition: transform .12s ease; } .slot:hover { transform: translateY(-2px); } @@ -61,23 +70,16 @@ .slot.size-medium .tile { width: 72px; height: 72px; font-size: 28px; border-radius: 14px; } .slot.size-large .tile { width: 88px; height: 88px; font-size: 34px; border-radius: 18px; } .slot.size-small .label { font-size: 10.5px; max-width: 72px; } - .slot.dragging { opacity: .6; } - .slot.drag-before::before, .slot.drag-after::after { - content: ""; position: absolute; top: 10%; bottom: 10%; width: 3px; - background: var(--acid); border-radius: 2px; box-shadow: 0 0 8px var(--acid); - } - .slot.drag-before::before { left: -8px; } - .slot.drag-after::after { right: -8px; } + .slot.dragging { opacity: .85; cursor: grabbing; z-index: 2; } - /* Slot-add sits in the grid alongside the slot icons so it's always on - the next free cell, not pinned under the whole column. */ - .slot-add { display: flex; align-items: center; justify-content: center; - min-height: 72px; border-radius: 12px; border: 2px dashed var(--line2); - background: transparent; color: var(--mut); font-size: 22px; cursor: pointer; - transition: color .12s, border-color .12s, background .12s; - margin-top: 15px; /* visually below the label of the icon above */ } - .slots-list .slot-add { margin-top: 0; } /* in-grid, no extra top gap */ - .slot-add:hover { color: var(--acid); border-color: var(--acid); background: rgb(from var(--acid) r g b / .04); } + /* Slot-add floats at the bottom-left of the free-positioning area so the + user always has a way to add without hunting for empty space. */ + .slot-add { position: absolute; left: 10px; bottom: 10px; + display: flex; align-items: center; justify-content: center; + width: 60px; height: 60px; border-radius: 14px; border: 2px dashed var(--line2); + background: rgba(20,26,36,.6); color: var(--mut); font-size: 20px; cursor: pointer; + transition: color .12s, border-color .12s, background .12s; } + .slot-add:hover { color: var(--acid); border-color: var(--acid); background: rgb(from var(--acid) r g b / .08); } /* RIGHT COLUMN — just the 3-column grid of Silent Mode cards; search lives in the hero above. */ @@ -86,8 +88,8 @@ @media (max-width: 980px) { .cards-grid { grid-template-columns: repeat(2, 1fr); } } @media (max-width: 560px) { .cards-grid { grid-template-columns: 1fr; } } - .card { display: flex; flex-direction: column; align-items: center; justify-content: center; - gap: 10px; padding: 20px 14px; min-height: 128px; + .card { display: flex; flex-direction: column; align-items: center; justify-content: flex-start; + gap: 8px; padding: 18px 14px 16px; min-height: 148px; background: var(--card); border: 1px solid var(--line); border-radius: 14px; text-decoration: none; color: inherit; cursor: pointer; position: relative; transition: transform .12s, background .12s, border-color .12s; } @@ -102,6 +104,10 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; } .card .card-title .tld { color: var(--acid); font-weight: 700; letter-spacing: .3px; text-shadow: 0 0 8px rgb(from var(--acid) r g b / .35); } + .card .card-sub { font-size: 12px; color: var(--mut); text-align: center; line-height: 1.35; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; + overflow: hidden; max-width: 100%; } + .card:hover .card-sub { color: var(--ink); } .card.dragging { opacity: .6; } .card.drag-before::before, .card.drag-after::after { content: ""; position: absolute; top: 10%; bottom: 10%; width: 3px; @@ -231,6 +237,8 @@

Add link

+ +
@@ -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",