| index dd10f11..4a4ea1e 100644 |
| --- a/home/web/static/home.js |
| +++ b/home/web/static/home.js |
| @@ -20,7 +20,8 @@ import { |
| apiJSON, beginLogin, beginRegister, runLogin, runRegister, fetchBlob, putBlob, |
| } from "/static/backup-core.js"; |
| import { |
| - partitionServers, toggleStar, moveInGroup, orderServers, filterServers, |
| + partitionServers, partitionArchived, setArchived, |
| + toggleStar, moveInGroup, orderServers, filterServers, |
| } from "/static/serverlist.js"; |
| import { mountFader } from "/static/fader.js"; |
| |
| @@ -58,6 +59,15 @@ function spaceStatus(msg, kind) { |
| el.textContent = msg || ""; |
| el.className = "status" + (kind ? " " + kind : ""); |
| } |
| +function archiveStatus(msg, kind) { |
| + const el = $("archive-status"); |
| + el.textContent = msg || ""; |
| + el.className = "status" + (kind ? " " + kind : ""); |
| +} |
| + |
| +// hostOf is the display host for a saved url, tolerant of a non-URL value. |
| +function hostOf(url) { try { return new URL(url).host; } catch { return url || ""; } } |
| +function labelOf(s) { return s.name || hostOf(s.url); } |
| |
| // Whether this home server offers self-serve space creation (set from |
| // /api/features). The create-a-space controls only appear when it's both |
| @@ -188,8 +198,9 @@ function signOut() { |
| dekKey = null; |
| $("vault").classList.add("hidden"); |
| $("create").classList.add("hidden"); |
| - $("remove").classList.add("hidden"); |
| - removing = null; |
| + $("archive").classList.add("hidden"); |
| + archiving = null; |
| + clearUndo(); |
| $("spaces").textContent = ""; |
| $("signed-out").classList.remove("hidden"); |
| $("newhere").classList.add("hidden"); // fresh signed-out state, no stale panel |
| @@ -213,39 +224,119 @@ function showVault() { |
| $("signed-out").classList.add("hidden"); |
| $("reassure").classList.add("hidden"); |
| $("create").classList.add("hidden"); |
| + $("archive").classList.add("hidden"); |
| $("vault").classList.remove("hidden"); |
| $("open-create").classList.toggle("hidden", !canCreateSpace()); // the + to create |
| |
| const box = $("spaces"); |
| box.textContent = ""; |
| - const all = vault.obj.servers || []; |
| - if (!all.length) { |
| - $("space-search").classList.add("hidden"); |
| - box.append(emptyLine("No spaces saved yet — save one from its chat page and it'll appear here.")); |
| - return; |
| - } |
| |
| - // Fuzzy search filters both lists. It only appears once there's enough to sift |
| - // through; while a query is active, reordering is off (dragging a filtered |
| - // subset would be ambiguous) so the grips are hidden. |
| + // Archived spaces are pulled out first — hidden from the main list, reachable |
| + // through the "Archived (N)" affordance at the foot. |
| + const { active, archived } = partitionArchived(vault.obj.servers || []); |
| + |
| + // A just-archived space leaves an inline "Archived · Undo" banner (never a |
| + // floating toast); it survives re-renders and auto-clears on a timer. |
| + if (pendingUndo) box.append(undoBanner()); |
| + |
| + // Fuzzy search filters the active lists. It only appears once there's enough |
| + // to sift through; while a query is active, reordering is off (dragging a |
| + // filtered subset would be ambiguous) so the grips are hidden. |
| const search = $("space-search"); |
| - search.classList.toggle("hidden", all.length < 4); |
| + search.classList.toggle("hidden", active.length < 4); |
| const query = search.classList.contains("hidden") ? "" : search.value.trim(); |
| - const servers = query ? filterServers(all, query) : all; |
| - if (!servers.length) { |
| - box.append(emptyLine("No spaces match “" + query + "”.")); |
| - return; |
| - } |
| - const sortable = !query; |
| - |
| - // Two independently-sorted lists: Starred (→ the app's rail) and the rest |
| - // (→ a "more" affordance). Headings appear only once something's starred. |
| - const { starred, others } = partitionServers(servers); |
| - if (starred.length) { |
| - box.append(sectionHeading("Starred"), serverList(starred, sortable)); |
| - if (others.length) box.append(sectionHeading("More")); |
| + |
| + if (!active.length) { |
| + box.append(emptyLine(archived.length |
| + ? "All your spaces are archived — open the archive to bring one back." |
| + : "No spaces saved yet — save one from its chat page and it'll appear here.")); |
| + } else { |
| + const servers = query ? filterServers(active, query) : active; |
| + if (!servers.length) { |
| + box.append(emptyLine("No spaces match “" + query + "”.")); |
| + } else { |
| + const sortable = !query; |
| + // Two independently-sorted lists: Starred (→ the app's rail) and the rest |
| + // (→ a "more" affordance). Headings appear only once something's starred. |
| + const { starred, others } = partitionServers(servers); |
| + if (starred.length) { |
| + box.append(sectionHeading("Starred"), serverList(starred, sortable)); |
| + if (others.length) box.append(sectionHeading("More")); |
| + } |
| + if (others.length) box.append(serverList(others, sortable)); |
| + } |
| } |
| - if (others.length) box.append(serverList(others, sortable)); |
| + |
| + if (archived.length) box.append(archivedButton(archived.length)); |
| +} |
| + |
| +// ---- archive: undo banner + entry point -------------------------------------- |
| +// pendingUndo holds the pre-archive servers snapshot so a single tap fully |
| +// restores the entry (its star + exact position), not just the archived flag. |
| +let pendingUndo = null; // { name, prev } | null |
| +let undoTimer = 0; |
| + |
| +function clearUndo() { |
| + if (undoTimer) { clearTimeout(undoTimer); undoTimer = 0; } |
| + pendingUndo = null; |
| +} |
| + |
| +function undoBanner() { |
| + const p = document.createElement("p"); |
| + p.className = "undo-note"; |
| + const span = document.createElement("span"); |
| + span.textContent = "Archived " + pendingUndo.name + ". "; |
| + const btn = document.createElement("button"); |
| + btn.type = "button"; |
| + btn.className = "linkish"; |
| + btn.textContent = "Undo"; |
| + btn.addEventListener("click", undoArchive); |
| + p.append(span, btn); |
| + return p; |
| +} |
| + |
| +// A muted archive-box glyph (static, in-code SVG — safe for innerHTML), matching |
| +// the quiet grip: a lidded box with a handle slot. |
| +const ARCHIVE_SVG = |
| + '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" fill="none" ' + |
| + 'stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">' + |
| + '<rect x="2" y="2.5" width="12" height="3" rx="0.8"/>' + |
| + '<path d="M3 5.5v7a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-7"/>' + |
| + '<path d="M6.3 8.3h3.4"/></svg>'; |
| + |
| +// The foot-of-list button that opens the archive; shown only when something's in |
| +// it. The count keeps the archive discoverable without cluttering the main list. |
| +function archivedButton(n) { |
| + const btn = document.createElement("button"); |
| + btn.type = "button"; |
| + btn.className = "archived-open"; |
| + btn.addEventListener("click", openArchive); |
| + const label = document.createElement("span"); |
| + label.innerHTML = ARCHIVE_SVG; |
| + const text = document.createElement("span"); |
| + text.textContent = "Archived (" + n + ")"; |
| + label.append(text); |
| + const chev = document.createElement("span"); |
| + chev.className = "chev"; |
| + chev.setAttribute("aria-hidden", "true"); |
| + chev.textContent = "›"; |
| + btn.append(label, chev); |
| + return btn; |
| +} |
| + |
| +function archiveSpace(s) { |
| + clearUndo(); |
| + const prev = vault.obj.servers; |
| + pendingUndo = { name: labelOf(s), prev }; // set before applyServers so the banner renders |
| + undoTimer = setTimeout(() => { pendingUndo = null; if (vault) showVault(); }, 8000); |
| + applyServers(setArchived(prev, s.url, true)); // re-renders (with the banner) + persists |
| +} |
| + |
| +function undoArchive() { |
| + if (!pendingUndo) return; |
| + const prev = pendingUndo.prev; |
| + clearUndo(); |
| + applyServers(prev); // restore the exact prior array (star + position), re-render, persist |
| } |
| |
| function emptyLine(text) { |
| @@ -271,10 +362,6 @@ function serverList(group, sortable) { |
| |
| // A quiet 6-dot grip (static, in-code SVG — safe for innerHTML). iOS-style, it |
| // lives on the right so the star stays the left anchor; it surfaces on hover. |
| -const X_SVG = |
| - '<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">' + |
| - '<path d="M4 4l8 8M12 4l-8 8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>'; |
| - |
| const GRIP_SVG = |
| '<svg viewBox="0 0 10 16" width="10" height="16" aria-hidden="true">' + |
| '<circle cx="2" cy="3" r="1.25"/><circle cx="8" cy="3" r="1.25"/>' + |
| @@ -289,15 +376,20 @@ function serverRow(s, sortable) { |
| star.type = "button"; |
| star.className = "star" + (s.starred ? " on" : ""); |
| star.textContent = s.starred ? "★" : "☆"; |
| - star.setAttribute("aria-label", (s.starred ? "Unstar " : "Star ") + (s.name || s.url)); |
| - star.addEventListener("click", () => { applyServers(toggleStar(vault.obj.servers, s.url)); }); |
| + star.setAttribute("aria-label", (s.starred ? "Unstar " : "Star ") + labelOf(s)); |
| + // Star/reorder act on the ACTIVE list only; archived entries ride along at the |
| + // tail untouched (they never render a star or grip). |
| + star.addEventListener("click", () => { |
| + const { active, archived } = partitionArchived(vault.obj.servers); |
| + applyServers([...toggleStar(active, s.url), ...archived]); |
| + }); |
| |
| const main = document.createElement("div"); |
| main.className = "srv-main"; |
| const name = document.createElement("a"); |
| name.className = "name"; |
| name.href = s.url; |
| - name.textContent = s.name || new URL(s.url).host; |
| + name.textContent = labelOf(s); |
| const url = document.createElement("span"); |
| url.className = "url"; |
| url.textContent = s.url; |
| @@ -305,15 +397,16 @@ function serverRow(s, sortable) { |
| |
| li.append(star, main); |
| |
| - // Manage/remove: an × that reveals on hover (like the grip) and opens the |
| - // manage screen — where "remove from list" and the stern delete live. |
| - const rm = document.createElement("button"); |
| - rm.type = "button"; |
| - rm.className = "row-remove"; |
| - rm.setAttribute("aria-label", "Remove " + (s.name || s.url) + "…"); |
| - rm.innerHTML = X_SVG; |
| - rm.addEventListener("click", () => openRemove(s)); |
| - li.append(rm); |
| + // Archive: a quiet box glyph that reveals on hover (like the grip). One tap |
| + // sets it aside (reversible via the inline Undo); no confirmation — the archive |
| + // is where the permanent, strongly-worded delete lives. |
| + const arch = document.createElement("button"); |
| + arch.type = "button"; |
| + arch.className = "row-archive"; |
| + arch.setAttribute("aria-label", "Archive " + labelOf(s)); |
| + arch.innerHTML = ARCHIVE_SVG; |
| + arch.addEventListener("click", () => archiveSpace(s)); |
| + li.append(arch); |
| |
| // The drag handle: grab to reorder (pointer), or ↑/↓ when focused (keyboard) — |
| // the a11y reorder fallback. Omitted while a search filter is active (you can't |
| @@ -322,14 +415,15 @@ function serverRow(s, sortable) { |
| const handle = document.createElement("button"); |
| handle.type = "button"; |
| handle.className = "drag-handle"; |
| - handle.setAttribute("aria-label", "Reorder " + (s.name || s.url)); |
| + handle.setAttribute("aria-label", "Reorder " + labelOf(s)); |
| handle.innerHTML = GRIP_SVG; |
| handle.addEventListener("pointerdown", (e) => startDrag(e, li)); |
| handle.addEventListener("keydown", (e) => { |
| const dir = e.key === "ArrowUp" ? -1 : e.key === "ArrowDown" ? 1 : 0; |
| if (!dir) return; |
| e.preventDefault(); |
| - applyServers(moveInGroup(vault.obj.servers, s.url, dir)); |
| + const { active, archived } = partitionArchived(vault.obj.servers); |
| + applyServers([...moveInGroup(active, s.url, dir), ...archived]); |
| focusHandle(s.url); // re-render dropped focus; put it back on the moved row |
| }); |
| li.append(handle); |
| @@ -428,10 +522,10 @@ function commitOrder() { |
| for (const ul of $("spaces").querySelectorAll("ul.servers")) { |
| for (const li of ul.querySelectorAll("li")) order.push(li.dataset.url); |
| } |
| - const cur = vault.obj.servers || []; |
| - const next = orderServers(cur, order); |
| - if (next.length === cur.length && next.every((s, i) => s.url === cur[i].url)) return; |
| - vault.obj.servers = next; |
| + const { active, archived } = partitionArchived(vault.obj.servers || []); |
| + const next = orderServers(active, order); |
| + if (next.length === active.length && next.every((s, i) => s.url === active[i].url)) return; |
| + vault.obj.servers = [...next, ...archived]; |
| persistVault(); |
| } |
| |
| @@ -447,13 +541,13 @@ function applyServers(next) { |
| persistVault(); |
| } |
| |
| -async function persistVault() { |
| - vaultStatus("Saving…", "busy"); |
| +async function persistVault(status = vaultStatus) { |
| + status("Saving…", "busy"); |
| try { |
| await saveVault(); |
| - vaultStatus("Saved.", "ok"); |
| + status("Saved.", "ok"); |
| } catch (e) { |
| - vaultStatus(e.message || "Couldn't save your changes.", "error"); |
| + status(e.message || "Couldn't save your changes.", "error"); |
| } |
| } |
| |
| @@ -573,6 +667,7 @@ function ensureFader() { |
| } |
| |
| function openCreate() { |
| + clearUndo(); |
| $("new-space-result").classList.add("hidden"); |
| $("create-back").classList.remove("hidden"); |
| spaceStatus(""); |
| @@ -589,94 +684,173 @@ function closeCreate() { |
| showVault(); // reveals the list again, now including any newly created space |
| } |
| |
| -// ---- manage / remove / delete a space ------------------------------------------- |
| +// ---- archive: list, unarchive, permanent delete -------------------------------- |
| |
| -// removing is the entry being managed on the #remove screen. |
| -let removing = null; |
| +// archiving is the entry currently in the stern delete step of the archive. |
| +let archiving = null; |
| |
| -function removeStatus(msg, kind) { |
| - const el = $("remove-status"); |
| - el.textContent = msg || ""; |
| - el.className = "status" + (kind ? " " + kind : ""); |
| +// openArchive shows the archive screen (the archived-spaces list). |
| +function openArchive() { |
| + clearUndo(); |
| + archiving = null; |
| + $("vault").classList.add("hidden"); |
| + $("archive-stern").classList.add("hidden"); |
| + $("archive-main").classList.remove("hidden"); |
| + $("archive").classList.remove("hidden"); |
| + archiveStatus(""); |
| + renderArchiveList(); |
| } |
| |
| -// openRemove shows the manage screen for one saved space: "remove from list" |
| -// always; the stern delete only if this account created the space (checked |
| -// against the provisioner's ledger via GET /api/spaces — the server enforces |
| -// the same ownership on the actual delete, this just decides what to offer). |
| -function openRemove(s) { |
| - removing = s; |
| - const host = (() => { try { return new URL(s.url).host; } catch { return s.url; } })(); |
| - $("remove-title").textContent = s.name || host; |
| - $("remove-host").textContent = s.url; |
| - $("remove-main").classList.remove("hidden"); |
| - $("delete-stern").classList.add("hidden"); |
| - $("danger-zone").classList.add("hidden"); |
| - removeStatus(""); |
| - $("vault").classList.add("hidden"); |
| - $("remove").classList.remove("hidden"); |
| +function closeArchive() { |
| + archiving = null; |
| + $("archive").classList.add("hidden"); |
| + showVault(); |
| +} |
| + |
| +// closeArchiveStern steps back from the delete step to the archive list. |
| +function closeArchiveStern() { |
| + archiving = null; |
| + $("archive-stern").classList.add("hidden"); |
| + $("archive-main").classList.remove("hidden"); |
| + archiveStatus(""); |
| +} |
| + |
| +function renderArchiveList() { |
| + const box = $("archive-list"); |
| + box.textContent = ""; |
| + const { archived } = partitionArchived(vault.obj.servers || []); |
| + if (!archived.length) { |
| + box.append(emptyLine("Nothing archived.")); |
| + return; |
| + } |
| + const ul = document.createElement("ul"); |
| + ul.className = "servers archived"; |
| + for (const s of archived) ul.append(archiveRow(s)); |
| + box.append(ul); |
| +} |
| + |
| +function archiveRow(s) { |
| + const li = document.createElement("li"); |
| + li.dataset.url = s.url; |
| + |
| + const main = document.createElement("div"); |
| + main.className = "srv-main"; |
| + const name = document.createElement("span"); // plain text — archived spaces stay quiet |
| + name.className = "name"; |
| + name.textContent = labelOf(s); |
| + const url = document.createElement("span"); |
| + url.className = "url"; |
| + url.textContent = s.url; |
| + main.append(name, url); |
| + |
| + const unarch = document.createElement("button"); |
| + unarch.type = "button"; |
| + unarch.className = "pill"; |
| + unarch.textContent = "Unarchive"; |
| + unarch.addEventListener("click", () => unarchiveSpace(s)); |
| + |
| + const del = document.createElement("button"); |
| + del.type = "button"; |
| + del.className = "pill danger-ink"; |
| + del.textContent = "Delete…"; |
| + del.addEventListener("click", () => openArchiveDelete(s)); |
| + |
| + li.append(main, unarch, del); |
| + return li; |
| +} |
| + |
| +// unarchiveSpace moves the entry back to the active list. |
| +function unarchiveSpace(s) { |
| + vault.obj.servers = setArchived(vault.obj.servers, s.url, false); |
| + if (!partitionArchived(vault.obj.servers).archived.length) { |
| + closeArchive(); // archive is empty now — the space is back on the list |
| + persistVault(); |
| + return; |
| + } |
| + renderArchiveList(); |
| + persistVault(archiveStatus); |
| +} |
| + |
| +// openArchiveDelete opens the stern permanent-delete step for one archived space. |
| +// "Delete your access" (drop the saved entry) is always offered; for a space THIS |
| +// account created, the deeper "destroy for everyone" is revealed too (ownership |
| +// checked against the provisioner ledger; the server re-enforces it on delete). |
| +function openArchiveDelete(s) { |
| + archiving = s; |
| + const host = hostOf(s.url); |
| + $("stern-name").textContent = labelOf(s); |
| + $("stern-host").textContent = host; |
| + $("delete-arm").value = ""; |
| + $("delete-arm").placeholder = host; |
| + $("delete-access").disabled = true; |
| + $("destroy-zone").classList.add("hidden"); |
| + $("destroy-forever").disabled = true; |
| + $("archive-main").classList.add("hidden"); |
| + $("archive-stern").classList.remove("hidden"); |
| + archiveStatus(""); |
| if (spacesEnabled) { |
| apiJSON("/api/spaces", { headers: { Authorization: "Bearer " + state.token } }) |
| .then((r) => { |
| const mine = (r.spaces || []).some((sp) => sp.host === host); |
| - if (mine && removing === s) $("danger-zone").classList.remove("hidden"); |
| + if (mine && archiving === s) { |
| + $("destroy-zone").classList.remove("hidden"); |
| + prefetchLogin(); // the destroy ceremony needs fresh options (Safari gesture rule) |
| + } |
| }) |
| - .catch(() => { /* can't confirm ownership — don't offer deletion */ }); |
| + .catch(() => { /* can't confirm ownership — don't offer the destroy */ }); |
| } |
| } |
| |
| -function closeRemove() { |
| - removing = null; |
| - $("remove").classList.add("hidden"); |
| - showVault(); |
| +// After a delete/destroy empties the entry, return to wherever makes sense: the |
| +// archive list if anything's left, else the main list. |
| +function afterDelete(okMsg) { |
| + archiving = null; |
| + if (partitionArchived(vault.obj.servers).archived.length) { |
| + closeArchiveStern(); |
| + renderArchiveList(); |
| + archiveStatus(okMsg, "ok"); |
| + } else { |
| + closeArchive(); |
| + vaultStatus(okMsg, "ok"); |
| + } |
| } |
| |
| -// removeFromList drops the entry from the vault — the light action. The space |
| -// itself keeps running; an invite/add-device link is the way back in. |
| -async function removeFromList() { |
| - if (!removing) return; |
| - vault.obj.servers = (vault.obj.servers || []).filter((x) => x.url !== removing.url); |
| - removeStatus("Removing…", "busy"); |
| +// deleteAccess drops the saved entry from the vault — permanently. The space |
| +// keeps running for everyone else; this only removes THIS account's saved way |
| +// back in (the strongly-worded warning makes that consequence explicit). |
| +async function deleteAccess() { |
| + if (!archiving) return; |
| + const s = archiving; |
| + vault.obj.servers = (vault.obj.servers || []).filter((x) => x.url !== s.url); |
| + archiveStatus("Deleting…", "busy"); |
| try { |
| await saveVault(); |
| - closeRemove(); |
| - vaultStatus("Removed from your list.", "ok"); |
| + afterDelete("Removed from your account."); |
| } catch (e) { |
| - removeStatus(e.message || "Couldn't save the change.", "error"); |
| + archiveStatus(e.message || "Couldn't save the change.", "error"); |
| } |
| } |
| |
| -// The stern step: type the space's address to arm the button, then a FRESH |
| -// passkey ceremony (Face ID) authorises the deletion — a link token alone can |
| -// never destroy a space. The server re-checks ownership against the ledger. |
| -function openDelete() { |
| - const host = (() => { try { return new URL(removing.url).host; } catch { return removing.url; } })(); |
| - $("stern-host").textContent = host; |
| - $("delete-arm").value = ""; |
| - $("delete-arm").placeholder = host; |
| - $("delete-forever").disabled = true; |
| - $("remove-main").classList.add("hidden"); |
| - $("delete-stern").classList.remove("hidden"); |
| - removeStatus(""); |
| - prefetchLogin(); // the ceremony ahead needs fresh options (Safari gesture rule) |
| -} |
| - |
| -async function deleteForever() { |
| - if (!removing) return; |
| +// destroyForEveryone is the owner-only deeper action: a FRESH passkey ceremony |
| +// (a link token alone can never destroy a space) then DELETE against the |
| +// provisioner, which re-checks ownership against its ledger. |
| +async function destroyForEveryone() { |
| + if (!archiving) return; |
| + const s = archiving; |
| const host = $("stern-host").textContent; |
| - const btn = $("delete-forever"); |
| + const btn = $("destroy-forever"); |
| btn.disabled = true; |
| - removeStatus("Confirm with your passkey…", "busy"); |
| + archiveStatus("Confirm with your passkey…", "busy"); |
| let proof; |
| try { |
| ({ proof } = await runLogin(loginBegin)); |
| } catch (e) { |
| prefetchLogin(); |
| - removeStatus(e.name === "NotAllowedError" ? "Passkey prompt was dismissed." : (e.message || String(e)), "error"); |
| + archiveStatus(e.name === "NotAllowedError" ? "Passkey prompt was dismissed." : (e.message || String(e)), "error"); |
| btn.disabled = false; |
| return; |
| } |
| - removeStatus("Deleting the space…", "busy"); |
| + archiveStatus("Destroying the space…", "busy"); |
| try { |
| const res = await fetch("/api/spaces/" + encodeURIComponent(host), { |
| method: "DELETE", |
| @@ -688,12 +862,11 @@ async function deleteForever() { |
| if (!res.ok && res.status !== 404) { |
| throw new Error(body.message || body.error || `Couldn't delete (HTTP ${res.status}).`); |
| } |
| - vault.obj.servers = (vault.obj.servers || []).filter((x) => x.url !== removing.url); |
| + vault.obj.servers = (vault.obj.servers || []).filter((x) => x.url !== s.url); |
| try { await saveVault(); } catch { /* the space is gone; the entry retries next save */ } |
| - closeRemove(); |
| - vaultStatus("Space deleted.", "ok"); |
| + afterDelete("Space destroyed."); |
| } catch (e) { |
| - removeStatus(e.message || String(e), "error"); |
| + archiveStatus(e.message || String(e), "error"); |
| btn.disabled = false; |
| } |
| } |
| @@ -735,19 +908,16 @@ $("open-create").addEventListener("click", openCreate); |
| $("create-back").addEventListener("click", closeCreate); |
| $("create-done").addEventListener("click", closeCreate); |
| // (Creating a space is the fader now — mounted in openCreate; see fader.js.) |
| -$("remove-back").addEventListener("click", closeRemove); |
| -$("remove-from-list").addEventListener("click", removeFromList); |
| -$("open-delete").addEventListener("click", openDelete); |
| -$("delete-cancel").addEventListener("click", () => { |
| - $("delete-stern").classList.add("hidden"); |
| - $("remove-main").classList.remove("hidden"); |
| - removeStatus(""); |
| -}); |
| -// The typed address arms the delete — exact match only. |
| +$("archive-back").addEventListener("click", closeArchive); |
| +$("archive-cancel").addEventListener("click", closeArchiveStern); |
| +$("delete-access").addEventListener("click", deleteAccess); |
| +$("destroy-forever").addEventListener("click", destroyForEveryone); |
| +// The typed address arms both delete actions — exact match only. |
| $("delete-arm").addEventListener("input", () => { |
| - $("delete-forever").disabled = $("delete-arm").value.trim() !== $("stern-host").textContent; |
| + const armed = $("delete-arm").value.trim() === $("stern-host").textContent; |
| + $("delete-access").disabled = !armed; |
| + $("destroy-forever").disabled = !armed; |
| }); |
| -$("delete-forever").addEventListener("click", deleteForever); |
| // Live fuzzy filter: re-render the (unlocked) list on each keystroke. The input |
| // lives outside #spaces, so re-rendering the lists never steals its focus. |
| $("space-search").addEventListener("input", () => { if (vault) showVault(); }); |