feat(theseus): add a page to favorites by hand

The favorites row's right-click menu could only save the current page.
"Add a page…" opens a two-field form: the address (scheme optional,
must be a real host — Chromium's parser happily percent-encodes junk
like "not a url" into a bogus bookmark, so hosts are checked) and a
title that fills itself from the address, brand-cased like the chips,
until you edit it. Duplicates are refused; the site's /favicon.ico is
used for the chip.
This commit is contained in:
Local Dev 2026-09-25 08:29:00 +02:00
parent 5b6b693694
commit df6eb5d35b

View file

@ -401,6 +401,9 @@
color: var(--ink); font: inherit; font-size: 13px; outline: none; }
.promptbox input:focus { border-color: rgb(from var(--acid) r g b / .55); }
.promptbox .pact { display: flex; gap: 6px; justify-content: flex-end; margin-top: 10px; }
.promptbox .pfield { display: flex; flex-direction: column; gap: 4px; margin-top: 8px; }
.promptbox .pfield label { font-size: 11.5px; color: var(--dim); }
.promptbox .perr { color: #f6768a; font-size: 12px; margin-top: 8px; }
.promptbox .pbtn { padding: 6px 12px; border-radius: 6px; border: 1px solid var(--line);
background: var(--surface2, #0f1621); color: var(--ink); cursor: pointer;
font: inherit; font-size: 12.5px; }
@ -1121,10 +1124,68 @@
setTimeout(() => { input.focus(); input.select(); }, 0);
});
}
// "Add a page…": a two-field form (address + title). The title fills itself
// from the address as you type — brand-cased host, same as the chip label —
// until you edit it by hand; then it is yours. Resolves { url, title } or null.
function normalizeBookmarkUrl(s) {
let v = String(s || "").trim();
if (!v) return "";
if (!/^[a-z][a-z0-9+.-]*:/i.test(v)) v = "https://" + v.replace(/^\/+/, "");
try {
const u = new URL(v);
if (!/^https?:$|^bns:$|^file:$/.test(u.protocol)) return "";
// A real host: letters/digits/dots/dashes (IDNA already applied), no
// percent-escapes from spaces, and at least one dot or "localhost".
if (u.protocol !== "file:" && !(/^[a-z0-9.-]+$/i.test(u.hostname) && (u.hostname.includes(".") || u.hostname === "localhost"))) return "";
return u.href;
} catch { return ""; }
}
function inBookmarkForm(initial = {}) {
return new Promise((resolve) => {
const mask = document.createElement("div"); mask.className = "promptmask";
mask.innerHTML = `<div class="promptbox"><div class="plbl">Add a page to favorites</div>`
+ `<div class="pfield"><label>Address</label><input type="text" class="purl" spellcheck="false" placeholder="example.com or https://…"></div>`
+ `<div class="pfield"><label>Title</label><input type="text" class="ptitle" spellcheck="false" placeholder="Filled in from the address — edit as you like"></div>`
+ `<div class="perr" hidden></div>`
+ `<div class="pact"><button class="pbtn pcancel" type="button">Cancel</button><button class="pbtn primary pok" type="button">Save</button></div></div>`;
document.body.appendChild(mask);
const url = mask.querySelector(".purl"), title = mask.querySelector(".ptitle"), err = mask.querySelector(".perr");
url.value = String(initial.url ?? ""); title.value = String(initial.title ?? "");
let titleTouched = !!title.value;
const autoTitle = () => {
if (titleTouched) return;
const norm = normalizeBookmarkUrl(url.value);
let host = ""; try { host = norm ? new URL(norm).hostname.replace(/^www\./, "") : ""; } catch {}
title.value = host ? bookmarkShortLabel("", host) : "";
};
url.addEventListener("input", autoTitle);
title.addEventListener("input", () => { titleTouched = title.value.trim().length > 0; if (!titleTouched) autoTitle(); });
const done = (val) => { try { mask.remove(); } catch {} document.removeEventListener("keydown", key); resolve(val); };
const submit = () => {
const norm = normalizeBookmarkUrl(url.value);
if (!norm) { err.textContent = "That doesn't look like a web address."; err.hidden = false; url.focus(); return; }
if (bookmarks.some((b) => b.url === norm)) { err.textContent = "Already in your favorites."; err.hidden = false; url.focus(); return; }
let host = ""; try { host = new URL(norm).hostname.replace(/^www\./, ""); } catch {}
done({ url: norm, title: title.value.trim() || (host ? bookmarkShortLabel("", host) : norm) });
};
const key = (e) => { if (e.key === "Escape") done(null); else if (e.key === "Enter") submit(); };
document.addEventListener("keydown", key);
mask.addEventListener("mousedown", (e) => { if (e.target === mask) done(null); });
mask.querySelector(".pcancel").addEventListener("click", () => done(null));
mask.querySelector(".pok").addEventListener("click", submit);
setTimeout(() => { url.focus(); url.select(); }, 0);
});
}
async function addBookmarkByHand(initial) {
const r = await inBookmarkForm(initial);
if (!r) return;
let favicon = null; try { const u = new URL(r.url); if (/^https?:$/.test(u.protocol)) favicon = `${u.origin}/favicon.ico`; } catch {}
T.addBookmark({ title: r.title, url: r.url, favicon });
}
// Right-click context menu on the bookmarks bar. Two modes:
// - On a specific bookmark: Open / Edit title / Remove
// - On empty area OR the placeholder text: Add current page (or Remove
// if the page is already bookmarked)
// if the page is already bookmarked), and Add a page… by hand
function closeBmMenu() { const m = document.getElementById("bmMenu"); if (m) m.remove(); }
function openBmMenu(x, y, ctx) {
closeBmMenu();
@ -1147,6 +1208,7 @@
} else {
items.push({ label: "Add current page", disabled: true });
}
items.push({ label: "Add a page…", act: () => addBookmarkByHand() });
const m = document.createElement("div");
m.id = "bmMenu"; m.className = "ctxmenu";
m.innerHTML = items.map((it, i) => it.sep