diff --git a/home-brand/ariadne-mark.svg b/home-brand/ariadne-mark.svg new file mode 100644 index 00000000..40e74d09 --- /dev/null +++ b/home-brand/ariadne-mark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/home.html b/home.html index 94a6aa75..c08a307f 100644 --- a/home.html +++ b/home.html @@ -82,6 +82,14 @@ background: transparent; 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 / .04); } + /* Empty slots in the grid: drop targets for pinning any link to a + specific slot. Order matches the grid's cell index. Subtly visible + so users see where they can drop; brighter on hover of the strip. */ + .slot-empty { display: block; min-height: 72px; border-radius: 12px; + border: 2px dashed rgb(from var(--line2) r g b / .55); background: transparent; + transition: border-color .12s, background .12s; } + .slots-list:hover .slot-empty { border-color: var(--line2); } + .slot-empty.drop-hot { border-color: var(--acid); background: rgb(from var(--acid) r g b / .06); } /* RIGHT COLUMN — just the 3-column grid of Silent Mode cards; search lives in the hero above. */ @@ -306,7 +314,7 @@ prometheus: ``, hermes: { src: "home-brand/hermes-mark.svg" }, helios: ``, - ariadne: { src: "home-brand/ariadne-pottery.svg" }, + ariadne: { src: "home-brand/ariadne-mark.svg" }, pithos: ``, aegis: ``, bergamot: ``, @@ -400,25 +408,38 @@ function iconUsesBrand(icon) { return !!(icon.brand && BRAND[icon.brand]); } function render() { - // LEFT — fixed-cell grid. Icons flow left-to-right, top-to-bottom; the - // "+" cell always sits at the end so there's a visible way to add. + // LEFT — fixed-cell grid of up to 9+ slots (3 wide × as many rows as + // needed). Each item in q[] may carry a `slot: number` to pin it to a + // specific cell; items without a slot flow top-to-bottom into the + // first unoccupied cells. Empty cells are drop targets, visible on + // hover of the grid. The "+" cell sits at the end so adding is always + // one click away. const q = state.sections.q || []; + const slotCells = layoutSlots(q); const list = $("slotsList"); list.innerHTML = ""; - q.forEach((ic, i) => { - const el = document.createElement("a"); - el.className = `slot size-${state.iconSize}`; - el.href = ic.url; el.dataset.sec = "q"; el.dataset.i = i; - if (!iconUsesBrand(ic)) { - el.style.setProperty("--iconBg", ic.color || "#d6ff3d"); - el.style.setProperty("--iconFg", isLightColor(ic.color || "#d6ff3d") ? "#0b0e14" : "#ffffff"); + slotCells.forEach((entry, cellIdx) => { + if (entry) { + const ic = entry.item, i = entry.i; + const el = document.createElement("a"); + el.className = `slot size-${state.iconSize}`; + el.href = ic.url; el.dataset.sec = "q"; el.dataset.i = i; el.dataset.cell = cellIdx; + if (!iconUsesBrand(ic)) { + el.style.setProperty("--iconBg", ic.color || "#d6ff3d"); + el.style.setProperty("--iconFg", isLightColor(ic.color || "#d6ff3d") ? "#0b0e14" : "#ffffff"); + } else { + el.style.setProperty("--iconBg", "transparent"); + el.style.setProperty("--iconFg", "#e7eaf1"); + } + el.innerHTML = `
${iconArt(ic)}
${esc(ic.title || ic.url)}
`; + wireSlot(el, i); + list.appendChild(el); } else { - el.style.setProperty("--iconBg", "transparent"); - el.style.setProperty("--iconFg", "#e7eaf1"); + const empty = document.createElement("div"); + empty.className = "slot-empty"; empty.dataset.cell = cellIdx; + wireEmptySlot(empty, cellIdx); + list.appendChild(empty); } - el.innerHTML = `
${iconArt(ic)}
${esc(ic.title || ic.url)}
`; - wireSlot(el, i); - list.appendChild(el); }); const slotAdd = document.createElement("button"); slotAdd.className = "slot-add"; slotAdd.textContent = "+"; slotAdd.title = "Add link"; @@ -452,13 +473,139 @@ $("ctxIconSizeVal").textContent = state.iconSize; } + // Lay out the q[] items into a fixed-slot grid: pinned items (with a + // `slot: number`) go into that cell; unpinned items flow into the first + // free cell, top-to-bottom. The grid always extends to at least one + // trailing empty row for drop space. + function layoutSlots(items, minSlots = 9) { + const pinned = []; const unpinned = []; + items.forEach((ic, i) => { + const s = Number.isInteger(ic.slot) && ic.slot >= 0 ? ic.slot : null; + if (s !== null) pinned.push({ item: ic, i, slot: s }); + else unpinned.push({ item: ic, i }); + }); + const maxPinned = pinned.reduce((m, p) => Math.max(m, p.slot), -1); + const used = pinned.length + unpinned.length; + let need = Math.max(minSlots, maxPinned + 1, used); + need = Math.ceil(need / 3) * 3; + need = Math.max(need, used + 1); // keep one trailing empty cell visible + need = Math.ceil(need / 3) * 3; + const cells = new Array(need).fill(null); + for (const p of pinned) { + if (p.slot < cells.length && !cells[p.slot]) cells[p.slot] = { item: p.item, i: p.i }; + else unpinned.push({ item: p.item, i: p.i }); // collision: demote to unpinned + } + let u = 0; + for (let i = 0; i < cells.length && u < unpinned.length; i++) { + if (!cells[i]) { cells[i] = unpinned[u++]; } + } + return cells; + } + // Both sections are 2D grids: slots are fixed cells on the left (3 wide), // cards are fixed cells on the right (3 wide). Drag reorders inside a // section with a before/after indicator on the hover target; drop onto // the other container's empty space re-parents (appends). - function wireSlot(el, i) { wireGridItem(el, "q", i); } + // Left-grid slots: drop onto a filled slot swaps the two slot assignments + // (so the user can rearrange visually without having to think about array + // order). Drop onto an empty slot is handled by wireEmptySlot. + function wireSlot(el, i) { + el.draggable = true; + el.addEventListener("click", (e) => { + e.preventDefault(); + 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, "q", i); + }); + el.addEventListener("dragstart", (e) => { + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("application/x-slot", JSON.stringify({ sec: "q", i })); + el.classList.add("dragging"); + hideAllCtx(); + }); + el.addEventListener("dragend", () => { + el.classList.remove("dragging"); + document.querySelectorAll(".drop-hot,.drag-before,.drag-after") + .forEach((n) => n.classList.remove("drop-hot", "drag-before", "drag-after")); + }); + el.addEventListener("dragover", (e) => { + e.preventDefault(); e.dataTransfer.dropEffect = "move"; + el.classList.add("drag-before"); + }); + el.addEventListener("dragleave", () => el.classList.remove("drag-before")); + el.addEventListener("drop", (e) => { + e.preventDefault(); el.classList.remove("drag-before"); + const payload = tryParse(e.dataTransfer.getData("application/x-slot")); + if (!payload) return; + const targetCell = Number(el.dataset.cell); + if (payload.sec !== "q") { pinItemToSlot(payload.sec, payload.i, targetCell); return; } + if (payload.i === i) return; + swapSlots(payload.i, i, targetCell); + }); + } function wireCard(el, i) { wireGridItem(el, "sm", i); } + // Swap the pinned-slot positions of two q items. The dropped item takes + // over the target's cell; the target moves into whatever cell the dropped + // item was in. Unpinned items get pinned as part of the swap. + function swapSlots(fromI, toI, targetCell) { + const q = state.sections.q; + const src = q[fromI], dst = q[toI]; + if (!src || !dst) return; + const prevSrcSlot = Number.isInteger(src.slot) ? src.slot : null; + src.slot = targetCell; + // The target item moves into the source's old slot; if source was + // unpinned, we need to give the target a slot too (its current flowed + // position) so it doesn't jump when the source leaves. + if (prevSrcSlot !== null) dst.slot = prevSrcSlot; + else { + // Find where dst currently sits in the laid-out grid, pre-swap. + const grid = layoutSlots(q); + const dstCell = grid.findIndex((e) => e && e.i === toI); + dst.slot = dstCell >= 0 ? dstCell : targetCell; + // After the swap, dst takes the old source cell. Re-compute. + const srcCell = grid.findIndex((e) => e && e.i === fromI); + if (srcCell >= 0) dst.slot = srcCell; + } + persist(); render(); + } + + // Empty cell in the left grid: accepts a dropped slot (from q or sm) and + // pins the item to this cell's index. Dropping an unpinned item here + // promotes it to pinned; dropping a pinned item just moves its pin. + function wireEmptySlot(el, cellIdx) { + el.addEventListener("dragover", (e) => { + e.preventDefault(); e.dataTransfer.dropEffect = "move"; + el.classList.add("drop-hot"); + }); + el.addEventListener("dragleave", () => el.classList.remove("drop-hot")); + el.addEventListener("drop", (e) => { + e.preventDefault(); el.classList.remove("drop-hot"); + const payload = tryParse(e.dataTransfer.getData("application/x-slot")); + if (!payload) return; + pinItemToSlot(payload.sec, payload.i, cellIdx); + }); + } + + // Place the dragged item at `cellIdx` in the q grid, pinning it. + // Cross-section (sm → q) moves the item first, then pins. Same-cell + // self-drop is a no-op. + function pinItemToSlot(fromSec, fromI, cellIdx) { + if (fromSec !== "q") { + const [m] = state.sections[fromSec].splice(fromI, 1); + m.slot = cellIdx; + state.sections.q.push(m); + } else { + const it = state.sections.q[fromI]; if (!it) return; + it.slot = cellIdx; + } + persist(); render(); + } + function wireGridItem(el, sec, i) { el.draggable = true; el.addEventListener("click", (e) => { diff --git a/package.json b/package.json index efaedda4..0af828b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "theseus-navigator", - "version": "0.3.104", + "version": "0.3.105", "license": "MPL-2.0", "description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.", "author": "Silent Mode",