theseus/quicklinks.html
Local Dev 59d1c5ad56 Theseus: drag sidebar icons into any order
The strip always drew extension panels first and web apps after them,
in a fixed order. Web apps and panels now share one order (stripOrder)
that the icons can be dragged into; reordering web apps in Settings
moves them on the strip too, and a new app or extension goes to the end.
Until the strip is rearranged, Proton Mail leads, above the panels.
2026-10-08 01:30:33 +02:00

187 lines
13 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html>
<head><meta charset="utf-8">
<style>
:root {
color-scheme: light dark;
--bg:#0e131c; --ink:#e7eaf1; --mut:#8b98a9; --line2:rgba(255,255,255,.13);
--acid:#d6ff3d; --acid-tint: rgb(from var(--acid) r g b / .14);
}
@media (prefers-color-scheme: light) {
: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);
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; overflow: hidden;
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;
padding: 6px 0; height: 100%; box-sizing: border-box;
border-right: 1px solid var(--line2); }
.icons { display: flex; flex-direction: column; align-items: center; gap: 4px;
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; }
.icon:hover { background: var(--line2); }
.icon:active { transform: scale(0.94); }
.icon.on { background: var(--acid-tint); box-shadow: inset 2px 0 0 var(--acid); }
.icon.dragging { opacity: .4; }
.icon.dropbefore { box-shadow: inset 0 2px 0 var(--acid); }
.icon.dropafter { box-shadow: inset 0 -2px 0 var(--acid); }
.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%);
background: var(--bg); color: var(--ink); padding: 4px 8px; border-radius: 6px;
border: 1px solid var(--line2); white-space: nowrap; font-size: 12px;
pointer-events: none; opacity: 0; transition: opacity .1s; z-index: 10; }
.icon:hover .tt { opacity: 1; transition-delay: .4s; }
.add { color: var(--mut); font: 500 20px/1 system-ui, sans-serif; }
.sep { width: 22px; height: 1px; background: var(--line2); margin: 4px 0; }
.icon.emoji { font-size: 18px; }
.add:hover { color: var(--acid); }
/* Gear: how panels sit next to the page, and the Sidebar settings. */
.gear { color: var(--mut); margin-bottom: 2px; }
.gear:hover { color: var(--ink); }
.gear svg { width: 17px; height: 17px; }
</style></head>
<body>
<div class="strip">
<div class="spacer"></div>
<div class="icons" id="icons"></div>
<div class="spacer"></div>
<button class="icon gear" id="gearBtn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg><span class="tt">Sidebar options</span></button>
<button class="icon add" id="addBtn" title="Add link">+<span class="tt">Add link</span></button>
</div>
<script>
const Q = window.quicklinks;
const icons = document.getElementById("icons");
const addBtn = document.getElementById("addBtn");
addBtn.onclick = () => Q && Q.add();
const gearBtn = document.getElementById("gearBtn");
gearBtn.onclick = () => {
const r = gearBtn.getBoundingClientRect();
Q && Q.menu && Q.menu({ x: r.left, y: r.top, width: r.width, height: r.height });
};
// Bundled brand marks. Each SVG is tight (20×20 viewBox, no padding), the
// colour is baked in so icons read at a glance in both themes. Unknown ids
// fall back to a letter chip. All marks are purely to identify services.
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>',
// Proton Mail: an envelope in Proton's purple.
proton: '<svg viewBox="0 0 24 24" fill="none"><rect x="2" y="5" width="20" height="14" rx="3" fill="#6D4AFF"/><path d="M3.5 7.5 12 13.2l8.5-5.7" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></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; }
// A link added by hand gets an id like "youtube-k3f9", so the mark is also
// found by the site's address.
const HOST_BRAND = [
[/(^|\.)(x|twitter)\.com$/, "x"], [/(^|\.)telegram\.org$/, "telegram"], [/(^|\.)whatsapp\.com$/, "whatsapp"],
[/(^|\.)messenger\.com$/, "messenger"], [/(^|\.)(youtube\.com|youtu\.be)$/, "youtube"], [/(^|\.)spotify\.com$/, "spotify"],
[/(^|\.)discord(app)?\.(com|gg)$/, "discord"], [/(^|\.)(proton\.me|protonmail\.com)$/, "proton"],
];
const hostOf = (url) => { try { return new URL(url).hostname.toLowerCase(); } catch { return ""; } };
function brandForLink(L) {
const byId = brandFor(L.id);
if (byId) return byId;
const h = hostOf(L.url);
for (const [re, key] of HOST_BRAND) if (re.test(h)) return BRAND[key];
return null;
}
// Any other site: its own favicon, and the first letter if it has none.
function letterIcon(b, L) { b.classList.add("letter"); b.prepend(document.createTextNode((L.title || "?").slice(0, 1).toUpperCase())); }
// One order for web apps and extension panels (main keeps it); drag an
// icon up or down the strip to move it.
let order = [];
let dragKey = null;
function wireDrag(b, key) {
b.draggable = true;
b.dataset.key = key;
b.addEventListener("dragstart", (e) => { dragKey = key; b.classList.add("dragging"); try { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("application/x-theseus-strip", key); } catch {} });
b.addEventListener("dragend", () => { dragKey = null; b.classList.remove("dragging"); icons.querySelectorAll(".icon").forEach((x) => x.classList.remove("dropbefore", "dropafter")); });
b.addEventListener("dragover", (e) => {
if (!dragKey || dragKey === key) return;
e.preventDefault(); e.dataTransfer.dropEffect = "move";
const r = b.getBoundingClientRect(); const before = (e.clientY - r.top) < r.height / 2;
b.classList.toggle("dropbefore", before); b.classList.toggle("dropafter", !before);
});
b.addEventListener("dragleave", () => b.classList.remove("dropbefore", "dropafter"));
b.addEventListener("drop", (e) => {
e.preventDefault();
if (!dragKey || dragKey === key) return;
const r = b.getBoundingClientRect(); const before = (e.clientY - r.top) < r.height / 2;
const next = order.filter((k) => k !== dragKey);
next.splice(next.indexOf(key) + (before ? 0 : 1), 0, dragKey);
dragKey = null;
Q && Q.reorder && Q.reorder(next);
});
}
function render(data) {
const links = (data && data.links) || [];
const openId = (data && data.openId) || null;
icons.innerHTML = "";
const panels = (data && data.panels) || [];
order = Array.isArray(data && data.order) ? data.order : [...panels.map((P) => P.id), ...links.map((L) => "q:" + L.id)];
const made = new Map();
for (const P of panels) {
const b = document.createElement("button");
b.className = "icon";
if (openId === P.id) b.classList.add("on");
b.title = P.title;
if (/^data:image\//.test(P.icon)) {
const img = document.createElement("img");
img.src = P.icon;
img.alt = "";
b.appendChild(img);
} else {
b.classList.add(P.icon ? "emoji" : "letter");
b.textContent = P.icon || (P.title || "?").slice(0, 1).toUpperCase();
}
const tt = document.createElement("span");
tt.className = "tt"; tt.textContent = P.title;
b.appendChild(tt);
b.onclick = () => Q && Q.open(P.id);
wireDrag(b, P.id);
made.set(P.id, b);
}
for (const L of links) {
const b = document.createElement("button");
b.className = "icon";
if (openId && L.id === openId) b.classList.add("on");
b.title = L.title || L.url;
const svg = brandForLink(L);
const host = hostOf(L.url);
if (svg) {
b.innerHTML = svg;
} else if (host && /^https?:/.test(L.url)) {
const img = document.createElement("img");
img.alt = "";
img.onerror = () => { img.remove(); letterIcon(b, L); };
img.src = new URL("/favicon.ico", L.url).href;
b.appendChild(img);
} else {
letterIcon(b, L);
}
const tt = document.createElement("span");
tt.className = "tt"; tt.textContent = L.title || L.url;
b.appendChild(tt);
b.onclick = () => Q && Q.open(L.id);
wireDrag(b, "q:" + L.id);
made.set("q:" + L.id, b);
}
for (const k of order) { const b = made.get(k); if (b) { icons.appendChild(b); made.delete(k); } }
for (const b of made.values()) icons.appendChild(b);
}
Q && Q.getState && Q.getState().then(render).catch(() => render(null));
Q && Q.onState && Q.onState(render);
</script>
</body>
</html>