Three home-page tweaks: - Removed the YOUR LINKS and SILENT MODE section headings; the two panels now stand on their own without labels. - The + Add card cell now matches the real cards' size and shape so it reads as a peer of the grid rather than a short dashed strip. - Empty slot cells in the left grid are invisible by default and only reveal their dashed outline when you hover the strip or drag a link over them. The + add-link cell stays visible as the discoverable entry. AI engine picker: - The chevron badge on the AI button is now a plain span with the button role, not a nested <button> inside the main AI button. Chromium flattened the invalid nesting so the chevron's own click handler never fired; the picker now opens from the chevron and from keyboard Enter / Space as well as right-click. Performance (Settings > Performance > Startup): - Pithos ships with activation: on-demand; its control server for s3d no longer boots at launch. Wakes on first panel open or a pithos.sia navigation. - Hermes relay auto-restore (1.5 s after launch) is now gated by a new setting hermesAutoRestore (default on). Turn it off to skip background Nostr relay connections for users who don't live in Hermes. - New setting pithosAtLaunch (default off) mirrors the existing walletAtLaunch so users who do want Pithos ready at launch can opt back in. - The Lightest preset now also turns off pithosAtLaunch and hermesAutoRestore.
941 lines
51 KiB
HTML
941 lines
51 KiB
HTML
<!doctype html>
|
||
<html>
|
||
<head><meta charset="utf-8"><title>Theseus</title>
|
||
<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; min-height: 100vh; font-family: system-ui, sans-serif; color: var(--ink); }
|
||
body { background: radial-gradient(1200px 600px at 50% -10%, var(--bg1), var(--bg2)); }
|
||
|
||
/* Hero (identity + search). Centered at the top of the page — the user's
|
||
most-used thing on this screen. The slot/card desktop lives beneath.
|
||
The mark + wordmark share a translucent panel so they read on any
|
||
wallpaper; the tag, search box and hint sit outside it, centered. */
|
||
.hero { text-align: center; padding: 5vh 1rem 0; max-width: 820px; margin: 0 auto; }
|
||
.hero .hero-id { display: inline-flex; align-items: center; gap: 14px;
|
||
padding: 12px 24px 12px 14px; border-radius: 22px;
|
||
background: rgba(11,14,20,.55); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
||
box-shadow: 0 10px 30px rgba(0,0,0,.3), inset 0 0 0 1px rgba(255,255,255,.06);
|
||
margin-bottom: .8rem; }
|
||
.hero .mark { flex: none; width: 56px; height: 56px; line-height: 1; }
|
||
.hero .mark svg { width: 100%; height: 100%; display: block; }
|
||
.hero h1 { font-size: 2.2rem; margin: 0; letter-spacing: .5px; font-weight: 600; line-height: 1.05; }
|
||
.hero h1 .g { color: var(--acid); }
|
||
.hero .tag { color: var(--ink); margin: 0 0 1.4rem;
|
||
display: inline-block; padding: 6px 14px; border-radius: 999px;
|
||
background: rgba(11,14,20,.42); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
|
||
.hero form { display: flex; gap: 8px; justify-content: center; max-width: 620px; margin: 0 auto; padding: 0 1rem; }
|
||
.hero form input { flex: 1; padding: 13px 18px; 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); }
|
||
.hero form input:focus { border-color: var(--blue); }
|
||
.hero form button { padding: 13px 20px; border-radius: 999px; border: none; background: var(--blue); color: #fff; font-size: 15px; cursor: pointer; }
|
||
.hero .hint { color: var(--mut); font-size: 12.5px; margin-top: .8rem;
|
||
display: inline-block; padding: 4px 12px; border-radius: 999px;
|
||
background: rgba(11,14,20,.42); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
|
||
|
||
/* Two distinct sections under the hero: a self-contained "Your links"
|
||
panel on the LEFT (quick-launch tiles) and a Silent Mode cards panel
|
||
on the RIGHT. Each gets its own panel background so they read as two
|
||
separate boxes instead of one shared body. The two are side-by-side
|
||
on desktop and stack vertically on narrow viewports. */
|
||
.homepage { display: grid; grid-template-columns: minmax(360px, 440px) minmax(380px, 1fr);
|
||
gap: 24px; max-width: 1680px; margin: 2.4rem auto 0; padding: 0 32px 48px; align-items: start; }
|
||
@media (max-width: 900px) { .homepage { grid-template-columns: 1fr; gap: 20px; padding: 0 14px 32px; } }
|
||
|
||
/* Section container styling — each column is its own padded panel. The
|
||
panel itself is subtle (soft tinted surface, no hard border) so the
|
||
separation reads structurally without the sections feeling boxed-in. */
|
||
.section { background: rgba(255,255,255,.025); border-radius: 16px;
|
||
padding: 16px 16px 20px; box-shadow: inset 0 0 0 1px rgba(255,255,255,.03);
|
||
width: 100%; min-width: 0; }
|
||
body.light .section { background: rgba(0,0,0,.03); box-shadow: inset 0 0 0 1px rgba(0,0,0,.03); }
|
||
.section-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .9px;
|
||
color: var(--mut); margin: 0 2px 14px; }
|
||
|
||
/* LEFT AREA — fixed-cell grid. Each icon lives in its own cell; drag
|
||
reorders which icon is in which cell. Icons sized to roughly match a
|
||
Windows desktop shortcut (~48 px tile + label) so they stop looming
|
||
next to the Silent Mode cards. */
|
||
.left-column { align-self: start; }
|
||
/* Fixed-width cells (not 1fr) so icons sit in a tight desktop-style grid
|
||
instead of drifting inside stretched 3-equal-column cells. The whole
|
||
grid left-aligns to the column edge via justify-content: start. */
|
||
.slots-list { display: grid; grid-template-columns: repeat(auto-fill, 72px); gap: 10px 8px;
|
||
width: 100%; align-items: start; justify-content: start; }
|
||
.cards-grid.drop-target { outline: 2px dashed var(--acid); outline-offset: 4px; border-radius: 14px; }
|
||
|
||
.slot { position: relative; display: flex; flex-direction: column; align-items: center;
|
||
cursor: pointer; user-select: none; text-decoration: none; color: inherit;
|
||
transition: transform .12s ease; }
|
||
.slot:hover { transform: translateY(-2px); }
|
||
.slot .tile { display: flex; align-items: center; justify-content: center;
|
||
border-radius: 12px; background: var(--iconBg, #0b0e14);
|
||
color: var(--iconFg, #e7eaf1); font-weight: 700; letter-spacing: -.02em;
|
||
box-shadow: 0 4px 12px #0007, inset 0 0 0 1px rgba(255,255,255,.08);
|
||
overflow: hidden; }
|
||
.slot .tile svg, .slot .tile img { width: 76%; height: 76%; display: block; }
|
||
.slot .label { margin-top: 5px; font-size: 11px; line-height: 1.2; color: var(--ink);
|
||
max-width: 72px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||
padding: 1px 4px; border-radius: 4px; text-align: center; }
|
||
.slot.size-small .tile { width: 40px; height: 40px; font-size: 17px; border-radius: 10px; }
|
||
.slot.size-medium .tile { width: 48px; height: 48px; font-size: 20px; border-radius: 11px; }
|
||
.slot.size-large .tile { width: 60px; height: 60px; font-size: 24px; border-radius: 13px; }
|
||
.slot.size-small .label { font-size: 10.5px; }
|
||
.slot.dragging { opacity: .6; }
|
||
.slot.drag-before::before, .slot.drag-after::after {
|
||
content: ""; position: absolute; top: 10%; bottom: 10%; width: 3px;
|
||
background: var(--acid); border-radius: 2px; box-shadow: 0 0 8px var(--acid);
|
||
}
|
||
.slot.drag-before::before { left: -8px; }
|
||
.slot.drag-after::after { right: -8px; }
|
||
|
||
/* Slot-add sits in the grid alongside the slot icons so it's always at
|
||
the end of the row, not pinned to a corner. */
|
||
.slot-add { display: flex; align-items: center; justify-content: center;
|
||
width: 48px; height: 48px; margin: 0 auto;
|
||
border-radius: 10px; border: 2px dashed var(--line2);
|
||
background: transparent; color: var(--mut); font-size: 18px; cursor: pointer;
|
||
transition: color .12s, border-color .12s, background .12s; }
|
||
.slot-add:hover { color: var(--acid); border-color: var(--acid); background: rgb(from var(--acid) r g b / .04); }
|
||
/* Empty slots in the grid: drop targets for pinning any link to a
|
||
specific slot. Order matches the grid's cell index. Subtly visible
|
||
so users see where they can drop; brighter on hover of the strip. */
|
||
.slot-empty { display: block; width: 48px; height: 48px; margin: 0 auto;
|
||
border-radius: 10px; border: 2px dashed transparent;
|
||
background: transparent; transition: border-color .12s, background .12s; }
|
||
.slots-list:hover .slot-empty { border-color: rgb(from var(--line2) r g b / .55); }
|
||
.slot-empty.drop-hot { border-color: var(--acid); background: rgb(from var(--acid) r g b / .06); }
|
||
|
||
/* RIGHT COLUMN — just the 3-column grid of Silent Mode cards; search
|
||
lives in the hero above. */
|
||
.right-column { display: flex; flex-direction: column; gap: 22px; min-width: 0; }
|
||
.cards-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; align-content: start; }
|
||
@media (max-width: 980px) { .cards-grid { grid-template-columns: repeat(2, 1fr); } }
|
||
@media (max-width: 560px) { .cards-grid { grid-template-columns: 1fr; } }
|
||
|
||
.card { display: flex; flex-direction: row; align-items: center; justify-content: flex-start;
|
||
gap: 14px; padding: 14px 16px; min-height: 92px;
|
||
background: rgba(20,26,36,.55); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
||
border: 1px solid var(--line); border-radius: 14px;
|
||
text-decoration: none; color: inherit; cursor: pointer; position: relative;
|
||
transition: transform .12s, background .12s, border-color .12s; }
|
||
.card:hover { background: rgba(20,26,36,.72); border-color: rgba(75,123,236,.4); transform: translateY(-1px); }
|
||
.card .card-left { flex: none; display: flex; flex-direction: column; align-items: center; gap: 6px; width: 72px; }
|
||
.card .card-ico { flex: none; width: 40px; height: 40px; border-radius: 10px;
|
||
background: var(--iconBg, transparent);
|
||
display: flex; align-items: center; justify-content: center;
|
||
color: var(--iconFg, #e7eaf1); font-weight: 700; font-size: 20px;
|
||
overflow: hidden; box-shadow: inset 0 0 0 1px rgba(255,255,255,.06); }
|
||
.card .card-ico svg, .card .card-ico img { width: 100%; height: 100%; display: block; }
|
||
.card .card-title { font-size: 13px; font-weight: 600; color: var(--ink); text-align: center;
|
||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; line-height: 1.2; }
|
||
.card .card-title .tld { color: var(--acid); font-weight: 700; letter-spacing: .3px;
|
||
text-shadow: 0 0 8px rgb(from var(--acid) r g b / .35); }
|
||
.card .card-sub { flex: 1; min-width: 0; font-size: 12px; color: var(--mut); text-align: left; line-height: 1.4;
|
||
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
|
||
overflow: hidden; }
|
||
.card:hover .card-sub { color: var(--ink); }
|
||
.card.dragging { opacity: .6; }
|
||
.card.drag-before::before, .card.drag-after::after {
|
||
content: ""; position: absolute; top: 10%; bottom: 10%; width: 3px;
|
||
background: var(--acid); border-radius: 2px; box-shadow: 0 0 8px var(--acid);
|
||
}
|
||
.card.drag-before::before { left: -8px; }
|
||
.card.drag-after::after { right: -8px; }
|
||
/* Matches .card's horizontal shape so a "+ Add card" cell sits in the
|
||
grid as a peer of the real cards instead of a short dashed strip. */
|
||
.card-add { display: flex; align-items: center; justify-content: center;
|
||
gap: 14px; padding: 14px 16px; min-height: 92px;
|
||
border: 2px dashed var(--line2); border-radius: 14px;
|
||
background: rgba(20,26,36,.35); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
||
color: var(--mut); font-size: 13.5px; cursor: pointer;
|
||
transition: color .12s, border-color .12s, background .12s; }
|
||
.card-add:hover { color: var(--acid); border-color: var(--acid); background: rgb(from var(--acid) r g b / .06); }
|
||
|
||
/* Context menus. One for the background (edit actions), one for images
|
||
(copy / edit / remove). Positioned at the pointer by JS. */
|
||
.ctx-menu { position: fixed; display: none; z-index: 1000; min-width: 200px;
|
||
background: rgba(20,26,36,.96); border: 1px solid var(--line2); border-radius: 10px;
|
||
padding: 6px; box-shadow: 0 16px 40px #000a; backdrop-filter: blur(10px); }
|
||
.ctx-menu.on { display: block; }
|
||
.ctx-menu button, .ctx-menu .submenu-trigger {
|
||
display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
||
width: 100%; text-align: left; background: transparent; border: 0;
|
||
color: var(--ink); padding: 8px 12px; border-radius: 6px; cursor: pointer;
|
||
font: inherit; font-size: 13px; }
|
||
.ctx-menu button:hover, .ctx-menu .submenu-trigger:hover { background: var(--card-hi); }
|
||
.ctx-menu button.danger { color: var(--rose); }
|
||
.ctx-menu hr { border: 0; border-top: 1px solid var(--line); margin: 4px 2px; }
|
||
.ctx-menu .caret { color: var(--dim); font-size: 11px; }
|
||
.ctx-menu .value { color: var(--mut); font-size: 12px; }
|
||
|
||
/* Background chooser modal (opened from the context menu) */
|
||
.modal-bg { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 1100;
|
||
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); }
|
||
|
||
.bg-preset-row { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin-top: 6px; }
|
||
.bg-preset-row button { height: 44px; border-radius: 8px; border: 1px solid var(--line2); cursor: pointer; padding: 0; }
|
||
.bg-preset-row button.on { outline: 2px solid var(--acid); outline-offset: 1px; }
|
||
.bg-color-row { display: flex; align-items: center; gap: 10px; margin-top: 8px; }
|
||
.bg-color-row input[type=color] { width: 44px; height: 32px; border: 1px solid var(--line2); border-radius: 6px; background: transparent; padding: 0; cursor: pointer; }
|
||
.bg-file { font-size: 12.5px; color: var(--mut); border: 1px dashed var(--line2); border-radius: 6px; padding: 6px 12px; cursor: pointer; }
|
||
.bg-file:hover { color: var(--acid); border-color: var(--acid); }
|
||
|
||
.toast { position: fixed; left: 50%; bottom: 32px; transform: translateX(-50%) translateY(20px);
|
||
background: rgba(20,26,36,.96); color: var(--ink); padding: 10px 18px; border-radius: 10px;
|
||
border: 1px solid var(--line2); font-size: 13px; opacity: 0; pointer-events: none;
|
||
transition: opacity .18s, transform .18s; z-index: 1200; }
|
||
.toast.on { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||
|
||
@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; }
|
||
.slot .label { color: #0b0e14; text-shadow: 0 1px 2px #fffb, 0 0 10px #fffc; }
|
||
.modal, .ctx-menu { background: #ffffff; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="hero">
|
||
<div class="hero-id">
|
||
<div 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>
|
||
</div>
|
||
<h1>Theseus <span class="g">Navigator</span></h1>
|
||
</div>
|
||
<p class="tag">A browser that follows the thread. Names that live on decentralized blockchain.</p>
|
||
<form id="searchForm">
|
||
<input id="q" placeholder="Search the web" autofocus spellcheck="false">
|
||
<button type="submit">Search</button>
|
||
</form>
|
||
<p class="hint">Tip: type <b>hello.bch</b> in the address bar above to open a decentralized site.</p>
|
||
</div>
|
||
|
||
<div class="homepage">
|
||
<div class="section left-column">
|
||
<div class="slots-list" id="slotsList"></div>
|
||
</div>
|
||
<div class="section right-column">
|
||
<div class="cards-grid" id="cardsGrid"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ctx-menu" id="ctxBg">
|
||
<button data-action="change-bg">Change background…</button>
|
||
<button class="submenu-trigger" data-action="icon-size">
|
||
<span>Icon size</span><span class="value" id="ctxIconSizeVal">medium</span>
|
||
</button>
|
||
<hr>
|
||
<button data-action="add-link">Add link (left column)</button>
|
||
<button data-action="add-card">Add card (Silent Mode)</button>
|
||
<hr>
|
||
<button class="danger" data-action="reset">Reset to defaults</button>
|
||
</div>
|
||
|
||
<div class="ctx-menu" id="ctxItem">
|
||
<button data-action="open">Open</button>
|
||
<button data-action="open-new">Open in new tab</button>
|
||
<hr>
|
||
<button data-action="copy-image">Copy image</button>
|
||
<button data-action="copy-url">Copy link address</button>
|
||
<hr>
|
||
<button data-action="edit">Edit…</button>
|
||
<button class="danger" data-action="remove">Remove</button>
|
||
</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/">
|
||
<label>Description (shown on cards)</label>
|
||
<input id="mSub" placeholder="one short line — optional">
|
||
<div class="row">
|
||
<div>
|
||
<label>Column</label>
|
||
<select id="mSection">
|
||
<option value="q">Left column (slots)</option>
|
||
<option value="sm">Silent Mode (3-column grid)</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label>Icon color (fallback)</label>
|
||
<input id="mColor" type="color" value="#d6ff3d">
|
||
</div>
|
||
</div>
|
||
<label>Letter / emoji (optional)</label>
|
||
<input id="mLetter" placeholder="first letter is used by default" maxlength="4">
|
||
<div class="btns"><button id="mCancel" type="button">Cancel</button><button id="mSave" class="primary" type="button">Save</button></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-bg" id="bgModal">
|
||
<div class="modal">
|
||
<h2>Background</h2>
|
||
<label>Preset</label>
|
||
<div class="bg-preset-row">
|
||
<button data-bg="dark" 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>
|
||
<label>Custom color</label>
|
||
<div class="bg-color-row">
|
||
<input id="bgColor" type="color" value="#0b0e14">
|
||
<label class="bg-file">Upload image<input id="bgImage" type="file" accept="image/*" style="display:none"></label>
|
||
</div>
|
||
<div class="btns"><button id="bgClose" class="primary" type="button">Done</button></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<script>
|
||
const $ = (id) => document.getElementById(id);
|
||
const esc = (s) => String(s || "").replace(/</g, "<").replace(/"/g, """);
|
||
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 toast(msg) {
|
||
const t = $("toast"); t.textContent = msg; t.classList.add("on");
|
||
clearTimeout(toast._h); toast._h = setTimeout(() => t.classList.remove("on"), 1800);
|
||
}
|
||
|
||
// Brand marks — inline SVG strings or { src } references to files bundled
|
||
// under home-brand/. Fall back to a letter tile when the icon has no brand.
|
||
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: { src: "home-brand/hermes-mark.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: { src: "home-brand/ariadne-mark.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: `<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>`,
|
||
};
|
||
|
||
// v5 schema. Dropped the per-item x,y that v4 used for free-drag — left
|
||
// column is a vertical stack now, order within the array is the order on
|
||
// screen. Legacy x,y values are ignored by render() but preserved in
|
||
// storage (migrateCards treats items as opaque).
|
||
let state = null;
|
||
function defaultState() {
|
||
return {
|
||
version: 6,
|
||
iconSize: "medium",
|
||
background: { kind: "preset", value: "dark" },
|
||
sections: {
|
||
sm: [
|
||
{ title: "Theseus.X", url: "https://theseus.x/", brand: "theseus", sub: "The Web Navigator — this browser's home." },
|
||
{ title: "SilentMode.X", url: "https://silentmode.x/", brand: "silentmode", sub: "Infrastructure for a decentralized web." },
|
||
{ title: "Sirius.X", url: "https://sirius.x/", brand: "sirius", sub: "Register and manage BCDN names." },
|
||
{ title: "Hephaestus.X", url: "https://hephaestus.x/", brand: "hephaestus", sub: "The forge — Silent Mode's code host." },
|
||
{ title: "Prometheus.X", url: "https://prometheus.x/", brand: "prometheus", sub: "Decentralized app marketplace." },
|
||
{ title: "Hermes.X", url: "https://hermes.x/", brand: "hermes", sub: "E2E-encrypted messaging over Nostr." },
|
||
{ title: "Helios.X", url: "https://helios.x/", brand: "helios", sub: "Search engine for the decentralized web." },
|
||
{ title: "Ariadne.X", url: "https://ariadne.x/", brand: "ariadne", sub: "BNS resolver — read .x and .bch names." },
|
||
{ title: "Aegis.X", url: "https://aegis.x/", brand: "aegis", sub: "Multi-chain wallet, derived from the vault." },
|
||
],
|
||
// Your-links icons live in fixed grid cells; drag reorders which
|
||
// icon is in which cell. Order in the array = position in the grid
|
||
// (row-first, 3 per row).
|
||
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) {
|
||
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 = "medium";
|
||
return s;
|
||
}
|
||
function persist() { if (window.home && window.home.setCards) window.home.setCards(state); }
|
||
|
||
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("#bgModal .bg-preset-row 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;
|
||
}
|
||
|
||
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() {
|
||
// LEFT — fixed-cell grid of up to 9+ slots (3 wide × as many rows as
|
||
// needed). Each item in q[] may carry a `slot: number` to pin it to a
|
||
// specific cell; items without a slot flow top-to-bottom into the
|
||
// first unoccupied cells. Empty cells are drop targets, visible on
|
||
// hover of the grid. The "+" cell sits at the end so adding is always
|
||
// one click away.
|
||
const q = state.sections.q || [];
|
||
const slotCells = layoutSlots(q);
|
||
const list = $("slotsList");
|
||
list.innerHTML = "";
|
||
slotCells.forEach((entry, cellIdx) => {
|
||
if (entry) {
|
||
const ic = entry.item, i = entry.i;
|
||
const el = document.createElement("a");
|
||
el.className = `slot size-${state.iconSize}`;
|
||
el.href = ic.url; el.dataset.sec = "q"; el.dataset.i = i; el.dataset.cell = cellIdx;
|
||
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="tile">${iconArt(ic)}</div><div class="label">${esc(ic.title || ic.url)}</div>`;
|
||
wireSlot(el, i);
|
||
list.appendChild(el);
|
||
} else {
|
||
const empty = document.createElement("div");
|
||
empty.className = "slot-empty"; empty.dataset.cell = cellIdx;
|
||
wireEmptySlot(empty, cellIdx);
|
||
list.appendChild(empty);
|
||
}
|
||
});
|
||
const slotAdd = document.createElement("button");
|
||
slotAdd.className = "slot-add"; slotAdd.textContent = "+"; slotAdd.title = "Add link";
|
||
slotAdd.onclick = (e) => { e.preventDefault(); e.stopPropagation(); openModal("q", -1); };
|
||
slotAdd.oncontextmenu = (e) => e.preventDefault();
|
||
list.appendChild(slotAdd);
|
||
|
||
// RIGHT — Silent Mode cards with sub-description under the title.
|
||
const sm = state.sections.sm || [];
|
||
const grid = $("cardsGrid");
|
||
grid.innerHTML = "";
|
||
sm.forEach((ic, i) => {
|
||
const el = document.createElement("a");
|
||
el.className = "card";
|
||
el.href = ic.url; el.dataset.sec = "sm"; el.dataset.i = i;
|
||
if (!iconUsesBrand(ic)) {
|
||
el.style.setProperty("--iconBg", ic.color || "#d6ff3d");
|
||
el.style.setProperty("--iconFg", isLightColor(ic.color || "#d6ff3d") ? "#0b0e14" : "#ffffff");
|
||
}
|
||
const sub = ic.sub ? `<div class="card-sub">${esc(ic.sub)}</div>` : "";
|
||
el.innerHTML = `<div class="card-left"><div class="card-ico">${iconArt(ic)}</div><div class="card-title">${styledTitle(ic.title)}</div></div>${sub}`;
|
||
wireCard(el, i);
|
||
grid.appendChild(el);
|
||
});
|
||
const add = document.createElement("button");
|
||
add.className = "card-add"; add.textContent = "+ Add card";
|
||
add.onclick = (e) => { e.preventDefault(); e.stopPropagation(); openModal("sm", -1); };
|
||
add.oncontextmenu = (e) => e.preventDefault();
|
||
grid.appendChild(add);
|
||
|
||
$("ctxIconSizeVal").textContent = state.iconSize;
|
||
}
|
||
|
||
// Lay out the q[] items into a fixed-slot grid: pinned items (with a
|
||
// `slot: number`) go into that cell; unpinned items flow into the first
|
||
// free cell, top-to-bottom. The grid always extends to at least one
|
||
// trailing empty row for drop space.
|
||
function layoutSlots(items, minSlots = 9) {
|
||
const pinned = []; const unpinned = [];
|
||
items.forEach((ic, i) => {
|
||
const s = Number.isInteger(ic.slot) && ic.slot >= 0 ? ic.slot : null;
|
||
if (s !== null) pinned.push({ item: ic, i, slot: s });
|
||
else unpinned.push({ item: ic, i });
|
||
});
|
||
const maxPinned = pinned.reduce((m, p) => Math.max(m, p.slot), -1);
|
||
const used = pinned.length + unpinned.length;
|
||
let need = Math.max(minSlots, maxPinned + 1, used);
|
||
need = Math.ceil(need / 3) * 3;
|
||
need = Math.max(need, used + 1); // keep one trailing empty cell visible
|
||
need = Math.ceil(need / 3) * 3;
|
||
const cells = new Array(need).fill(null);
|
||
for (const p of pinned) {
|
||
if (p.slot < cells.length && !cells[p.slot]) cells[p.slot] = { item: p.item, i: p.i };
|
||
else unpinned.push({ item: p.item, i: p.i }); // collision: demote to unpinned
|
||
}
|
||
let u = 0;
|
||
for (let i = 0; i < cells.length && u < unpinned.length; i++) {
|
||
if (!cells[i]) { cells[i] = unpinned[u++]; }
|
||
}
|
||
return cells;
|
||
}
|
||
|
||
// Both sections are 2D grids: slots are fixed cells on the left (3 wide),
|
||
// cards are fixed cells on the right (3 wide). Drag reorders inside a
|
||
// section with a before/after indicator on the hover target; drop onto
|
||
// the other container's empty space re-parents (appends).
|
||
// Left-grid slots: drop onto a filled slot swaps the two slot assignments
|
||
// (so the user can rearrange visually without having to think about array
|
||
// order). Drop onto an empty slot is handled by wireEmptySlot.
|
||
function wireSlot(el, i) {
|
||
el.draggable = true;
|
||
el.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
const url = state.sections.q[i].url;
|
||
if (window.home && window.home.navigate) window.home.navigate(url);
|
||
else window.location.href = url;
|
||
});
|
||
el.addEventListener("contextmenu", (e) => {
|
||
e.preventDefault(); e.stopPropagation();
|
||
openCtxItem(e.clientX, e.clientY, "q", i);
|
||
});
|
||
el.addEventListener("dragstart", (e) => {
|
||
e.dataTransfer.effectAllowed = "move";
|
||
e.dataTransfer.setData("application/x-slot", JSON.stringify({ sec: "q", i }));
|
||
el.classList.add("dragging");
|
||
hideAllCtx();
|
||
});
|
||
el.addEventListener("dragend", () => {
|
||
el.classList.remove("dragging");
|
||
document.querySelectorAll(".drop-hot,.drag-before,.drag-after")
|
||
.forEach((n) => n.classList.remove("drop-hot", "drag-before", "drag-after"));
|
||
});
|
||
el.addEventListener("dragover", (e) => {
|
||
e.preventDefault(); e.dataTransfer.dropEffect = "move";
|
||
el.classList.add("drag-before");
|
||
});
|
||
el.addEventListener("dragleave", () => el.classList.remove("drag-before"));
|
||
el.addEventListener("drop", (e) => {
|
||
e.preventDefault(); el.classList.remove("drag-before");
|
||
const payload = tryParse(e.dataTransfer.getData("application/x-slot"));
|
||
if (!payload) return;
|
||
const targetCell = Number(el.dataset.cell);
|
||
if (payload.sec !== "q") { pinItemToSlot(payload.sec, payload.i, targetCell); return; }
|
||
if (payload.i === i) return;
|
||
swapSlots(payload.i, i, targetCell);
|
||
});
|
||
}
|
||
function wireCard(el, i) { wireGridItem(el, "sm", i); }
|
||
|
||
// Swap the pinned-slot positions of two q items. The dropped item takes
|
||
// over the target's cell; the target moves into whatever cell the dropped
|
||
// item was in. Unpinned items get pinned as part of the swap.
|
||
function swapSlots(fromI, toI, targetCell) {
|
||
const q = state.sections.q;
|
||
const src = q[fromI], dst = q[toI];
|
||
if (!src || !dst) return;
|
||
const prevSrcSlot = Number.isInteger(src.slot) ? src.slot : null;
|
||
src.slot = targetCell;
|
||
// The target item moves into the source's old slot; if source was
|
||
// unpinned, we need to give the target a slot too (its current flowed
|
||
// position) so it doesn't jump when the source leaves.
|
||
if (prevSrcSlot !== null) dst.slot = prevSrcSlot;
|
||
else {
|
||
// Find where dst currently sits in the laid-out grid, pre-swap.
|
||
const grid = layoutSlots(q);
|
||
const dstCell = grid.findIndex((e) => e && e.i === toI);
|
||
dst.slot = dstCell >= 0 ? dstCell : targetCell;
|
||
// After the swap, dst takes the old source cell. Re-compute.
|
||
const srcCell = grid.findIndex((e) => e && e.i === fromI);
|
||
if (srcCell >= 0) dst.slot = srcCell;
|
||
}
|
||
persist(); render();
|
||
}
|
||
|
||
// Empty cell in the left grid: accepts a dropped slot (from q or sm) and
|
||
// pins the item to this cell's index. Dropping an unpinned item here
|
||
// promotes it to pinned; dropping a pinned item just moves its pin.
|
||
function wireEmptySlot(el, cellIdx) {
|
||
el.addEventListener("dragover", (e) => {
|
||
e.preventDefault(); e.dataTransfer.dropEffect = "move";
|
||
el.classList.add("drop-hot");
|
||
});
|
||
el.addEventListener("dragleave", () => el.classList.remove("drop-hot"));
|
||
el.addEventListener("drop", (e) => {
|
||
e.preventDefault(); el.classList.remove("drop-hot");
|
||
const payload = tryParse(e.dataTransfer.getData("application/x-slot"));
|
||
if (!payload) return;
|
||
pinItemToSlot(payload.sec, payload.i, cellIdx);
|
||
});
|
||
}
|
||
|
||
// Place the dragged item at `cellIdx` in the q grid, pinning it.
|
||
// Cross-section (sm → q) moves the item first, then pins. Same-cell
|
||
// self-drop is a no-op.
|
||
function pinItemToSlot(fromSec, fromI, cellIdx) {
|
||
if (fromSec !== "q") {
|
||
const [m] = state.sections[fromSec].splice(fromI, 1);
|
||
m.slot = cellIdx;
|
||
state.sections.q.push(m);
|
||
} else {
|
||
const it = state.sections.q[fromI]; if (!it) return;
|
||
it.slot = cellIdx;
|
||
}
|
||
persist(); render();
|
||
}
|
||
|
||
function wireGridItem(el, sec, i) {
|
||
el.draggable = true;
|
||
el.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
const url = state.sections[sec][i].url;
|
||
if (window.home && window.home.navigate) window.home.navigate(url);
|
||
else window.location.href = url;
|
||
});
|
||
el.addEventListener("contextmenu", (e) => {
|
||
e.preventDefault(); e.stopPropagation();
|
||
openCtxItem(e.clientX, e.clientY, sec, i);
|
||
});
|
||
el.addEventListener("dragstart", (e) => {
|
||
e.dataTransfer.effectAllowed = "move";
|
||
e.dataTransfer.setData("application/x-slot", JSON.stringify({ sec, i }));
|
||
el.classList.add("dragging");
|
||
hideAllCtx();
|
||
});
|
||
el.addEventListener("dragend", () => {
|
||
el.classList.remove("dragging");
|
||
document.querySelectorAll(".drag-before,.drag-after")
|
||
.forEach((n) => n.classList.remove("drag-before", "drag-after"));
|
||
});
|
||
el.addEventListener("dragover", (e) => {
|
||
e.preventDefault(); e.dataTransfer.dropEffect = "move";
|
||
const r = el.getBoundingClientRect();
|
||
const before = e.clientX < r.left + r.width / 2;
|
||
el.classList.toggle("drag-before", before);
|
||
el.classList.toggle("drag-after", !before);
|
||
});
|
||
el.addEventListener("dragleave", () => {
|
||
el.classList.remove("drag-before", "drag-after");
|
||
});
|
||
el.addEventListener("drop", (e) => {
|
||
e.preventDefault();
|
||
const payload = tryParse(e.dataTransfer.getData("application/x-slot"));
|
||
if (!payload) return;
|
||
const r = el.getBoundingClientRect();
|
||
const side = e.clientX < r.left + r.width / 2 ? -1 : 1;
|
||
let targetIdx = i + (side === 1 ? 1 : 0);
|
||
moveItem(payload.sec, payload.i, sec, targetIdx);
|
||
});
|
||
}
|
||
|
||
// Container-level drops handle cross-section drops onto empty space —
|
||
// the item appends to the target section's grid.
|
||
function wireContainer(container, sec) {
|
||
container.addEventListener("dragover", (e) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; });
|
||
container.addEventListener("drop", (e) => {
|
||
if (e.target !== container) return;
|
||
e.preventDefault();
|
||
const payload = tryParse(e.dataTransfer.getData("application/x-slot"));
|
||
if (!payload) return;
|
||
moveItem(payload.sec, payload.i, sec, state.sections[sec].length);
|
||
});
|
||
}
|
||
function tryParse(s) { try { return JSON.parse(s); } catch { return null; } }
|
||
function moveItem(fromSec, fromI, toSec, toIdx) {
|
||
const [m] = state.sections[fromSec].splice(fromI, 1);
|
||
if (fromSec === toSec && toIdx > fromI) toIdx--;
|
||
toIdx = Math.max(0, Math.min(state.sections[toSec].length, toIdx));
|
||
state.sections[toSec].splice(toIdx, 0, m);
|
||
persist(); render();
|
||
}
|
||
|
||
// Context menus. Right-click on an empty area shows the background menu;
|
||
// right-click on an icon/card shows the item menu (copy image, edit, …).
|
||
let ctxTarget = null;
|
||
function hideAllCtx() { $("ctxBg").classList.remove("on"); $("ctxItem").classList.remove("on"); ctxTarget = null; }
|
||
function positionMenu(menu, x, y) {
|
||
menu.classList.add("on");
|
||
const r = menu.getBoundingClientRect();
|
||
const nx = Math.min(x, window.innerWidth - r.width - 8);
|
||
const ny = Math.min(y, window.innerHeight - r.height - 8);
|
||
menu.style.left = nx + "px"; menu.style.top = ny + "px";
|
||
}
|
||
function openCtxBg(x, y) {
|
||
hideAllCtx();
|
||
$("ctxIconSizeVal").textContent = state.iconSize;
|
||
positionMenu($("ctxBg"), x, y);
|
||
}
|
||
function openCtxItem(x, y, sec, i) {
|
||
hideAllCtx();
|
||
ctxTarget = { sec, i };
|
||
positionMenu($("ctxItem"), x, y);
|
||
}
|
||
document.addEventListener("contextmenu", (e) => {
|
||
// Default: background menu. Items override this with their own handler.
|
||
e.preventDefault();
|
||
openCtxBg(e.clientX, e.clientY);
|
||
});
|
||
document.addEventListener("click", (e) => {
|
||
if (!e.target.closest(".ctx-menu")) hideAllCtx();
|
||
});
|
||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") { hideAllCtx(); closeModal(); closeBgModal(); } });
|
||
window.addEventListener("blur", hideAllCtx);
|
||
window.addEventListener("scroll", hideAllCtx, true);
|
||
|
||
$("ctxBg").addEventListener("click", async (e) => {
|
||
const btn = e.target.closest("button[data-action]"); if (!btn) return;
|
||
const a = btn.dataset.action;
|
||
hideAllCtx();
|
||
if (a === "change-bg") $("bgModal").classList.add("on");
|
||
else if (a === "icon-size") {
|
||
const next = state.iconSize === "small" ? "medium" : state.iconSize === "medium" ? "large" : "small";
|
||
state.iconSize = next; persist(); render();
|
||
toast("Icon size: " + next);
|
||
}
|
||
else if (a === "add-link") openModal("q", -1);
|
||
else if (a === "add-card") openModal("sm", -1);
|
||
else if (a === "reset") {
|
||
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(); render(); persist();
|
||
}
|
||
});
|
||
|
||
$("ctxItem").addEventListener("click", async (e) => {
|
||
const btn = e.target.closest("button[data-action]"); if (!btn) return;
|
||
if (!ctxTarget) { hideAllCtx(); return; }
|
||
const { sec, i } = ctxTarget;
|
||
const item = state.sections[sec][i]; if (!item) { hideAllCtx(); return; }
|
||
const a = btn.dataset.action;
|
||
hideAllCtx();
|
||
if (a === "open") {
|
||
if (window.home && window.home.navigate) window.home.navigate(item.url);
|
||
else window.location.href = item.url;
|
||
} else if (a === "open-new") {
|
||
if (window.home && window.home.openInNewTab) window.home.openInNewTab(item.url);
|
||
else window.open(item.url, "_blank");
|
||
} else if (a === "copy-image") {
|
||
await copyImage(item);
|
||
} else if (a === "copy-url") {
|
||
try { await navigator.clipboard.writeText(item.url); toast("Link copied"); }
|
||
catch { toast("Copy failed"); }
|
||
} else if (a === "edit") {
|
||
openModal(sec, i);
|
||
} else if (a === "remove") {
|
||
if (!confirm(`Remove "${item.title}"?`)) return;
|
||
state.sections[sec].splice(i, 1); persist(); render();
|
||
}
|
||
});
|
||
|
||
// Render the brand mark to a PNG blob and write it to the clipboard as an
|
||
// image. SVG strings get base64-encoded for the <img> source. {src: …}
|
||
// brands are loaded straight from the bundled file.
|
||
async function copyImage(item) {
|
||
try {
|
||
const b = (item.brand && BRAND[item.brand]) || null;
|
||
let srcUrl;
|
||
if (typeof b === "string") {
|
||
srcUrl = "data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(b)));
|
||
} else if (b && b.src) {
|
||
srcUrl = b.src;
|
||
} else {
|
||
// Letter tile — render a 128px square with the icon color + letter.
|
||
srcUrl = letterTileToDataURL(item);
|
||
}
|
||
const png = await imageToPngBlob(srcUrl, 256);
|
||
await navigator.clipboard.write([new ClipboardItem({ "image/png": png })]);
|
||
toast("Image copied");
|
||
} catch (err) {
|
||
console.error("copy image failed", err);
|
||
toast("Copy failed");
|
||
}
|
||
}
|
||
function imageToPngBlob(url, size) {
|
||
return new Promise((resolve, reject) => {
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = size; canvas.height = size;
|
||
const ctx = canvas.getContext("2d");
|
||
ctx.clearRect(0, 0, size, size);
|
||
ctx.drawImage(img, 0, 0, size, size);
|
||
canvas.toBlob((blob) => blob ? resolve(blob) : reject(new Error("toBlob null")), "image/png");
|
||
};
|
||
img.onerror = (e) => reject(e);
|
||
img.src = url;
|
||
});
|
||
}
|
||
function letterTileToDataURL(item) {
|
||
const size = 256;
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = size; canvas.height = size;
|
||
const ctx = canvas.getContext("2d");
|
||
const bg = item.color || "#d6ff3d";
|
||
const fg = isLightColor(bg) ? "#0b0e14" : "#ffffff";
|
||
const r = 48;
|
||
ctx.fillStyle = bg;
|
||
ctx.beginPath();
|
||
ctx.moveTo(r, 0); ctx.lineTo(size - r, 0); ctx.quadraticCurveTo(size, 0, size, r);
|
||
ctx.lineTo(size, size - r); ctx.quadraticCurveTo(size, size, size - r, size);
|
||
ctx.lineTo(r, size); ctx.quadraticCurveTo(0, size, 0, size - r);
|
||
ctx.lineTo(0, r); ctx.quadraticCurveTo(0, 0, r, 0); ctx.closePath(); ctx.fill();
|
||
ctx.fillStyle = fg;
|
||
ctx.font = "bold 140px system-ui, sans-serif";
|
||
ctx.textAlign = "center"; ctx.textBaseline = "middle";
|
||
ctx.fillText(iconLetter(item), size / 2, size / 2 + 10);
|
||
return canvas.toDataURL("image/png");
|
||
}
|
||
|
||
// Add/edit modal — the same shape works for both left slots and right cards.
|
||
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: "", sub: "", color: "#d6ff3d", letter: "" }
|
||
: state.sections[sec][i];
|
||
$("mTitle").value = c.title || "";
|
||
$("mUrl").value = c.url || "";
|
||
$("mSub").value = c.sub || "";
|
||
$("mSection").value = sec;
|
||
$("mColor").value = c.color || "#d6ff3d";
|
||
$("mLetter").value = c.letter || "";
|
||
$("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(); });
|
||
$("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,
|
||
sub: $("mSub").value.trim() || null,
|
||
color: $("mColor").value,
|
||
letter: $("mLetter").value.trim() || 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();
|
||
};
|
||
|
||
// Background chooser modal
|
||
function closeBgModal() { $("bgModal").classList.remove("on"); }
|
||
$("bgClose").onclick = closeBgModal;
|
||
$("bgModal").addEventListener("click", (e) => { if (e.target.id === "bgModal") closeBgModal(); });
|
||
document.querySelectorAll("#bgModal .bg-preset-row button").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 (slot-add button is rendered inside slotsList per render())
|
||
// Select-all on first click of the search field so a click lands with any
|
||
// existing query ready to replace. Second click (already-focused) falls
|
||
// through to native cursor placement.
|
||
(() => {
|
||
const q = $("q"); let needSelect = false;
|
||
q.addEventListener("mousedown", (e) => { needSelect = (document.activeElement !== e.target); });
|
||
q.addEventListener("mouseup", (e) => { if (needSelect) { e.preventDefault(); e.target.select(); needSelect = false; } });
|
||
q.addEventListener("focus", (e) => { if (!needSelect) e.target.select(); });
|
||
})();
|
||
$("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);
|
||
});
|
||
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(() => {});
|
||
}
|
||
|
||
// Container-level drop handlers accept cross-column drops onto empty space.
|
||
wireContainer($("slotsList"), "q");
|
||
wireContainer($("cardsGrid"), "sm");
|
||
|
||
// Initial load. If the stored state has x,y on q items (v4 free-drag),
|
||
// we just ignore them — their order in the array is their order on screen.
|
||
(async () => {
|
||
let loaded = null;
|
||
try { if (window.home && window.home.getCards) loaded = await window.home.getCards(); } catch {}
|
||
state = ensureShape(loaded) || defaultState();
|
||
applyBg(); render();
|
||
if (window.home && window.home.onCards) {
|
||
window.home.onCards((data) => {
|
||
const next = ensureShape(data);
|
||
if (next) { state = next; applyBg(); render(); }
|
||
});
|
||
}
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|