Theseus 0.3.68: quick-links strip opens services in a dedicated side panel

Clicking an icon on the left strip now opens the service inside a dedicated
380-px mini-view (quickPanel) next to the strip, Opera-style, instead of a
new full-sized tab. Click the active icon again to close the panel; click a
different one to switch. If the panel is already pointed at the same host,
we skip the loadURL so scroll position, open chat and login state survive
a close+reopen round-trip.

Icons now paint as real brand SVGs (Messenger, WhatsApp, Telegram, X,
YouTube, Spotify) with their official colours, bundled inside quicklinks.html
so no external favicon fetch leaks the fact that the strip is loaded.
Unknown ids fall back to a letter chip. The strip vertically centres the
icons between two flex spacers to match Opera's layout.

Defaults ship Messenger, WhatsApp, Telegram, X, YouTube and Spotify. The
Settings › General › Quick links section still lists / adds / removes
entries and toggles the strip.
This commit is contained in:
Silent Mode 2026-10-03 00:09:44 +02:00
parent 08ecd093d2
commit 166e220343
4 changed files with 131 additions and 75 deletions

109
main.js
View file

@ -503,9 +503,12 @@ const SETTINGS_DEFAULTS = {
// service shortcuts; click opens the URL in a new tab. Toggle via settings. // service shortcuts; click opens the URL in a new tab. Toggle via settings.
quickLinksShow: true, quickLinksShow: true,
quickLinks: [ quickLinks: [
{ id: "x", url: "https://x.com/", title: "X" }, { id: "messenger", url: "https://www.messenger.com/", title: "Messenger" },
{ id: "telegram", url: "https://web.telegram.org/", title: "Telegram" }, { id: "whatsapp", url: "https://web.whatsapp.com/", title: "WhatsApp" },
{ id: "whatsapp", url: "https://web.whatsapp.com/", title: "WhatsApp" }, { id: "telegram", url: "https://web.telegram.org/k/", title: "Telegram" },
{ id: "x", url: "https://x.com/", title: "X" },
{ id: "youtube", url: "https://www.youtube.com/", title: "YouTube" },
{ id: "spotify", url: "https://open.spotify.com/", title: "Spotify" },
], ],
// Toolbar bar sizes. The URL bar is flex:1 by default so it eats all // Toolbar bar sizes. The URL bar is flex:1 by default so it eats all
// remaining space; `compact`/`medium` cap it so the extension dock has // remaining space; `compact`/`medium` cap it so the extension dock has
@ -1920,8 +1923,11 @@ let linkStatusW = 100, linkStatusH = 22;
// nothing until the user actively opens it, so the perf cost of an unused // nothing until the user actively opens it, so the perf cost of an unused
// add-on is nil. // add-on is nil.
let sidebar, sidebarVisible = false, sidebarActivePanelId = null; let sidebar, sidebarVisible = false, sidebarActivePanelId = null;
let quicklinks; // left-edge quick-links strip (Opera-style) let quicklinks; // left-edge quick-links strip (Opera-style)
const QUICKLINKS_W = 44; // fixed width in px — just big enough for 32-icon tiles const QUICKLINKS_W = 44; // fixed width in px — just big enough for 32-icon tiles
let quickPanel; // the mini dedicated view for the active quick-link
const QUICK_PANEL_W = 380; // Opera's sidebar panel sits around this width
let activeQuickLinkId = null; // id of the quick-link whose panel is open, or null
// Sidebar width is user-adjustable via a drag grip on the panel's left edge. // Sidebar width is user-adjustable via a drag grip on the panel's left edge.
// The value below is the default; settings.sidebarWidth overrides it once // The value below is the default; settings.sidebarWidth overrides it once
// loadSettings() runs and persists any drag adjustment made by the user. // loadSettings() runs and persists any drag adjustment made by the user.
@ -2736,16 +2742,24 @@ function layout() {
quicklinks.setBounds({ x: 0, y: chromeH, width: leftW, height: bodyH }); quicklinks.setBounds({ x: 0, y: chromeH, width: leftW, height: bodyH });
try { quicklinks.setVisible(leftW > 0); } catch {} try { quicklinks.setVisible(leftW > 0); } catch {}
} }
// Quick-link panel: a narrow mini-view just right of the strip, showing the
// active quick-link's web app. Only takes space when something is open.
const panelW = (quickPanel && activeQuickLinkId && !fsTab) ? QUICK_PANEL_W : 0;
if (quickPanel) {
quickPanel.setBounds({ x: leftW, y: chromeH, width: panelW, height: bodyH });
try { quickPanel.setVisible(panelW > 0); } catch {}
}
// Sidebar (when visible) claims a fixed slice on the right; the tab views // Sidebar (when visible) claims a fixed slice on the right; the tab views
// shrink to fit alongside it. When hidden, tabs get the full width. // shrink to fit alongside it. When hidden, tabs get the full width.
const sideW = sidebarVisible && !fsTab ? sidebarW : 0; const sideW = sidebarVisible && !fsTab ? sidebarW : 0;
const tabW = Math.max(0, width - leftW - sideW); const tabX = leftW + panelW;
for (const t of tabs) t.view.setBounds({ x: leftW, y: chromeH, width: tabW, height: bodyH }); const tabW = Math.max(0, width - tabX - sideW);
if (sidebar) sidebar.setBounds({ x: leftW + tabW, y: chromeH, width: sideW, height: bodyH }); for (const t of tabs) t.view.setBounds({ x: tabX, y: chromeH, width: tabW, height: bodyH });
if (sidebar) sidebar.setBounds({ x: tabX + tabW, y: chromeH, width: sideW, height: bodyH });
// Approval overlay sits exactly over the tab area — the page underneath // Approval overlay sits exactly over the tab area — the page underneath
// keeps running; only pointer input is intercepted. // keeps running; only pointer input is intercepted.
if (approvalPop) approvalPop.setBounds({ x: leftW, y: chromeH, width: tabW, height: bodyH }); if (approvalPop) approvalPop.setBounds({ x: tabX, y: chromeH, width: tabW, height: bodyH });
if (jsDialogPop) jsDialogPop.setBounds({ x: leftW, y: chromeH, width: tabW, height: bodyH }); if (jsDialogPop) jsDialogPop.setBounds({ x: tabX, y: chromeH, width: tabW, height: bodyH });
positionPopover(); positionPopover();
positionEnginePicker(); positionEnginePicker();
positionDownloads(); positionDownloads();
@ -3788,12 +3802,23 @@ function createWindow() {
sidebar.setVisible(false); sidebar.setVisible(false);
// Opera-style quick-links strip on the left edge: a thin vertical column // Opera-style quick-links strip on the left edge: a thin vertical column
// with icons for a few web apps (X, Telegram, WhatsApp by default). Clicking // with icons for a few web apps (X, Telegram, WhatsApp by default). Clicking
// one opens its URL in a new tab (or focuses an existing tab for that URL). // one opens that service in a dedicated mini-tab (the quickPanel next door)
// rather than a full-sized tab.
quicklinks = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "quicklinks-preload.js") } }); quicklinks = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "quicklinks-preload.js") } });
try { quicklinks.setBackgroundColor(nativeTheme.shouldUseDarkColors ? "#0e131c" : "#eceff3"); } catch {} try { quicklinks.setBackgroundColor(nativeTheme.shouldUseDarkColors ? "#0e131c" : "#eceff3"); } catch {}
win.contentView.addChildView(quicklinks); win.contentView.addChildView(quicklinks);
styleScrollbars(quicklinks.webContents); styleScrollbars(quicklinks.webContents);
quicklinks.webContents.loadFile("quicklinks.html"); quicklinks.webContents.loadFile("quicklinks.html");
// Dedicated mini-view that renders the currently-active quick-link in its
// own narrow column, Opera-style. Lives permanently in the window; shown /
// hidden via activeQuickLinkId. Separate browsing context from any tab,
// so a Facebook sidebar visit doesn't share cookies with a Facebook tab
// the user opened — matching how Opera's sidebar panels feel.
quickPanel = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "home-preload.js") } });
try { quickPanel.setBackgroundColor(nativeTheme.shouldUseDarkColors ? "#0b0e14" : "#ffffff"); } catch {}
win.contentView.addChildView(quickPanel);
styleScrollbars(quickPanel.webContents);
quickPanel.setVisible(false);
// Add-on approval overlay (approval-modal capability). Transparent view // Add-on approval overlay (approval-modal capability). Transparent view
// over the tab area, loaded once, shown per request. // over the tab area, loaded once, shown per request.
approvalPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "approval-preload.js") } }); approvalPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "approval-preload.js") } });
@ -6282,11 +6307,15 @@ ipcMain.handle("settings-set", (_e, key, val) => {
const wc = t && !t.settings && !t.addonId && t.url ? t.view.webContents : null; const wc = t && !t.settings && !t.addonId && t.url ? t.view.webContents : null;
try { wc && wc.reload(); } catch {} try { wc && wc.reload(); } catch {}
} }
// Quick-links strip: re-layout + push the new list to the strip's view so // Quick-links strip: re-layout + push the new state to the strip's view so
// show/hide and link edits take effect without a relaunch. // show/hide and link edits take effect without a relaunch. If the active
// panel's link was removed, close the panel.
if (key === "quickLinksShow" || key === "quickLinks") { if (key === "quickLinksShow" || key === "quickLinks") {
if (activeQuickLinkId && !(settings.quickLinks || []).some((L) => L.id === activeQuickLinkId)) {
activeQuickLinkId = null;
}
layout(); layout();
try { quicklinks?.webContents.send("quicklinks-update", settings.quickLinks || []); } catch {} emitQuickLinksState();
} }
// Broadcast to every renderer that watches the live settings — the chrome // Broadcast to every renderer that watches the live settings — the chrome
// (toolbar sizes, URL-bar language chip) and every open settings tab (so // (toolbar sizes, URL-bar language chip) and every open settings tab (so
@ -6295,29 +6324,43 @@ ipcMain.handle("settings-set", (_e, key, val) => {
broadcastSettings(); broadcastSettings();
return settings; return settings;
}); });
// Quick-links IPC: read the saved list, open one in a new tab (focus an // Quick-links IPC. getState: list + whichever one is currently open. open: a
// existing tab for that host if present), or prompt for a URL to add. // toggle — click to open the panel on that link, click the active one again
ipcMain.handle("quicklinks-get", () => settings.quickLinks || []); // to close it, click a different one to switch. The strip subscribes via
ipcMain.handle("quicklinks-open", (_e, id) => { // onState so it can highlight the active icon.
function emitQuickLinksState() {
const payload = { links: settings.quickLinks || [], openId: activeQuickLinkId };
try { quicklinks?.webContents.send("quicklinks-state", payload); } catch {}
}
function openQuickPanel(id) {
const L = (settings.quickLinks || []).find((x) => x.id === id); const L = (settings.quickLinks || []).find((x) => x.id === id);
if (!L || !L.url) return false; if (!quickPanel || !L || !L.url) return;
activeQuickLinkId = id;
try { try {
// Focus an existing tab whose current URL is on the same host — avoids const cur = quickPanel.webContents.getURL();
// stacking a new tab every click. // Only re-navigate when the panel isn't already showing this origin —
const u = new URL(L.url); // avoids blowing away the user's state (open chat, scroll position)
const existing = tabs.find((t) => { try { return t.url && new URL(t.url).host === u.host; } catch { return false; } }); // when they click the icon to reopen the panel they just closed.
if (existing) { setActive(existing.id); return true; } const sameHost = (() => { try { return cur && new URL(cur).host === new URL(L.url).host; } catch { return false; } })();
} catch {} if (!sameHost) quickPanel.webContents.loadURL(L.url);
createTab(L.url); } catch (e) { console.warn("[quicklinks] loadURL failed:", e?.message); }
return true; layout();
}); emitQuickLinksState();
ipcMain.handle("quicklinks-add", async () => { }
// Minimal add flow for now: prompt via a native input dialog isn't ideal function closeQuickPanel() {
// in Electron, so route the user to Settings where they can edit the list activeQuickLinkId = null;
// directly. (A proper picker UI can land later.) layout();
openSettingsTab("general"); emitQuickLinksState();
}
ipcMain.handle("quicklinks-get-state", () => ({ links: settings.quickLinks || [], openId: activeQuickLinkId }));
ipcMain.handle("quicklinks-open", (_e, id) => {
if (!id) return false;
if (id === activeQuickLinkId) { closeQuickPanel(); return true; }
openQuickPanel(String(id));
return true; return true;
}); });
ipcMain.handle("quicklinks-close", () => { closeQuickPanel(); return true; });
ipcMain.handle("quicklinks-add", () => { openSettingsTab("general"); return true; });
function broadcastSettings() { function broadcastSettings() {
try { chrome?.webContents.send("settings-update", settings); } catch {} try { chrome?.webContents.send("settings-update", settings); } catch {}
for (const t of tabs) { for (const t of tabs) {

View file

@ -1,6 +1,6 @@
{ {
"name": "theseus-navigator", "name": "theseus-navigator",
"version": "0.3.67", "version": "0.3.68",
"license": "MPL-2.0", "license": "MPL-2.0",
"description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.", "description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
"author": "Silent Mode", "author": "Silent Mode",

View file

@ -1,7 +1,8 @@
const { contextBridge, ipcRenderer } = require("electron"); const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("quicklinks", { contextBridge.exposeInMainWorld("quicklinks", {
getLinks: () => ipcRenderer.invoke("quicklinks-get"), getState: () => ipcRenderer.invoke("quicklinks-get-state"),
open: (id) => ipcRenderer.invoke("quicklinks-open", String(id || "")), open: (id) => ipcRenderer.invoke("quicklinks-open", String(id || "")),
close: () => ipcRenderer.invoke("quicklinks-close"),
add: () => ipcRenderer.invoke("quicklinks-add"), add: () => ipcRenderer.invoke("quicklinks-add"),
onLinks: (cb) => ipcRenderer.on("quicklinks-update", (_e, d) => cb(d)), onState: (cb) => ipcRenderer.on("quicklinks-state", (_e, d) => cb(d)),
}); });

View file

@ -5,78 +5,90 @@
:root { :root {
color-scheme: light dark; color-scheme: light dark;
--bg:#0e131c; --ink:#e7eaf1; --mut:#8b98a9; --line2:rgba(255,255,255,.13); --bg:#0e131c; --ink:#e7eaf1; --mut:#8b98a9; --line2:rgba(255,255,255,.13);
--acid:#d6ff3d; --acid:#d6ff3d; --acid-tint: rgb(from var(--acid) r g b / .14);
} }
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
:root { --bg:#eceff3; --ink:#1a1f28; --mut:#5c6676; --line2:rgba(0,0,0,.10); --acid:#0AC18E; } :root { --bg:#eceff3; --ink:#1a1f28; --mut:#5c6676; --line2:rgba(0,0,0,.10); --acid:#0AC18E; --acid-tint: rgb(from var(--acid) r g b / .14); }
} }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink); html, body { margin: 0; height: 100%; background: var(--bg); color: var(--ink);
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; overflow: hidden; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; overflow: hidden;
user-select: none; } user-select: none; }
/* Icons sit in a vertically-centered column: the two spacers split the
remaining space above and below so the stack reads as "quick access"
centered in the strip, Opera-style. */
.strip { display: flex; flex-direction: column; align-items: center; .strip { display: flex; flex-direction: column; align-items: center;
padding: 10px 0; gap: 4px; height: 100%; box-sizing: border-box; padding: 6px 0; height: 100%; box-sizing: border-box;
border-right: 1px solid var(--line2); } border-right: 1px solid var(--line2); }
.icon { width: 36px; height: 36px; border-radius: 8px; display: grid; place-items: center; .icons { display: flex; flex-direction: column; align-items: center; gap: 4px;
cursor: pointer; background: transparent; border: none; padding: 0; color: var(--mut); flex: none; }
.spacer { flex: 1 1 auto; min-height: 10px; }
.icon { width: 34px; height: 34px; border-radius: 8px; display: grid; place-items: center;
cursor: pointer; background: transparent; border: none; padding: 0; color: var(--ink);
position: relative; } position: relative; }
.icon:hover { background: var(--line2); color: var(--ink); } .icon:hover { background: var(--line2); }
.icon:active { transform: scale(0.94); } .icon:active { transform: scale(0.94); }
.icon svg { width: 20px; height: 20px; display: block; } .icon.on { background: var(--acid-tint); box-shadow: inset 2px 0 0 var(--acid); }
.icon img { width: 22px; height: 22px; display: block; border-radius: 4px; } .icon svg, .icon img { width: 20px; height: 20px; display: block; }
.icon.letter { font: 600 13px/1 system-ui, sans-serif; color: var(--mut); }
.icon .tt { position: absolute; left: 44px; top: 50%; transform: translateY(-50%); .icon .tt { position: absolute; left: 44px; top: 50%; transform: translateY(-50%);
background: var(--bg); color: var(--ink); padding: 4px 8px; border-radius: 6px; background: var(--bg); color: var(--ink); padding: 4px 8px; border-radius: 6px;
border: 1px solid var(--line2); white-space: nowrap; font-size: 12px; border: 1px solid var(--line2); white-space: nowrap; font-size: 12px;
pointer-events: none; opacity: 0; transition: opacity .1s; z-index: 10; } pointer-events: none; opacity: 0; transition: opacity .1s; z-index: 10; }
.icon:hover .tt { opacity: 1; transition-delay: .4s; } .icon:hover .tt { opacity: 1; transition-delay: .4s; }
.sep { width: 24px; height: 1px; background: var(--line2); margin: 6px 0; flex: none; }
.spacer { flex: 1 1 auto; }
/* Add button — small plus to add a custom link. Prompts for URL via main. */
.add { color: var(--mut); font: 500 20px/1 system-ui, sans-serif; } .add { color: var(--mut); font: 500 20px/1 system-ui, sans-serif; }
.add:hover { color: var(--acid); } .add:hover { color: var(--acid); }
</style></head> </style></head>
<body> <body>
<div class="strip" id="strip"></div> <div class="strip">
<div class="spacer"></div>
<div class="icons" id="icons"></div>
<div class="spacer"></div>
<button class="icon add" id="addBtn" title="Add link">+<span class="tt">Add link</span></button>
</div>
<script> <script>
const Q = window.quicklinks; const Q = window.quicklinks;
const strip = document.getElementById("strip"); const icons = document.getElementById("icons");
// The links come from main (saved in settings.quickLinks). Each entry is const addBtn = document.getElementById("addBtn");
// { id, url, title, icon } where icon is either a letter, an emoji, or a addBtn.onclick = () => Q && Q.add();
// data: URL for a bundled brand SVG. // Bundled brand marks. Each SVG is tight (20×20 viewBox, no padding), the
function render(links) { // colour is baked in so icons read at a glance in both themes. Unknown ids
strip.innerHTML = ""; // fall back to a letter chip. All marks are purely to identify services.
for (const L of (links || [])) { const BRAND = {
x: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2H21.5l-7.63 8.72L22.5 22h-7.09l-5.54-7.26L3.5 22H.25l8.18-9.35L1.5 2h7.26l5.01 6.63L18.244 2zm-2.49 18.3h1.84L7.37 3.6H5.4l10.354 16.7z"/></svg>',
twitter: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2H21.5l-7.63 8.72L22.5 22h-7.09l-5.54-7.26L3.5 22H.25l8.18-9.35L1.5 2h7.26l5.01 6.63L18.244 2zm-2.49 18.3h1.84L7.37 3.6H5.4l10.354 16.7z"/></svg>',
telegram: '<svg viewBox="0 0 24 24" fill="#229ED9"><path d="M9.78 15.65 9.53 19.1c.37 0 .54-.16.73-.35l1.76-1.65 3.65 2.63c.67.37 1.15.18 1.33-.61l2.42-11.35h.01c.21-.98-.36-1.37-1.01-1.13L3.87 10.84c-.95.38-.94.92-.17 1.16l3.73 1.16L16 7.52c.4-.27.77-.12.47.15l-6.69 7.98z"/></svg>',
whatsapp: '<svg viewBox="0 0 24 24" fill="#25D366"><path d="M19.11 4.86A9.78 9.78 0 0 0 12.04 2 9.86 9.86 0 0 0 3.6 16.84L2.05 22l5.3-1.39A9.84 9.84 0 0 0 12.04 22h.01a9.86 9.86 0 0 0 9.85-9.86 9.78 9.78 0 0 0-2.79-7.28zM12.05 20.3h-.01a8.17 8.17 0 0 1-4.16-1.14l-.3-.18-3.14.82.84-3.06-.2-.32a8.2 8.2 0 1 1 15.19-4.3 8.2 8.2 0 0 1-8.22 8.18zm4.49-6.14c-.25-.12-1.46-.72-1.69-.8-.23-.09-.4-.12-.56.12-.17.25-.65.8-.8.97-.14.17-.29.19-.54.06-.25-.12-1.05-.39-2-1.23a7.5 7.5 0 0 1-1.38-1.72c-.14-.25-.02-.38.11-.5.11-.11.25-.29.37-.43.12-.14.17-.25.25-.42.08-.17.04-.31-.02-.43-.06-.12-.56-1.35-.77-1.85-.2-.49-.4-.42-.56-.43l-.47-.01c-.17 0-.43.06-.65.31-.22.25-.86.84-.86 2.05 0 1.21.88 2.38 1 2.55.12.17 1.74 2.65 4.21 3.72.59.25 1.05.4 1.41.52.59.19 1.13.16 1.56.1.47-.07 1.46-.6 1.66-1.18.21-.57.21-1.07.14-1.17-.06-.1-.23-.17-.48-.3z"/></svg>',
messenger: '<svg viewBox="0 0 24 24"><defs><linearGradient id="mg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#00B2FF"/><stop offset="1" stop-color="#006AFF"/></linearGradient></defs><path fill="url(#mg)" d="M12 2C6.48 2 2 6.14 2 11.26c0 2.77 1.3 5.24 3.37 6.95V22l3.08-1.69c.82.23 1.69.35 2.55.35 5.52 0 10-4.14 10-9.4C22 6.14 17.52 2 12 2zm1 12.63-2.56-2.72-4.99 2.72 5.49-5.84 2.62 2.72 4.93-2.72-5.49 5.84z"/></svg>',
youtube: '<svg viewBox="0 0 24 24" fill="#FF0000"><path d="M23.5 6.5a3 3 0 0 0-2.1-2.12C19.46 4 12 4 12 4s-7.46 0-9.4.38A3 3 0 0 0 .5 6.5 31.3 31.3 0 0 0 .11 12a31.3 31.3 0 0 0 .4 5.5 3 3 0 0 0 2.1 2.12C4.54 20 12 20 12 20s7.46 0 9.4-.38a3 3 0 0 0 2.1-2.12A31.3 31.3 0 0 0 23.9 12a31.3 31.3 0 0 0-.4-5.5zM9.75 15.57V8.43L15.82 12l-6.07 3.57z"/></svg>',
spotify: '<svg viewBox="0 0 24 24" fill="#1DB954"><path d="M12 0a12 12 0 1 0 0 24 12 12 0 0 0 0-24zm5.5 17.3a.75.75 0 0 1-1.03.25c-2.82-1.72-6.37-2.11-10.56-1.15a.75.75 0 1 1-.34-1.46c4.58-1.05 8.5-.6 11.68 1.33a.75.75 0 0 1 .25 1.03zm1.47-3.26a.94.94 0 0 1-1.29.3c-3.23-1.98-8.14-2.56-11.95-1.4a.94.94 0 1 1-.55-1.8c4.36-1.33 9.78-.68 13.5 1.6a.94.94 0 0 1 .3 1.3zm.13-3.4C15.26 8.4 8.72 8.2 5.1 9.34a1.12 1.12 0 1 1-.66-2.15c4.16-1.27 11.37-1.02 15.86 1.65a1.12 1.12 0 1 1-1.14 1.93z"/></svg>',
discord: '<svg viewBox="0 0 24 24" fill="#5865F2"><path d="M20.3 4.5A17.6 17.6 0 0 0 15.9 3l-.2.4a16.2 16.2 0 0 1 3.7 1.2 20 20 0 0 0-5.6-1 18.4 18.4 0 0 0-5.7 1A16.2 16.2 0 0 1 11.8 3.4L11.6 3a17.6 17.6 0 0 0-4.4 1.5A20.2 20.2 0 0 0 3.2 15.5a16.4 16.4 0 0 0 5 2.5l.6-1a11.3 11.3 0 0 1-2.9-1.4l.5-.3c3 1.5 9.1 1.5 12 0l.5.3c-.9.5-1.9 1-2.9 1.4l.6 1a16.4 16.4 0 0 0 5-2.5A20.2 20.2 0 0 0 20.3 4.5zM9 13c-1 0-1.8-1-1.8-2.2S8 8.6 9 8.6c1 0 1.8 1 1.8 2.2S10 13 9 13zm6 0c-1 0-1.8-1-1.8-2.2S14 8.6 15 8.6c1 0 1.8 1 1.8 2.2S16 13 15 13z"/></svg>',
};
function brandFor(id) { return BRAND[String(id || "").toLowerCase()] || null; }
function render(data) {
const links = (data && data.links) || [];
const openId = (data && data.openId) || null;
icons.innerHTML = "";
for (const L of links) {
const b = document.createElement("button"); const b = document.createElement("button");
b.className = "icon"; b.className = "icon";
if (openId && L.id === openId) b.classList.add("on");
b.title = L.title || L.url; b.title = L.title || L.url;
// Icon: a data URL (img) or a short text badge. const svg = brandFor(L.id);
if (L.icon && /^(data:|https?:|file:)/.test(L.icon)) { if (svg) {
const img = document.createElement("img"); b.innerHTML = svg;
img.src = L.icon; img.alt = "";
img.onerror = () => { b.textContent = (L.title || "?").slice(0, 1).toUpperCase(); };
b.appendChild(img);
} else if (L.svg) {
b.innerHTML = L.svg;
} else { } else {
b.classList.add("letter");
b.textContent = (L.title || "?").slice(0, 1).toUpperCase(); b.textContent = (L.title || "?").slice(0, 1).toUpperCase();
} }
const tt = document.createElement("span"); const tt = document.createElement("span");
tt.className = "tt"; tt.textContent = L.title || L.url; tt.className = "tt"; tt.textContent = L.title || L.url;
b.appendChild(tt); b.appendChild(tt);
b.onclick = () => Q && Q.open(L.id); b.onclick = () => Q && Q.open(L.id);
strip.appendChild(b); icons.appendChild(b);
} }
// Spacer pushes the + to the bottom.
const sp = document.createElement("div"); sp.className = "spacer";
strip.appendChild(sp);
const add = document.createElement("button");
add.className = "icon add"; add.textContent = "+"; add.title = "Add link";
const addTt = document.createElement("span"); addTt.className = "tt"; addTt.textContent = "Add link";
add.appendChild(addTt);
add.onclick = () => Q && Q.add();
strip.appendChild(add);
} }
Q && Q.getLinks && Q.getLinks().then(render).catch(() => render([])); Q && Q.getState && Q.getState().then(render).catch(() => render(null));
Q && Q.onLinks && Q.onLinks(render); Q && Q.onState && Q.onState(render);
</script> </script>
</body> </body>
</html> </html>