Theseus 0.3.90: a desktop home page — two columns, brand icons, pick-and-move
The new-tab page is now a two-column desktop. The right column holds the
curated Silent Mode X-family (Theseus.X, SilentMode.X, Sirius.X,
Hephaestus.X, Prometheus.X, Hermes.X, Helios.X, Ariadne.X, Aegis.X) with
real brand marks pulled from each product's own favicon or add-on manifest.
The left column is yours — hello.bch, sia.coin, Pithos.sia, Bergamot.X by
default, plus whatever you add. Icons pick up and move within and across
columns in edit mode.
Edit mode also carries per-column default-size pickers (S / M / L / XL),
six background presets + a custom colour + an image upload, and + slots
in each column. The Ariadne.X mark uses the real face/thread illustration
in ancient-Greek-pottery colours (terracotta rings on cream). The Aegis.X
tile matches the one the add-on itself ships.
The on-disk format moves from a bare array of cards (v1) to a v3 object:
{ version:3, iconSize, quickSize, background, sections:{ sm, q } }
On first read, a v1 array lands as sections.q and the Silent Mode column
stays at the code default. The remote home-cards.json URL is still
accepted as either shape; migrateCards() normalises before use.
This commit is contained in:
parent
5e7a8b14a8
commit
94ec382f20
4 changed files with 716 additions and 335 deletions
6
home-brand/ariadne-pottery.svg
Normal file
6
home-brand/ariadne-pottery.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 14 KiB |
891
home.html
891
home.html
|
|
@ -1,292 +1,603 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>Theseus</title>
|
||||
<!-- Theseus compass mark as the tab icon of the home / new-tab page (inline, so it needs no network and no file path). Same mark as theseus.x/assets/favicon.svg. -->
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%230b0e14'/%3E%3Cpath d='M32 6 L38 32 L26 32 Z' fill='%23e04e4e'/%3E%3Cpath d='M38 32 L32 58 L26 32 Z' fill='%23d6ff3d'/%3E%3Cpath d='M6 32 L32 27 L58 32 L32 37 Z' fill='%23d6ff3d' opacity='0.55'/%3E%3Ccircle cx='32' cy='32' r='3' fill='%230b0e14'/%3E%3C/svg%3E">
|
||||
<style>
|
||||
:root { color-scheme: light dark;
|
||||
--bg1: #16202e; --bg2: #0b0e14;
|
||||
--card: #141a24; --card-hi: #18202c; --line: #ffffff12; --line2: #ffffff22;
|
||||
--ink: #e7eaf1; --mut: #8b98a9; --dim: #5e6678;
|
||||
--acid: #d6ff3d; --blue: #4b7bec; --sia: #b39ddb; --srv: #4fd1a5; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-height: 100vh; font-family: system-ui, sans-serif;
|
||||
background: radial-gradient(1200px 600px at 50% -10%, var(--bg1), var(--bg2));
|
||||
color: var(--ink); display: flex; flex-direction: column; align-items: center; }
|
||||
.hero { text-align: center; margin-top: 8vh; padding: 0 1rem; width: 100%; }
|
||||
.mark { font-size: 64px; line-height: 1; }
|
||||
h1 { font-size: 2.4rem; margin: .4rem 0 .2rem; letter-spacing: .5px; }
|
||||
h1 .g { color: var(--acid); }
|
||||
.tag { color: var(--mut); margin: 0 0 1.6rem; }
|
||||
form { display: flex; gap: 8px; justify-content: center; max-width: 620px; margin: 0 auto; padding: 0 1rem; }
|
||||
form input { flex: 1; padding: 13px 18px; border-radius: 999px; border: 1px solid var(--line2);
|
||||
background: var(--card); color: var(--ink); font-size: 15px; outline: none; }
|
||||
form input:focus { border-color: var(--blue); }
|
||||
form button { padding: 13px 20px; border-radius: 999px; border: none; background: var(--blue); color: #fff; font-size: 15px; cursor: pointer; }
|
||||
.hint { color: var(--dim); font-size: 12.5px; margin-top: .8rem; }
|
||||
|
||||
/* Cards — larger, responsive. Grid autoscales to viewport width:
|
||||
wide screens fit 4-5 across, narrow screens collapse to 2, mobile to 1. */
|
||||
.cards-wrap { max-width: 1200px; width: 100%; margin: 3rem auto 4rem; padding: 0 1.2rem; }
|
||||
.cards-hdr { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; padding: 0 4px; }
|
||||
.cards-hdr .title { font-size: 12px; letter-spacing: .12em; text-transform: uppercase; color: var(--dim); }
|
||||
.cards-hdr .tools button { background: transparent; border: 1px solid var(--line2); color: var(--mut);
|
||||
border-radius: 8px; padding: 6px 12px; font-size: 12.5px; cursor: pointer; margin-left: 6px; }
|
||||
.cards-hdr .tools button:hover { background: var(--card-hi); color: var(--ink); }
|
||||
.cards-hdr .tools button.on { border-color: var(--acid); color: var(--acid); background: rgb(from var(--acid) r g b / .08); }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
|
||||
@media (min-width: 900px) { .grid { gap: 18px; } }
|
||||
@media (min-width: 1200px) { .grid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px; } }
|
||||
@media (max-width: 600px) { .grid { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; } .cards-wrap { padding: 0 .8rem; } }
|
||||
.card { display: block; text-decoration: none; color: inherit; background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 14px; padding: 20px 20px 18px; transition: transform .15s, background .15s, border-color .15s;
|
||||
position: relative; cursor: pointer; }
|
||||
.card:hover { border-color: rgba(75,123,236,.4); background: var(--card-hi); transform: translateY(-2px); }
|
||||
.card .n { font-weight: 600; color: var(--ink); font-size: 15.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* TLD suffix (.x, .bch, ...) rendered in the Silent Mode acid so the brand
|
||||
accent reads instantly across the grid. Subtle glow reinforces it. */
|
||||
.card .n .tld { color: var(--acid); font-weight: 700; letter-spacing: .3px;
|
||||
text-shadow: 0 0 8px rgb(from var(--acid) r g b / .35); }
|
||||
.card .d { color: var(--mut); font-size: 13px; margin-top: 6px; line-height: 1.4; overflow: hidden;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.card:hover .d { color: var(--ink); }
|
||||
.card .row { display: flex; align-items: center; justify-content: space-between; margin-top: 12px; gap: 8px; }
|
||||
.badge { display: inline-block; font-size: 10.5px; padding: 2px 9px; border-radius: 999px; letter-spacing: .02em; }
|
||||
/* Provenance-typed badges (which chain/storage backed the site). */
|
||||
.b-on-chain { background: rgb(from var(--acid) r g b / .14); color: var(--acid); }
|
||||
.b-sia { background: rgba(179,157,219,.14); color: var(--sia); }
|
||||
.b-server { background: rgba(79,209,165,.14); color: var(--srv); }
|
||||
/* Product-role badges — same acid family with per-role hue shifts.
|
||||
Kept muted so the badge is legible but doesn't compete with the title. */
|
||||
.b-infrastructure { background: rgb(from var(--acid) r g b / .10); color: var(--acid); }
|
||||
.b-navigator { background: rgba(75,123,236,.14); color: #7ea3ff; }
|
||||
.b-registrar { background: rgb(from var(--acid) r g b / .10); color: var(--acid); }
|
||||
.b-code-host { background: rgba(179,157,219,.14); color: var(--sia); }
|
||||
.b-app-store { background: rgba(79,209,165,.14); color: var(--srv); }
|
||||
.b-search { background: rgba(255,199,95,.14); color: #ffc75f; }
|
||||
.b-messaging { background: rgba(246,118,138,.14); color: #f6768a; }
|
||||
.b-default { background: rgba(139,152,169,.14); color: var(--mut); }
|
||||
|
||||
/* Edit affordances — hidden until Edit mode toggled */
|
||||
.card .edit-actions { position: absolute; top: 8px; right: 8px; display: none; gap: 4px; }
|
||||
.card .edit-actions button { border: 1px solid var(--line2); background: rgba(0,0,0,.35); color: var(--mut);
|
||||
width: 26px; height: 26px; border-radius: 6px; cursor: pointer; font-size: 13px; padding: 0; }
|
||||
.card .edit-actions button:hover { color: var(--ink); background: rgba(0,0,0,.55); }
|
||||
.card .edit-actions button.rm:hover { color: #f6768a; border-color: rgba(246,118,138,.5); }
|
||||
body.editing .card .edit-actions { display: flex; }
|
||||
body.editing .card { cursor: default; }
|
||||
body.editing .card:hover { transform: none; }
|
||||
.add-card { display: none; align-items: center; justify-content: center;
|
||||
background: transparent; border: 2px dashed var(--line2); border-radius: 14px;
|
||||
color: var(--mut); font-size: 14px; cursor: pointer; padding: 20px; min-height: 100px; }
|
||||
.add-card:hover { border-color: var(--acid); color: var(--acid); background: rgb(from var(--acid) r g b / .04); }
|
||||
body.editing .add-card { display: flex; }
|
||||
|
||||
/* Modal for add/edit */
|
||||
.modal-bg { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 100;
|
||||
align-items: center; justify-content: center; padding: 1rem; }
|
||||
.modal-bg.on { display: flex; }
|
||||
.modal { background: var(--card); border: 1px solid var(--line2); border-radius: 12px;
|
||||
padding: 22px; width: 100%; max-width: 460px; box-shadow: 0 20px 60px #000c; }
|
||||
.modal h2 { margin: 0 0 14px; font-size: 17px; color: var(--ink); }
|
||||
.modal label { display: block; font-size: 12.5px; color: var(--mut); margin: 10px 0 4px; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.modal input, .modal select { width: 100%; padding: 9px 12px; border-radius: 8px; border: 1px solid var(--line2);
|
||||
background: var(--bg2); color: var(--ink); font-size: 13.5px; outline: none; font-family: inherit; }
|
||||
.modal input:focus, .modal select:focus { border-color: var(--blue); }
|
||||
.modal .btns { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
|
||||
.modal button { border-radius: 8px; padding: 8px 16px; font-size: 13.5px; cursor: pointer; border: 1px solid var(--line2);
|
||||
background: transparent; color: var(--mut); }
|
||||
.modal button:hover { background: var(--card-hi); color: var(--ink); }
|
||||
.modal button.primary { background: var(--blue); color: #fff; border-color: var(--blue); }
|
||||
.modal button.primary:hover { background: #5a89f5; }
|
||||
|
||||
footer { color: #47505f; font-size: 12px; padding-bottom: 2rem; text-align: center; }
|
||||
footer b { color: #6b7688; }
|
||||
|
||||
/* Light theme */
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root { --bg1: #dce6f2; --bg2: #eef0f4;
|
||||
--card: #ffffff; --card-hi: #f4f7fb; --line: rgba(0,0,0,.10); --line2: rgba(0,0,0,.14);
|
||||
--ink: #1a1f28; --mut: #4a5262; --dim: #8a93a2;
|
||||
/* Darker acid for light backgrounds — ~5.5:1 on white. */
|
||||
--acid: #0AC18E; }
|
||||
h1 .g { color: var(--acid); }
|
||||
.modal { background: #ffffff; }
|
||||
}
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="hero">
|
||||
<div class="mark">⛓️</div>
|
||||
<h1>Theseus <span class="g">Navigator</span></h1>
|
||||
<p class="tag">A browser that follows the thread. Names that live on the Bitcoin Cash chain.</p>
|
||||
<form id="searchForm">
|
||||
<input id="q" name="q" placeholder="Search the web" autofocus spellcheck="false">
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
<p class="hint">Tip: type a <b>.bch</b> name in the address bar above to open a decentralized site.</p>
|
||||
</div>
|
||||
|
||||
<div class="cards-wrap">
|
||||
<div class="cards-hdr">
|
||||
<span class="title">Quick links</span>
|
||||
<div class="tools">
|
||||
<button id="editBtn" type="button">Edit</button>
|
||||
<button id="resetBtn" type="button" title="Restore the default set of cards" style="display:none">Reset to defaults</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid" id="grid"></div>
|
||||
</div>
|
||||
|
||||
<div class="modal-bg" id="modal">
|
||||
<div class="modal">
|
||||
<h2 id="modalTitle">Add card</h2>
|
||||
<label>Title</label><input id="mTitle" placeholder="e.g. hello.bch">
|
||||
<label>URL</label><input id="mUrl" placeholder="https://example.com/">
|
||||
<label>Subtitle (optional)</label><input id="mSub" placeholder="one-line description">
|
||||
<label>Badge</label>
|
||||
<select id="mBadge">
|
||||
<option value="">— none —</option>
|
||||
<option value="on-chain">on-chain</option>
|
||||
<option value="Sia">Sia</option>
|
||||
<option value="server">server</option>
|
||||
<option value="default">(custom)</option>
|
||||
</select>
|
||||
<div class="btns"><button id="mCancel" type="button">Cancel</button><button id="mSave" class="primary" type="button">Save</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>Names resolved from the <b>Bitcoin Cash</b> blockchain — no registrar, no DNS.<br>Theseus, by Silent Mode — a Deviant project.</footer>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const esc = (s) => String(s || "").replace(/</g, "<");
|
||||
let cards = []; let editIdx = -1;
|
||||
// Search form: submit → navigate the tab via IPC (main.js applies the
|
||||
// user's default search engine + regional hints).
|
||||
$("searchForm").addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const q = $("q").value.trim(); if (!q) return;
|
||||
if (window.home && window.home.navigate) window.home.navigate(q);
|
||||
// Fallback if the preload didn't bind (shouldn't happen). Startpage
|
||||
// over DuckDuckGo since that's Theseus's own default engine now.
|
||||
else window.location.href = "https://www.startpage.com/do/search?q=" + encodeURIComponent(q);
|
||||
});
|
||||
// Populate placeholder with the user's current default engine name so
|
||||
// "Search the web with <engine>" reflects reality. Silently no-ops if
|
||||
// the preload or IPC isn't available (dev-open of home.html in a plain
|
||||
// Chromium tab). Also refreshes on onEngines events would be nice but
|
||||
// main only broadcasts `engines` to chrome — home only queries at load,
|
||||
// which is fine because switching engines is a settings action that
|
||||
// usually implies a reload of the tab shortly after.
|
||||
if (window.home && window.home.getEngines) {
|
||||
window.home.getEngines().then((r) => {
|
||||
const name = r && r.engines && r.engines.find((x) => x.id === r.current)?.name;
|
||||
if (name) $("q").placeholder = "Search the web with " + name;
|
||||
}).catch(() => {});
|
||||
}
|
||||
function badgeClass(b) {
|
||||
// Normalize badge label into a CSS class (spaces + case → dashes / lower).
|
||||
if (!b) return "b-default";
|
||||
return "b-" + String(b).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
}
|
||||
// Split a domain-style title so ".X" (or ".bch") can render in the acid brand
|
||||
// color while the rest stays ink. Preserves capitals in the base label.
|
||||
function styledTitle(t) {
|
||||
const m = String(t).match(/^(.+?)(\.[A-Za-z]{1,10})$/);
|
||||
if (!m) return esc(t);
|
||||
return esc(m[1]) + '<span class="tld">' + esc(m[2]) + '</span>';
|
||||
}
|
||||
function render() {
|
||||
const g = $("grid");
|
||||
g.innerHTML = cards.map((c, i) => {
|
||||
const badge = c.badge ? `<span class="badge ${badgeClass(c.badge)}">${esc(c.badge)}</span>` : "";
|
||||
return `<div class="card" data-i="${i}">
|
||||
<div class="edit-actions">
|
||||
<button class="ed" title="Edit">✎</button>
|
||||
<button class="rm" title="Remove">✕</button>
|
||||
</div>
|
||||
<div class="n">${styledTitle(c.title)}</div>
|
||||
<div class="d">${esc(c.sub || c.url)}</div>
|
||||
<div class="row">${badge}</div>
|
||||
</div>`;
|
||||
}).join("") + `<div class="add-card" id="addBtn">+ Add card</div>`;
|
||||
// Wire clicks: normal-mode = navigate, edit-mode = per-action buttons.
|
||||
g.querySelectorAll(".card").forEach((el) => {
|
||||
const i = Number(el.dataset.i);
|
||||
el.onclick = (e) => {
|
||||
if (document.body.classList.contains("editing")) {
|
||||
if (e.target.classList.contains("rm")) return removeCard(i);
|
||||
if (e.target.classList.contains("ed")) return openModal(i);
|
||||
return; // click on card body in edit mode = no-op
|
||||
}
|
||||
// normal mode: navigate
|
||||
if (window.home && window.home.navigate) window.home.navigate(cards[i].url);
|
||||
else window.location.href = cards[i].url;
|
||||
};
|
||||
});
|
||||
$("addBtn").onclick = () => openModal(-1);
|
||||
}
|
||||
function removeCard(i) {
|
||||
if (!confirm(`Remove "${cards[i].title}" from Quick links?`)) return;
|
||||
cards.splice(i, 1); persist(); render();
|
||||
}
|
||||
function openModal(i) {
|
||||
editIdx = i;
|
||||
const isNew = i < 0;
|
||||
$("modalTitle").textContent = isNew ? "Add card" : "Edit card";
|
||||
const c = isNew ? { title: "", url: "", sub: "", badge: "" } : cards[i];
|
||||
$("mTitle").value = c.title || "";
|
||||
$("mUrl").value = c.url || "";
|
||||
$("mSub").value = c.sub || "";
|
||||
$("mBadge").value = c.badge || "";
|
||||
$("modal").classList.add("on");
|
||||
setTimeout(() => $("mTitle").focus(), 20);
|
||||
}
|
||||
function closeModal() { $("modal").classList.remove("on"); }
|
||||
$("mCancel").onclick = closeModal;
|
||||
$("modal").addEventListener("click", (e) => { if (e.target.id === "modal") closeModal(); });
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape" && $("modal").classList.contains("on")) closeModal(); });
|
||||
$("mSave").onclick = () => {
|
||||
const title = $("mTitle").value.trim();
|
||||
let url = $("mUrl").value.trim();
|
||||
if (!title || !url) { alert("Title and URL are required."); return; }
|
||||
// Normalize URL: allow bare hosts like "example.com" (add https://).
|
||||
if (!/^[a-z]+:\/\//i.test(url) && !/^[a-z]+:/.test(url)) url = "https://" + url;
|
||||
const card = { title, url, sub: $("mSub").value.trim(), badge: $("mBadge").value };
|
||||
if (editIdx < 0) cards.push(card); else cards[editIdx] = card;
|
||||
persist(); render(); closeModal();
|
||||
};
|
||||
$("editBtn").onclick = () => {
|
||||
const editing = document.body.classList.toggle("editing");
|
||||
$("editBtn").classList.toggle("on", editing);
|
||||
$("editBtn").textContent = editing ? "Done" : "Edit";
|
||||
$("resetBtn").style.display = editing ? "" : "none";
|
||||
};
|
||||
$("resetBtn").onclick = async () => {
|
||||
if (!confirm("Restore the default Quick links? Your custom entries will be lost.")) return;
|
||||
if (window.home) await window.home.resetCards();
|
||||
cards = window.home ? (await window.home.getCards()) : [];
|
||||
render();
|
||||
};
|
||||
function persist() { if (window.home && window.home.setCards) window.home.setCards(cards); }
|
||||
// Initial load — via IPC when available, otherwise a hardcoded fallback so
|
||||
// opening home.html directly (e.g. from disk without Electron) still shows
|
||||
// something instead of a blank grid.
|
||||
(async () => {
|
||||
if (window.home && window.home.getCards) cards = await window.home.getCards();
|
||||
if (!cards || !cards.length) cards = [
|
||||
{ title: "hello.bch", url: "https://hello.bch/", sub: "On the blockchain itself.", badge: "on-chain" },
|
||||
{ title: "silentmode.bch", url: "https://silentmode.bch/", sub: "The division behind this stack.", badge: "on-chain" },
|
||||
];
|
||||
render();
|
||||
// If main pushes a refreshed list from the remote URL, adopt it — but
|
||||
// only when the user hasn't customised (main gates this on its side too).
|
||||
if (window.home && window.home.onCards) {
|
||||
window.home.onCards((list) => {
|
||||
if (Array.isArray(list) && list.length) { cards = list; render(); }
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%230b0e14'/%3E%3Cpath d='M32 6 L38 32 L26 32 Z' fill='%23e04e4e'/%3E%3Cpath d='M38 32 L32 58 L26 32 Z' fill='%23d6ff3d'/%3E%3Cpath d='M6 32 L32 27 L58 32 L32 37 Z' fill='%23d6ff3d' opacity='0.55'/%3E%3Ccircle cx='32' cy='32' r='3' fill='%230b0e14'/%3E%3C/svg%3E">
|
||||
<style>
|
||||
:root { color-scheme: light dark;
|
||||
--bg1: #16202e; --bg2: #0b0e14;
|
||||
--card: #141a24; --card-hi: #18202c; --line: #ffffff12; --line2: #ffffff22;
|
||||
--ink: #e7eaf1; --mut: #8b98a9; --dim: #5e6678;
|
||||
--acid: #d6ff3d; --blue: #4b7bec; --sia: #b39ddb; --srv: #4fd1a5; --warm: #ffc75f; --rose: #f6768a; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; height: 100vh; overflow: hidden; font-family: system-ui, sans-serif; color: var(--ink); }
|
||||
body {
|
||||
display: flex; flex-direction: column;
|
||||
background: radial-gradient(1200px 600px at 50% -10%, var(--bg1), var(--bg2));
|
||||
}
|
||||
|
||||
/* Topbar: brand+search+edit. Three-column grid keeps the search centred
|
||||
even when the brand and edit button expand to different widths. */
|
||||
.topbar { position: relative; z-index: 10; flex: none; padding: 24px 28px 12px;
|
||||
display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 20px; }
|
||||
.search { display: flex; justify-content: center; }
|
||||
.search form { display: flex; gap: 8px; width: 100%; max-width: 560px; }
|
||||
.search input { flex: 1; padding: 12px 20px; border-radius: 999px; border: 1px solid var(--line2);
|
||||
background: rgba(20,26,36,.72); color: var(--ink); font-size: 15px; outline: none;
|
||||
backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); }
|
||||
.search input:focus { border-color: var(--blue); }
|
||||
.search button { padding: 12px 22px; border-radius: 999px; border: none; background: var(--blue); color: #fff; font-size: 15px; cursor: pointer; }
|
||||
.brandbar { display: flex; align-items: center; gap: 12px; justify-content: flex-end; }
|
||||
.brandbar .brand { display: inline-flex; align-items: center; gap: 10px; color: var(--ink); font-size: 15px; white-space: nowrap; }
|
||||
.brandbar .brand .mark { width: 24px; height: 24px; display: inline-block; }
|
||||
.brandbar .brand .g { color: var(--acid); font-weight: 600; }
|
||||
.brandbar button { background: rgba(20,26,36,.6); border: 1px solid var(--line2); color: var(--mut);
|
||||
border-radius: 8px; padding: 8px 14px; font-size: 12.5px; cursor: pointer; backdrop-filter: blur(6px); }
|
||||
.brandbar button:hover { background: var(--card-hi); color: var(--ink); }
|
||||
.brandbar button.on { border-color: var(--acid); color: var(--acid); background: rgb(from var(--acid) r g b / .08); }
|
||||
|
||||
/* Edit toolbar — floating pill, shown only while editing. */
|
||||
.editbar { position: absolute; top: 76px; left: 50%; transform: translateX(-50%); z-index: 9;
|
||||
display: none; gap: 14px; align-items: center; padding: 10px 16px;
|
||||
background: rgba(20,26,36,.9); border: 1px solid var(--line2); border-radius: 14px;
|
||||
backdrop-filter: blur(10px); box-shadow: 0 10px 30px #0008; max-width: calc(100vw - 48px); flex-wrap: wrap; }
|
||||
body.editing .editbar { display: flex; }
|
||||
.editbar .group { display: flex; align-items: center; gap: 8px; }
|
||||
.editbar .label { font-size: 11px; color: var(--mut); letter-spacing: .08em; text-transform: uppercase; }
|
||||
.editbar .sz { display: inline-flex; border: 1px solid var(--line2); border-radius: 999px; overflow: hidden; }
|
||||
.editbar .sz button { background: transparent; border: 0; color: var(--mut); padding: 6px 10px; font-size: 12px; cursor: pointer; }
|
||||
.editbar .sz button.on { background: var(--acid); color: #0b0e14; font-weight: 600; }
|
||||
.editbar .bg { display: inline-flex; gap: 6px; }
|
||||
.editbar .bg button { width: 22px; height: 22px; border-radius: 6px; border: 1px solid var(--line2); cursor: pointer; padding: 0; }
|
||||
.editbar .bg button.on { outline: 2px solid var(--acid); outline-offset: 1px; }
|
||||
.editbar input[type=color] { width: 30px; height: 22px; border: 1px solid var(--line2); border-radius: 6px; background: transparent; padding: 0; cursor: pointer; }
|
||||
.editbar .filebtn { font-size: 11.5px; color: var(--mut); border: 1px dashed var(--line2); border-radius: 6px; padding: 4px 10px; cursor: pointer; }
|
||||
.editbar .filebtn:hover { color: var(--acid); border-color: var(--acid); }
|
||||
.editbar .add { background: var(--acid); color: #0b0e14; font-weight: 600; border: none; border-radius: 8px; padding: 6px 12px; font-size: 12.5px; cursor: pointer; }
|
||||
.editbar .reset { background: transparent; border: 1px solid var(--line2); color: var(--mut); border-radius: 8px; padding: 6px 12px; font-size: 12px; cursor: pointer; }
|
||||
.editbar .reset:hover { color: var(--rose); border-color: var(--rose); }
|
||||
|
||||
/* Two-column canvas: Your links (left) + Silent Mode family (right).
|
||||
Column widths roughly match the icon counts (right carries the big
|
||||
curated set). Collapses to one column on narrow windows. Dragging
|
||||
an icon highlights the column under the cursor as a drop target. */
|
||||
.desktop { position: relative; flex: 1; overflow: auto; padding: 24px 32px 32px;
|
||||
display: grid; grid-template-columns: minmax(240px, 2fr) minmax(300px, 3fr);
|
||||
gap: 36px; align-items: start; }
|
||||
.column { min-width: 0; }
|
||||
.section-hdr { display: flex; align-items: baseline; gap: 10px; margin: 4px 4px 14px;
|
||||
font-size: 11px; color: var(--mut); letter-spacing: .14em; text-transform: uppercase; font-weight: 600; }
|
||||
.section-hdr .acid { color: var(--acid); }
|
||||
.section-hdr .count { color: var(--dim); font-weight: 400; letter-spacing: .08em; font-size: 10.5px; }
|
||||
.section-hdr .rule { flex: 1; height: 1px; background: linear-gradient(to right, var(--line2), transparent); }
|
||||
.section-grid { display: flex; flex-wrap: wrap; gap: 20px 24px; padding: 10px 4px; align-content: flex-start;
|
||||
min-height: 140px; border-radius: 14px; }
|
||||
body.dragging-any .section-grid { outline: 1px dashed var(--line2); outline-offset: 4px; }
|
||||
body.dragging-any .section-grid.drop-target { outline-color: var(--acid); background: rgb(from var(--acid) r g b / .04); }
|
||||
@media (max-width: 820px) { .desktop { grid-template-columns: 1fr; gap: 20px; } }
|
||||
|
||||
/* Icon tile. Brand marks render as inline SVG or an <img src>; user-added
|
||||
entries fall back to a colored tile with the first letter. */
|
||||
.icon { display: flex; flex-direction: column; align-items: center; position: relative;
|
||||
cursor: pointer; user-select: none; text-decoration: none; color: inherit;
|
||||
transition: transform .12s ease; }
|
||||
.icon:hover { transform: translateY(-2px); }
|
||||
.icon .tile { display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 18px; background: var(--iconBg, #0b0e14);
|
||||
color: var(--iconFg, #e7eaf1); font-weight: 700; letter-spacing: -.02em;
|
||||
box-shadow: 0 6px 18px #0007, inset 0 0 0 1px rgba(255,255,255,.08);
|
||||
overflow: hidden; }
|
||||
.icon .tile svg, .icon .tile img { width: 72%; height: 72%; display: block; }
|
||||
.icon .label { margin-top: 7px; font-size: 12.5px; line-height: 1.2; color: #fff;
|
||||
text-shadow: 0 1px 3px #000c, 0 0 10px #000a; max-width: 100%;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
padding: 2px 6px; border-radius: 4px; }
|
||||
body.light .icon .label { color: #0b0e14; text-shadow: 0 1px 2px #fffb, 0 0 10px #fffc; }
|
||||
.icon.size-small .tile { width: 56px; height: 56px; font-size: 22px; border-radius: 14px; }
|
||||
.icon.size-medium .tile { width: 80px; height: 80px; font-size: 30px; border-radius: 18px; }
|
||||
.icon.size-large .tile { width: 104px; height: 104px; font-size: 40px; border-radius: 22px; }
|
||||
.icon.size-xl .tile { width: 136px; height: 136px; font-size: 52px; border-radius: 28px; }
|
||||
.icon .label { max-width: 120px; }
|
||||
.icon.size-small .label { max-width: 80px; font-size: 11px; margin-top: 5px; }
|
||||
.icon.size-large .label { max-width: 140px; font-size: 13px; }
|
||||
.icon.size-xl .label { max-width: 160px; font-size: 14px; }
|
||||
|
||||
.icon .ctrls { position: absolute; top: -8px; right: -8px; display: none; gap: 2px; z-index: 2; }
|
||||
.icon .ctrls button { width: 22px; height: 22px; border-radius: 50%; border: 1px solid var(--line2);
|
||||
background: rgba(0,0,0,.65); color: #fff; font-size: 11px; cursor: pointer; padding: 0; line-height: 1; }
|
||||
.icon .ctrls button.rm:hover { background: var(--rose); border-color: var(--rose); }
|
||||
.icon .ctrls button.ed:hover { background: var(--blue); border-color: var(--blue); }
|
||||
body.editing .icon .ctrls { display: flex; }
|
||||
body.editing .icon { cursor: grab; }
|
||||
body.editing .icon.dragging { opacity: .85; cursor: grabbing; }
|
||||
|
||||
/* "+" slot inside each column — only in edit mode */
|
||||
.add-slot { display: none; align-items: center; justify-content: center;
|
||||
border: 2px dashed var(--line2); border-radius: 18px;
|
||||
color: var(--mut); font-size: 13.5px; cursor: pointer; }
|
||||
.column.left .add-slot { width: 80px; height: 80px; border-radius: 16px; font-size: 12px; }
|
||||
.column.right .add-slot { width: 104px; height: 104px; }
|
||||
.add-slot:hover { border-color: var(--acid); color: var(--acid); background: rgb(from var(--acid) r g b / .04); }
|
||||
body.editing .add-slot { display: inline-flex; }
|
||||
|
||||
/* Add/Edit modal */
|
||||
.modal-bg { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 100;
|
||||
align-items: center; justify-content: center; padding: 1rem; backdrop-filter: blur(4px); }
|
||||
.modal-bg.on { display: flex; }
|
||||
.modal { background: var(--card); border: 1px solid var(--line2); border-radius: 14px;
|
||||
padding: 22px; width: 100%; max-width: 460px; box-shadow: 0 20px 60px #000c; }
|
||||
.modal h2 { margin: 0 0 14px; font-size: 17px; color: var(--ink); }
|
||||
.modal label { display: block; font-size: 12.5px; color: var(--mut); margin: 10px 0 4px; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.modal input, .modal select { width: 100%; padding: 9px 12px; border-radius: 8px; border: 1px solid var(--line2);
|
||||
background: var(--bg2); color: var(--ink); font-size: 13.5px; outline: none; font-family: inherit; }
|
||||
.modal input:focus, .modal select:focus { border-color: var(--blue); }
|
||||
.modal .row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.modal .btns { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
|
||||
.modal button { border-radius: 8px; padding: 8px 16px; font-size: 13.5px; cursor: pointer; border: 1px solid var(--line2);
|
||||
background: transparent; color: var(--mut); }
|
||||
.modal button:hover { background: var(--card-hi); color: var(--ink); }
|
||||
.modal button.primary { background: var(--blue); color: #fff; border-color: var(--blue); }
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root { --bg1: #dce6f2; --bg2: #eef0f4;
|
||||
--card: #ffffff; --card-hi: #f4f7fb; --line: rgba(0,0,0,.10); --line2: rgba(0,0,0,.14);
|
||||
--ink: #1a1f28; --mut: #4a5262; --dim: #8a93a2;
|
||||
--acid: #0AC18E; }
|
||||
.icon .label { color: #0b0e14; text-shadow: 0 1px 2px #fffb, 0 0 10px #fffc; }
|
||||
.modal { background: #ffffff; }
|
||||
}
|
||||
</style></head>
|
||||
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<div></div>
|
||||
<div class="search">
|
||||
<form id="searchForm">
|
||||
<input id="q" placeholder="Search the web" autofocus spellcheck="false">
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="brandbar">
|
||||
<div class="brand">
|
||||
<span class="mark">
|
||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="64" height="64" rx="12" fill="#0b0e14"/>
|
||||
<path d="M32 6 L38 32 L26 32 Z" fill="#e04e4e"/>
|
||||
<path d="M38 32 L32 58 L26 32 Z" fill="#d6ff3d"/>
|
||||
<path d="M6 32 L32 27 L58 32 L32 37 Z" fill="#d6ff3d" opacity="0.55"/>
|
||||
<circle cx="32" cy="32" r="3" fill="#0b0e14"/>
|
||||
</svg>
|
||||
</span>
|
||||
Theseus <span class="g">Navigator</span>
|
||||
</div>
|
||||
<button id="editBtn" type="button">Edit desktop</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editbar">
|
||||
<div class="group">
|
||||
<span class="label">Family size</span>
|
||||
<div class="sz" data-which="iconSize">
|
||||
<button data-size="small">S</button>
|
||||
<button data-size="medium">M</button>
|
||||
<button data-size="large" class="on">L</button>
|
||||
<button data-size="xl">XL</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="group">
|
||||
<span class="label">Your-links size</span>
|
||||
<div class="sz" data-which="quickSize">
|
||||
<button data-size="small">S</button>
|
||||
<button data-size="medium" class="on">M</button>
|
||||
<button data-size="large">L</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="group">
|
||||
<span class="label">Background</span>
|
||||
<div class="bg">
|
||||
<button data-bg="dark" class="on" style="background:#0b0e14" title="Dark"></button>
|
||||
<button data-bg="indigo" style="background:linear-gradient(135deg,#1e2a4a,#0b0e14)" title="Indigo"></button>
|
||||
<button data-bg="forest" style="background:linear-gradient(135deg,#132821,#0b140e)" title="Forest"></button>
|
||||
<button data-bg="rose" style="background:linear-gradient(135deg,#3a1830,#140b14)" title="Rose"></button>
|
||||
<button data-bg="warm" style="background:linear-gradient(135deg,#3a2a18,#140e0b)" title="Warm"></button>
|
||||
<button data-bg="light" style="background:linear-gradient(135deg,#dce6f2,#eef0f4)" title="Light"></button>
|
||||
</div>
|
||||
<input id="bgColor" type="color" value="#0b0e14" title="Custom color">
|
||||
<label class="filebtn">Upload image<input id="bgImage" type="file" accept="image/*" style="display:none"></label>
|
||||
</div>
|
||||
<div class="group">
|
||||
<button class="add" id="addBtn">+ Add link</button>
|
||||
<button class="reset" id="resetBtn" title="Restore the default two-column layout">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="desktop" id="desktop">
|
||||
<div class="column left">
|
||||
<div class="section-hdr">
|
||||
<span>Your links</span>
|
||||
<span class="count" id="qCount"></span>
|
||||
<span class="rule"></span>
|
||||
</div>
|
||||
<div class="section-grid" id="qGrid" data-sec="q"></div>
|
||||
</div>
|
||||
<div class="column right">
|
||||
<div class="section-hdr">
|
||||
<span class="acid">Silent Mode</span>
|
||||
<span class="count" id="smCount"></span>
|
||||
<span class="rule"></span>
|
||||
</div>
|
||||
<div class="section-grid" id="smGrid" data-sec="sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-bg" id="modal">
|
||||
<div class="modal">
|
||||
<h2 id="modalTitle">Add link</h2>
|
||||
<label>Title</label><input id="mTitle" placeholder="e.g. hello.bch">
|
||||
<label>URL</label><input id="mUrl" placeholder="https://example.com/">
|
||||
<div class="row">
|
||||
<div>
|
||||
<label>Column</label>
|
||||
<select id="mSection">
|
||||
<option value="q">Your links (left)</option>
|
||||
<option value="sm">Silent Mode (right)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Size</label>
|
||||
<select id="mSize">
|
||||
<option value="">(column default)</option>
|
||||
<option value="small">Small</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="large">Large</option>
|
||||
<option value="xl">Extra large</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<label>Icon color (used when no brand mark)</label>
|
||||
<input id="mColor" type="color" value="#d6ff3d" style="width:60px;height:30px;padding:0;cursor:pointer">
|
||||
<label>Letter / emoji (optional — default = first letter)</label>
|
||||
<input id="mLetter" placeholder="e.g. H, 🌿, ⛓️" maxlength="4">
|
||||
<div class="btns"><button id="mCancel" type="button">Cancel</button><button id="mSave" class="primary" type="button">Save</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const esc = (s) => String(s || "").replace(/</g, "<").replace(/"/g, """);
|
||||
|
||||
// ---- Brand marks ----
|
||||
// Keyed by icon.brand slug; value is an inline SVG string or an
|
||||
// { src: "<relative-url>" } descriptor pointing at a bundled file.
|
||||
// Entries without a brand fall back to a colored tile with a letter.
|
||||
const BRAND = {
|
||||
theseus: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><path d="M32 6 L38 32 L26 32 Z" fill="#e04e4e"/><path d="M38 32 L32 58 L26 32 Z" fill="#d6ff3d"/><path d="M6 32 L32 27 L58 32 L32 37 Z" fill="#d6ff3d" opacity=".55"/><circle cx="32" cy="32" r="3" fill="#0b0e14"/></svg>`,
|
||||
silentmode: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><rect x="10" y="22" width="26" height="20" rx="10" fill="none" stroke="#8b98a9" stroke-width="4"/><rect x="28" y="22" width="26" height="20" rx="10" fill="none" stroke="#d6ff3d" stroke-width="4"/></svg>`,
|
||||
sirius: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><path d="M32 4 L36.4 27.6 L60 32 L36.4 36.4 L32 60 L27.6 36.4 L4 32 L27.6 27.6 Z" fill="#d6ff3d"/></svg>`,
|
||||
hephaestus: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><path d="M14 36 h36 l-4 6 h-28 Z" fill="#ffc75f"/><rect x="26" y="42" width="12" height="8" fill="#ffc75f"/><rect x="22" y="50" width="20" height="4" rx="1" fill="#ffc75f"/><rect x="34" y="12" width="4" height="18" rx="1" fill="#8b98a9" transform="rotate(25 36 21)"/><rect x="30" y="12" width="14" height="6" rx="1" fill="#e7eaf1" transform="rotate(25 37 15)"/></svg>`,
|
||||
prometheus: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><path d="M32 8 C 26 18, 20 22, 20 32 C 20 42, 26 50, 32 50 C 38 50, 44 42, 44 32 C 44 24, 40 22, 36 18 C 36 24, 34 26, 32 28 C 30 24, 34 16, 32 8 Z" fill="#ffc75f"/><path d="M32 20 C 28 26, 26 30, 26 36 C 26 42, 29 46, 32 46 C 35 46, 38 42, 38 36 C 38 32, 36 30, 34 26 C 34 30, 33 32, 32 32 C 30 28, 33 24, 32 20 Z" fill="#f6768a"/></svg>`,
|
||||
hermes: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><path d="M4 28 L32 20 L60 28 L58 32 L32 26 L6 32 Z" fill="#e7eaf1" opacity=".65"/><rect x="14" y="26" width="36" height="22" rx="3" fill="none" stroke="#f6768a" stroke-width="3"/><path d="M14 30 L32 42 L50 30" fill="none" stroke="#f6768a" stroke-width="3"/></svg>`,
|
||||
helios: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><circle cx="32" cy="32" r="12" fill="#ffc75f"/><g stroke="#ffc75f" stroke-width="3" stroke-linecap="round"><line x1="32" y1="8" x2="32" y2="14"/><line x1="32" y1="50" x2="32" y2="56"/><line x1="8" y1="32" x2="14" y2="32"/><line x1="50" y1="32" x2="56" y2="32"/><line x1="15" y1="15" x2="19" y2="19"/><line x1="45" y1="45" x2="49" y2="49"/><line x1="15" y1="49" x2="19" y2="45"/><line x1="45" y1="19" x2="49" y2="15"/></g></svg>`,
|
||||
// Ariadne — real brand mark in ancient-Greek-pottery palette (terracotta
|
||||
// rings around the cream figure, derived from site-ariadne-x/assets/favicon.svg).
|
||||
// Bundled under home-brand/ariadne-pottery.svg and referenced relatively.
|
||||
ariadne: { src: "home-brand/ariadne-pottery.svg" },
|
||||
pithos: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><path d="M22 18h20v4c0 2-2 3-2 5 8 3 14 10 14 20 0 10-9 17-22 17S10 57 10 47c0-10 6-17 14-20 0-2-2-3-2-5z" fill="#3ddc97"/><path d="M14 36h36M14 44h36" stroke="#0b1f18" stroke-width="2.5" stroke-linecap="round" opacity=".55"/></svg>`,
|
||||
// Aegis — straight from bundled-addons/aegis/addon.json's data: URI,
|
||||
// decoded as inline SVG so the home tile matches the sidebar icon.
|
||||
aegis: `<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"><polygon points="16,2 28,9 28,23 16,30 4,23 4,9" fill="#0a0a0d" stroke="#D6FF3D" stroke-width="1.6" stroke-linejoin="round"/><circle cx="16" cy="16" r="4.5" fill="none" stroke="#D6FF3D" stroke-width="1.4"/><circle cx="16" cy="16" r="1.6" fill="#D6FF3D"/></svg>`,
|
||||
bergamot: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><path d="M14 50 C 14 28, 30 14, 54 14 C 54 36, 40 50, 18 50 C 20 42, 30 32, 44 28" fill="#9fd98f" stroke="#4fa66b" stroke-width="2" stroke-linejoin="round"/></svg>`,
|
||||
hellobch: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><text x="32" y="38" font-family="system-ui" font-weight="800" font-size="18" text-anchor="middle" fill="#8b98a9">hello</text><text x="32" y="54" font-family="system-ui" font-weight="800" font-size="13" text-anchor="middle" fill="#d6ff3d">.bch</text></svg>`,
|
||||
siacoin: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><circle cx="32" cy="32" r="18" fill="#3ddc97"/><text x="32" y="40" font-family="system-ui" font-weight="800" font-size="22" text-anchor="middle" fill="#0b0e14">S</text></svg>`,
|
||||
};
|
||||
|
||||
// ---- state ----
|
||||
// v3 schema: { version:3, iconSize, quickSize, background:{kind,value|dataUrl},
|
||||
// sections: { q:[icon,...], sm:[icon,...] } }
|
||||
// Icons: { title, url, brand?, color?, letter?, size? }
|
||||
// `brand` keys into BRAND for an inline SVG or {src} descriptor
|
||||
// `size` overrides the column's default (small/medium/large/xl)
|
||||
// `color` + `letter` are only used when brand is absent
|
||||
// The whole object is stored under userData/home-cards.json via main's
|
||||
// home-cards-get / home-cards-set IPC. If the stored file is a v1 bare
|
||||
// array (older schema), main migrates to v3 on first read.
|
||||
let state = null;
|
||||
|
||||
function defaultState() {
|
||||
return {
|
||||
version: 3,
|
||||
iconSize: "large",
|
||||
quickSize: "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" },
|
||||
],
|
||||
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" },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
function ensureShape(s) {
|
||||
// Keep whatever the user has, but guarantee the keys the render loop
|
||||
// reads — tolerate a half-migrated or partial object without crashing.
|
||||
if (!s || typeof s !== "object") return defaultState();
|
||||
if (!s.sections || typeof s.sections !== "object") return defaultState();
|
||||
if (!Array.isArray(s.sections.sm)) s.sections.sm = [];
|
||||
if (!Array.isArray(s.sections.q)) s.sections.q = [];
|
||||
if (!s.background) s.background = { kind: "preset", value: "dark" };
|
||||
if (!s.iconSize) s.iconSize = "large";
|
||||
if (!s.quickSize) s.quickSize = "medium";
|
||||
s.version = 3;
|
||||
return s;
|
||||
}
|
||||
function persist() { if (window.home && window.home.setCards) window.home.setCards(state); }
|
||||
|
||||
// ---- background ----
|
||||
const BG_PRESETS = {
|
||||
dark: "radial-gradient(1200px 600px at 50% -10%, #16202e, #0b0e14)",
|
||||
indigo: "linear-gradient(135deg,#1e2a4a 0%, #0b0e14 100%)",
|
||||
forest: "linear-gradient(135deg,#132821 0%, #0b140e 100%)",
|
||||
rose: "linear-gradient(135deg,#3a1830 0%, #140b14 100%)",
|
||||
warm: "linear-gradient(135deg,#3a2a18 0%, #140e0b 100%)",
|
||||
light: "linear-gradient(135deg,#dce6f2 0%, #eef0f4 100%)",
|
||||
};
|
||||
function applyBg() {
|
||||
const b = state.background || { kind: "preset", value: "dark" };
|
||||
if (b.kind === "preset") document.body.style.background = BG_PRESETS[b.value] || BG_PRESETS.dark;
|
||||
else if (b.kind === "color") document.body.style.background = b.value;
|
||||
else if (b.kind === "image" && b.dataUrl) document.body.style.background = `#0b0e14 center/cover no-repeat url("${b.dataUrl}")`;
|
||||
const isLight = (b.kind === "preset" && b.value === "light") || (b.kind === "color" && isLightColor(b.value));
|
||||
document.body.classList.toggle("light", isLight);
|
||||
document.querySelectorAll(".editbar .bg button").forEach((btn) => {
|
||||
btn.classList.toggle("on", b.kind === "preset" && btn.dataset.bg === b.value);
|
||||
});
|
||||
$("bgColor").value = (b.kind === "color" ? b.value : "#0b0e14");
|
||||
}
|
||||
function isLightColor(hex) {
|
||||
const h = String(hex || "").replace(/^#/, "");
|
||||
if (h.length !== 6) return false;
|
||||
const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
|
||||
return (r * 0.299 + g * 0.587 + b * 0.114) > 170;
|
||||
}
|
||||
|
||||
// ---- icon render ----
|
||||
function iconLetter(icon) {
|
||||
if (icon.letter) return icon.letter;
|
||||
const t = (icon.title || "").trim();
|
||||
return (t.match(/^[A-Za-z0-9]/) || ["?"])[0].toUpperCase();
|
||||
}
|
||||
function iconArt(icon) {
|
||||
if (icon.brand && BRAND[icon.brand]) {
|
||||
const b = BRAND[icon.brand];
|
||||
if (typeof b === "string") return b;
|
||||
if (b && b.src) return `<img src="${esc(b.src)}" alt="" draggable="false" style="width:100%;height:100%;object-fit:contain">`;
|
||||
}
|
||||
return esc(iconLetter(icon));
|
||||
}
|
||||
function iconUsesBrand(icon) { return !!(icon.brand && BRAND[icon.brand]); }
|
||||
|
||||
function render() {
|
||||
for (const sec of ["sm", "q"]) {
|
||||
const grid = $(sec === "sm" ? "smGrid" : "qGrid");
|
||||
const defaultSize = sec === "sm" ? state.iconSize : state.quickSize;
|
||||
const list = state.sections[sec] || [];
|
||||
$(sec === "sm" ? "smCount" : "qCount").textContent = `· ${list.length}`;
|
||||
grid.innerHTML = "";
|
||||
list.forEach((ic, i) => {
|
||||
const size = ic.size || defaultSize;
|
||||
const el = document.createElement("a");
|
||||
el.className = `icon size-${size}`;
|
||||
el.href = ic.url;
|
||||
el.dataset.sec = sec; el.dataset.i = i;
|
||||
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 = `
|
||||
<div class="ctrls">
|
||||
<button class="ed" title="Edit">✎</button>
|
||||
<button class="rm" title="Remove">×</button>
|
||||
</div>
|
||||
<div class="tile">${iconArt(ic)}</div>
|
||||
<div class="label">${esc(ic.title || ic.url)}</div>`;
|
||||
el.addEventListener("click", (e) => {
|
||||
if (document.body.classList.contains("editing")) {
|
||||
e.preventDefault();
|
||||
if (e.target.classList.contains("rm")) return removeIcon(sec, i);
|
||||
if (e.target.classList.contains("ed")) return openModal(sec, i);
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
if (window.home && window.home.navigate) window.home.navigate(ic.url);
|
||||
else window.location.href = ic.url;
|
||||
});
|
||||
el.addEventListener("pointerdown", (e) => startDrag(e, sec, i, el));
|
||||
grid.appendChild(el);
|
||||
});
|
||||
const add = document.createElement("button");
|
||||
add.className = "add-slot";
|
||||
add.textContent = "+";
|
||||
add.title = sec === "q" ? "Add to Your links" : "Add to Silent Mode";
|
||||
add.onclick = () => openModal(sec, -1);
|
||||
grid.appendChild(add);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- drag + drop (within and across columns) ----
|
||||
let drag = null;
|
||||
function startDrag(e, sec, i, el) {
|
||||
if (!document.body.classList.contains("editing")) return;
|
||||
if (e.target.closest(".ctrls")) return;
|
||||
e.preventDefault();
|
||||
drag = { sec, i, el };
|
||||
el.classList.add("dragging");
|
||||
document.body.classList.add("dragging-any");
|
||||
el.setPointerCapture(e.pointerId);
|
||||
}
|
||||
window.addEventListener("pointermove", (e) => {
|
||||
if (!drag) return;
|
||||
const target = document.elementFromPoint(e.clientX, e.clientY);
|
||||
const over = target && target.closest(".section-grid");
|
||||
document.querySelectorAll(".section-grid.drop-target").forEach((g) => { if (g !== over) g.classList.remove("drop-target"); });
|
||||
if (over) over.classList.add("drop-target");
|
||||
});
|
||||
window.addEventListener("pointerup", (e) => {
|
||||
if (!drag) return;
|
||||
const el = drag.el;
|
||||
const target = document.elementFromPoint(e.clientX, e.clientY);
|
||||
const targetGrid = target && target.closest(".section-grid");
|
||||
const targetSec = targetGrid && targetGrid.dataset ? targetGrid.dataset.sec : null;
|
||||
if (targetSec && (targetSec === "sm" || targetSec === "q")) {
|
||||
const siblings = [...targetGrid.querySelectorAll(".icon")];
|
||||
let insertAt = siblings.length;
|
||||
for (let k = 0; k < siblings.length; k++) {
|
||||
const r = siblings[k].getBoundingClientRect();
|
||||
if (e.clientX < r.left + r.width / 2 && e.clientY < r.bottom) { insertAt = k; break; }
|
||||
}
|
||||
const [moved] = state.sections[drag.sec].splice(drag.i, 1);
|
||||
if (targetSec === drag.sec && insertAt > drag.i) insertAt--;
|
||||
state.sections[targetSec].splice(insertAt, 0, moved);
|
||||
persist(); render();
|
||||
}
|
||||
document.querySelectorAll(".section-grid.drop-target").forEach((g) => g.classList.remove("drop-target"));
|
||||
document.body.classList.remove("dragging-any");
|
||||
el.classList.remove("dragging");
|
||||
drag = null;
|
||||
});
|
||||
|
||||
// ---- add / edit / delete ----
|
||||
let editKey = null;
|
||||
function openModal(sec, i) {
|
||||
editKey = { sec, i };
|
||||
const isNew = i < 0;
|
||||
$("modalTitle").textContent = isNew ? "Add link" : "Edit link";
|
||||
const c = isNew ? { title: "", url: "", color: "#d6ff3d", letter: "", size: "" }
|
||||
: state.sections[sec][i];
|
||||
$("mTitle").value = c.title || "";
|
||||
$("mUrl").value = c.url || "";
|
||||
$("mSection").value = sec;
|
||||
$("mSize").value = c.size || "";
|
||||
$("mColor").value = c.color || "#d6ff3d";
|
||||
$("mLetter").value = c.letter || "";
|
||||
$("modal").classList.add("on");
|
||||
setTimeout(() => $("mTitle").focus(), 20);
|
||||
}
|
||||
function closeModal() { $("modal").classList.remove("on"); }
|
||||
function removeIcon(sec, i) {
|
||||
if (!confirm(`Remove "${state.sections[sec][i].title}" from the desktop?`)) return;
|
||||
state.sections[sec].splice(i, 1); persist(); render();
|
||||
}
|
||||
$("mCancel").onclick = closeModal;
|
||||
$("modal").addEventListener("click", (e) => { if (e.target.id === "modal") closeModal(); });
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape" && $("modal").classList.contains("on")) closeModal(); });
|
||||
$("mSave").onclick = () => {
|
||||
const title = $("mTitle").value.trim();
|
||||
let url = $("mUrl").value.trim();
|
||||
if (!title || !url) { alert("Title and URL are required."); return; }
|
||||
if (!/^[a-z]+:\/\//i.test(url) && !/^[a-z]+:/.test(url)) url = "https://" + url;
|
||||
const next = { title, url, color: $("mColor").value, letter: $("mLetter").value.trim() || null, size: $("mSize").value || null };
|
||||
const toSec = $("mSection").value;
|
||||
if (!editKey || editKey.i < 0) {
|
||||
state.sections[toSec].push(next);
|
||||
} else {
|
||||
const prev = state.sections[editKey.sec][editKey.i];
|
||||
const merged = { ...prev, ...next, brand: prev.brand || null };
|
||||
if (toSec === editKey.sec) state.sections[toSec][editKey.i] = merged;
|
||||
else { state.sections[editKey.sec].splice(editKey.i, 1); state.sections[toSec].push(merged); }
|
||||
}
|
||||
persist(); render(); closeModal();
|
||||
};
|
||||
|
||||
// ---- edit toggle + toolbar ----
|
||||
$("editBtn").onclick = () => {
|
||||
const editing = document.body.classList.toggle("editing");
|
||||
$("editBtn").classList.toggle("on", editing);
|
||||
$("editBtn").textContent = editing ? "Done" : "Edit desktop";
|
||||
};
|
||||
$("addBtn").onclick = () => openModal("q", -1);
|
||||
$("resetBtn").onclick = async () => {
|
||||
if (!confirm("Restore the default desktop? Your added links will be removed.")) return;
|
||||
if (window.home && window.home.resetCards) await window.home.resetCards();
|
||||
state = defaultState();
|
||||
applyBg(); applySizeButtons(); render();
|
||||
persist();
|
||||
};
|
||||
document.querySelectorAll(".editbar .sz").forEach((group) => {
|
||||
const which = group.dataset.which; // iconSize | quickSize
|
||||
group.querySelectorAll("button").forEach((btn) => {
|
||||
btn.onclick = () => {
|
||||
state[which] = btn.dataset.size;
|
||||
group.querySelectorAll("button").forEach((b) => b.classList.toggle("on", b === btn));
|
||||
persist(); render();
|
||||
};
|
||||
});
|
||||
});
|
||||
document.querySelectorAll(".editbar .bg button[data-bg]").forEach((btn) => {
|
||||
btn.onclick = () => { state.background = { kind: "preset", value: btn.dataset.bg }; applyBg(); persist(); };
|
||||
});
|
||||
$("bgColor").oninput = () => { state.background = { kind: "color", value: $("bgColor").value }; applyBg(); persist(); };
|
||||
$("bgImage").onchange = (e) => {
|
||||
const f = e.target.files && e.target.files[0]; if (!f) return;
|
||||
if (f.size > 2_000_000) { alert("Please pick an image under 2 MB."); return; }
|
||||
const r = new FileReader();
|
||||
r.onload = () => { state.background = { kind: "image", dataUrl: r.result }; applyBg(); persist(); };
|
||||
r.readAsDataURL(f);
|
||||
};
|
||||
|
||||
// ---- search form ----
|
||||
// Submit goes through main (which applies the user's default engine + locale).
|
||||
$("searchForm").addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const q = $("q").value.trim(); if (!q) return;
|
||||
if (window.home && window.home.navigate) window.home.navigate(q);
|
||||
else window.location.href = "https://www.startpage.com/do/search?q=" + encodeURIComponent(q);
|
||||
});
|
||||
// Reflect the current default search engine in the placeholder, so the
|
||||
// input reads "Search the web with <engine>" instead of being generic.
|
||||
if (window.home && window.home.getEngines) {
|
||||
window.home.getEngines().then((r) => {
|
||||
const name = r && r.engines && r.engines.find((x) => x.id === r.current)?.name;
|
||||
if (name) $("q").placeholder = "Search the web with " + name;
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function applySizeButtons() {
|
||||
document.querySelectorAll(".editbar .sz").forEach((group) => {
|
||||
const which = group.dataset.which;
|
||||
group.querySelectorAll("button").forEach((b) => b.classList.toggle("on", b.dataset.size === state[which]));
|
||||
});
|
||||
}
|
||||
|
||||
// ---- initial load ----
|
||||
(async () => {
|
||||
let loaded = null;
|
||||
try { if (window.home && window.home.getCards) loaded = await window.home.getCards(); } catch {}
|
||||
state = ensureShape(loaded) || defaultState();
|
||||
applyBg(); applySizeButtons(); render();
|
||||
// Main may push a fresher state (remote-cache refresh succeeded) via onCards.
|
||||
if (window.home && window.home.onCards) {
|
||||
window.home.onCards((data) => {
|
||||
const next = ensureShape(data);
|
||||
if (next) { state = next; applyBg(); applySizeButtons(); render(); }
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
151
main.js
151
main.js
|
|
@ -832,54 +832,112 @@ function emitUpdateAvailable() {
|
|||
sendAllChrome("update-available", payload);
|
||||
}
|
||||
|
||||
// ---- home page editable cards (userData/home-cards.json) ------------------
|
||||
// Rendered by home.html as the "quick links" grid on the new-tab page. User
|
||||
// can add/edit/remove via the page's edit mode. First-run seed = the classic
|
||||
// Silent Mode showcase (hello.bch, theseus.bch, silentmode.bch, etc.).
|
||||
const DEFAULT_HOME_CARDS = [
|
||||
{ title: "hello.bch", url: "https://hello.bch/", sub: "A small page on the blockchain itself.", badge: "on-chain" },
|
||||
{ title: "siatest.bch", url: "https://siatest.bch/", sub: "A page with no server, backed by Sia.", badge: "Sia" },
|
||||
{ title: "SilentMode.X", url: "https://silentmode.x/", sub: "Infrastructure development for a decentralized web.", badge: "Infrastructure" },
|
||||
{ title: "Theseus.X", url: "https://theseus.x/", sub: "The Web Navigator — this browser's own address.", badge: "Navigator" },
|
||||
{ title: "Sirius.X", url: "https://sirius.x/", sub: "Register and manage BCDN names.", badge: "Registrar" },
|
||||
{ title: "Hephaestus.X", url: "https://hephaestus.x/", sub: "The forge — Silent Mode's code host.", badge: "Code host" },
|
||||
{ title: "Prometheus.X", url: "https://prometheus.x/", sub: "Decentralized App Marketplace.", badge: "App store" },
|
||||
{ title: "Helios.X", url: "https://helios.x/", sub: "Search engine for the decentralized web (in design).", badge: "Search" },
|
||||
{ title: "Hermes.X", url: "https://hermes.x/", sub: "Messaging — end-to-end encrypted over Nostr.", badge: "Messaging" },
|
||||
];
|
||||
// User's local edits win over everything else — that's the whole point of
|
||||
// the edit mode. Remote pull only feeds the "defaults" tier so brand copy
|
||||
// changes reach installs without a browser release.
|
||||
// ---- home page editable state (userData/home-cards.json) ------------------
|
||||
// Rendered by home.html as the two-column desktop on the new-tab page (left
|
||||
// = user's own links, right = Silent Mode X-family). User can add / edit /
|
||||
// remove / drag via the page's edit mode. First-run seed = the curated
|
||||
// defaults below.
|
||||
//
|
||||
// Schema v3 is an object:
|
||||
// { version:3, iconSize, quickSize, background:{...},
|
||||
// sections: { sm:[...], q:[...] } }
|
||||
// A v1 bare array (the pre-0.3.90 schema) is still accepted on read and
|
||||
// migrated into sections.q (user's own links) on first save. The remote URL
|
||||
// still serves an array — see migrateCards() for how that lands into v3.
|
||||
const DEFAULT_HOME_STATE = {
|
||||
version: 3,
|
||||
iconSize: "large",
|
||||
quickSize: "medium",
|
||||
background: { kind: "preset", value: "dark" },
|
||||
sections: {
|
||||
// Right column — the curated Silent Mode X-family lineup.
|
||||
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" },
|
||||
],
|
||||
// Left column — user's own quick links. Seed includes the demos plus
|
||||
// Pithos.sia + Bergamot.X (tools-you-use, not apex names).
|
||||
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" },
|
||||
],
|
||||
},
|
||||
};
|
||||
const homeCardsFile = () => path.join(app.getPath("userData"), "home-cards.json");
|
||||
const homeCardsRemoteCache = () => path.join(app.getPath("userData"), "home-cards-remote.json");
|
||||
// The canonical remote card list is served from silentmode.st (and mirrored
|
||||
// on silentmode.bch via Sia). Editing that file updates every install on
|
||||
// its next launch — no reinstall required.
|
||||
const HOME_CARDS_URL = "https://dl.silentmode.st/home-cards.json";
|
||||
const HOME_CARDS_REFRESH_MS = 6 * 60 * 60 * 1000; // every 6h
|
||||
function loadHomeCards() {
|
||||
// Keep a stable v3-shaped clone of the defaults so a caller mutating what we
|
||||
// hand them (edit-mode runs do that) never pollutes the module-level seed.
|
||||
function cloneDefaults() { return JSON.parse(JSON.stringify(DEFAULT_HOME_STATE)); }
|
||||
// Promote whatever shape we read on disk / off the wire into v3. Accepts:
|
||||
// - a v3 object (returned as-is after shape repair);
|
||||
// - a v1 bare array of {title,url,sub?,badge?} cards (lands in sections.q);
|
||||
// - anything else -> null, and the caller falls back to defaults.
|
||||
function migrateCards(raw) {
|
||||
if (!raw) return null;
|
||||
if (Array.isArray(raw)) {
|
||||
const q = raw.filter((c) => c && typeof c.title === "string" && typeof c.url === "string")
|
||||
.map((c) => ({ title: c.title, url: c.url }));
|
||||
if (!q.length) return null;
|
||||
const next = cloneDefaults();
|
||||
next.sections.q = q; // overlay the user's old picks onto the default SM column
|
||||
return next;
|
||||
}
|
||||
if (typeof raw !== "object") return null;
|
||||
if (!raw.sections || typeof raw.sections !== "object") return null;
|
||||
const s = {
|
||||
version: 3,
|
||||
iconSize: raw.iconSize || "large",
|
||||
quickSize: raw.quickSize || "medium",
|
||||
background: (raw.background && typeof raw.background === "object") ? raw.background : { kind: "preset", value: "dark" },
|
||||
sections: {
|
||||
sm: Array.isArray(raw.sections.sm) ? raw.sections.sm : [],
|
||||
q: Array.isArray(raw.sections.q) ? raw.sections.q : [],
|
||||
},
|
||||
};
|
||||
return s;
|
||||
}
|
||||
function loadHomeState() {
|
||||
// Priority: user's local edits > cached remote copy > code defaults.
|
||||
try {
|
||||
if (fs.existsSync(homeCardsFile())) {
|
||||
const v = JSON.parse(fs.readFileSync(homeCardsFile(), "utf8"));
|
||||
if (Array.isArray(v) && v.length) return v;
|
||||
const m = migrateCards(v);
|
||||
if (m) return m;
|
||||
}
|
||||
} catch (e) { console.error("home cards (user) load failed:", e.message); }
|
||||
} catch (e) { console.error("home state (user) load failed:", e.message); }
|
||||
try {
|
||||
if (fs.existsSync(homeCardsRemoteCache())) {
|
||||
const v = JSON.parse(fs.readFileSync(homeCardsRemoteCache(), "utf8"));
|
||||
if (Array.isArray(v) && v.length) return v;
|
||||
const m = migrateCards(v);
|
||||
if (m) return m;
|
||||
}
|
||||
} catch (e) { console.error("home cards (remote-cache) load failed:", e.message); }
|
||||
return DEFAULT_HOME_CARDS.slice();
|
||||
} catch (e) { console.error("home state (remote-cache) load failed:", e.message); }
|
||||
return cloneDefaults();
|
||||
}
|
||||
function saveHomeCards(cards) {
|
||||
try { fs.writeFileSync(homeCardsFile(), JSON.stringify(cards, null, 2)); }
|
||||
catch (e) { console.error("home cards save failed:", e.message); }
|
||||
function saveHomeState(state) {
|
||||
const m = migrateCards(state); if (!m) return;
|
||||
try { fs.writeFileSync(homeCardsFile(), JSON.stringify(m, null, 2)); }
|
||||
catch (e) { console.error("home state save failed:", e.message); }
|
||||
}
|
||||
// Fetch the canonical home-cards.json into the remote cache. Silent on any
|
||||
// error (no network, 404, bad JSON, etc.) — the cache stays as-is and the
|
||||
// user sees either their last cached set or the built-in defaults.
|
||||
// Legacy aliases — some older callers (and the IPC handler names) still read
|
||||
// as cards. New code should call loadHomeState / saveHomeState directly.
|
||||
function loadHomeCards() { return loadHomeState(); }
|
||||
function saveHomeCards(x) { return saveHomeState(x); }
|
||||
// Fetch the canonical home-cards.json into the remote cache. The remote may
|
||||
// serve either an array (v1) or a v3 object — migrateCards() handles both.
|
||||
// Silent on any error — the cache stays as-is and the user keeps seeing
|
||||
// either their last cached set or the built-in defaults.
|
||||
async function refreshRemoteHomeCards() {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
|
|
@ -887,20 +945,19 @@ async function refreshRemoteHomeCards() {
|
|||
const r = await fetch(HOME_CARDS_URL, { signal: controller.signal, cache: "no-store" });
|
||||
clearTimeout(to);
|
||||
if (!r.ok) return;
|
||||
const list = await r.json();
|
||||
if (!Array.isArray(list) || list.length === 0) return;
|
||||
// Basic sanity: every entry must be an object with a string title + url.
|
||||
const clean = list.filter((c) => c && typeof c.title === "string" && typeof c.url === "string");
|
||||
if (!clean.length) return;
|
||||
fs.writeFileSync(homeCardsRemoteCache(), JSON.stringify(clean, null, 2));
|
||||
const raw = await r.json();
|
||||
const next = migrateCards(raw);
|
||||
if (!next) return;
|
||||
fs.writeFileSync(homeCardsRemoteCache(), JSON.stringify(next, null, 2));
|
||||
// Only push into open home tabs if the user hasn't overridden — their
|
||||
// edits stay put.
|
||||
// edits stay put. The home page's onCards handler re-renders.
|
||||
if (!fs.existsSync(homeCardsFile())) {
|
||||
for (const t of tabs) {
|
||||
try { t.view.webContents.send("home-cards", clean); } catch {}
|
||||
try { t.view.webContents.send("home-cards", next); } catch {}
|
||||
}
|
||||
}
|
||||
console.log(`[home-cards] refreshed from ${HOME_CARDS_URL}: ${clean.length} cards`);
|
||||
const smN = next.sections.sm.length, qN = next.sections.q.length;
|
||||
console.log(`[home-cards] refreshed from ${HOME_CARDS_URL}: ${smN + qN} total (sm=${smN}, q=${qN})`);
|
||||
} catch (e) { /* silent */ }
|
||||
}
|
||||
// Sender validation — only accept IPC from our own app pages. Compared against
|
||||
|
|
@ -7571,8 +7628,14 @@ app.on("will-quit", () => {
|
|||
});
|
||||
// Home page editable cards. Origin-gated to home.html — random pages that
|
||||
// snoop the preload can't act on the local file.
|
||||
ipcMain.handle("home-cards-get", (e) => isHomePageSender(e.sender) ? loadHomeCards() : []);
|
||||
ipcMain.handle("home-cards-set", (e, cards) => { if (!isHomePageSender(e.sender)) return false; if (!Array.isArray(cards)) return false; saveHomeCards(cards); return true; });
|
||||
ipcMain.handle("home-cards-get", (e) => isHomePageSender(e.sender) ? loadHomeState() : null);
|
||||
// Accepts either the v3 state object or the legacy v1 array — migrateCards()
|
||||
// normalises both into v3 before persisting (saveHomeState clones through it).
|
||||
ipcMain.handle("home-cards-set", (e, state) => {
|
||||
if (!isHomePageSender(e.sender)) return false;
|
||||
if (!state || typeof state !== "object") return false;
|
||||
saveHomeState(state); return true;
|
||||
});
|
||||
ipcMain.handle("home-cards-reset", (e) => { if (!isHomePageSender(e.sender)) return false; try { fs.unlinkSync(homeCardsFile()); } catch {} return true; });
|
||||
ipcMain.handle("go-back", () => { const wc = activeTab()?.view.webContents; if (wc?.navigationHistory.canGoBack()) wc.navigationHistory.goBack(); });
|
||||
ipcMain.handle("go-forward", () => { const wc = activeTab()?.view.webContents; if (wc?.navigationHistory.canGoForward()) wc.navigationHistory.goForward(); });
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "theseus-navigator",
|
||||
"version": "0.3.89",
|
||||
"version": "0.3.90",
|
||||
"license": "MPL-2.0",
|
||||
"description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
|
||||
"author": "Silent Mode",
|
||||
|
|
@ -109,6 +109,7 @@
|
|||
"panel-header-preload.js",
|
||||
"panel-grip.html",
|
||||
"engine-icons/**/*",
|
||||
"home-brand/**/*",
|
||||
"lib/**/*",
|
||||
"package.json",
|
||||
"node_modules/**/*",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue