Theseus 0.3.92: home — hero returns above a slot column + 3-column card grid
Pulls the Theseus Navigator hero (compass + title + search + .bch tip) back to the top so identity + search stay the first thing on the page. Underneath, the two-column desktop keeps the shape from 0.3.91 but drops the second search bar: a narrow left column of stackable slot tiles (hello.bch, sia.coin, Pithos.sia, Bergamot.X, + add) and a 3-column grid of nine Silent Mode cards on the right. Right-click opens context menus — background edits (change bg, icon size, add link/card, reset) on empty space, and copy image / copy link / open / edit / remove on any icon or card. Copy image renders the brand mark to a 256px PNG and writes it with navigator.clipboard.write. Slot order is HTML5 drag-and-drop between either column; schema is v5 and passes through migrateCards unchanged.
This commit is contained in:
parent
8ac8b549b9
commit
092a779a6a
2 changed files with 422 additions and 369 deletions
789
home.html
789
home.html
|
|
@ -10,14 +10,11 @@
|
|||
--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 {
|
||||
display: flex; flex-direction: column;
|
||||
background: radial-gradient(1200px 600px at 50% -10%, var(--bg1), var(--bg2));
|
||||
}
|
||||
body { background: radial-gradient(1200px 600px at 50% -10%, var(--bg1), var(--bg2)); }
|
||||
|
||||
/* Hero (identity + search). Centered at the top of the page, same shape
|
||||
as the pre-desktop home — this is what the user sees most often. */
|
||||
.hero { text-align: center; padding: 5vh 1rem 0; }
|
||||
/* 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. */
|
||||
.hero { text-align: center; padding: 5vh 1rem 0; max-width: 760px; margin: 0 auto; }
|
||||
.hero .mark { display: inline-block; width: 64px; height: 64px; line-height: 1; }
|
||||
.hero .mark svg { width: 100%; height: 100%; display: block; }
|
||||
.hero h1 { font-size: 2.4rem; margin: .3rem 0 .2rem; letter-spacing: .5px; font-weight: 600; }
|
||||
|
|
@ -25,96 +22,68 @@
|
|||
.hero .tag { color: var(--mut); margin: 0 0 1.4rem; }
|
||||
.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); }
|
||||
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(--dim); font-size: 12.5px; margin-top: .8rem; }
|
||||
|
||||
.corner { position: absolute; top: 18px; right: 18px; z-index: 10; }
|
||||
.corner 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); }
|
||||
.corner button:hover { background: var(--card-hi); color: var(--ink); }
|
||||
.corner button.on { border-color: var(--acid); color: var(--acid); background: rgb(from var(--acid) r g b / .08); }
|
||||
/* Two-column desktop under the hero: narrow LEFT column of stackable icon
|
||||
slots, wider RIGHT column with a 3-column grid of Silent Mode cards. */
|
||||
.homepage { display: grid; grid-template-columns: minmax(96px, 110px) minmax(380px, 1fr);
|
||||
gap: 36px; max-width: 1180px; margin: 2.4rem auto 0; padding: 0 32px 48px; align-items: start; }
|
||||
@media (max-width: 760px) { .homepage { grid-template-columns: 1fr; gap: 20px; padding: 0 14px 32px; } }
|
||||
|
||||
/* Edit toolbar (shown only while editing) */
|
||||
.editbar { display: none; margin: 1.6rem auto .4rem; 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; width: fit-content; 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); }
|
||||
/* LEFT COLUMN — vertical stack of slots + an "add" button underneath */
|
||||
.left-column { display: flex; flex-direction: column; align-items: center; gap: 14px; }
|
||||
.slots-list { display: flex; flex-direction: column; gap: 14px; align-items: center; width: 100%; }
|
||||
|
||||
/* Two-column canvas under the hero: narrow LEFT for icon tiles,
|
||||
wider RIGHT for Silent Mode cards. Not full width — centered with
|
||||
breathing room on both sides. Collapses to one column under 820px. */
|
||||
.desktop { position: relative; width: 100%; max-width: 1180px; margin: 2.2rem auto 3rem; padding: 0 2rem;
|
||||
display: grid; grid-template-columns: minmax(180px, 220px) minmax(380px, 760px);
|
||||
gap: 44px; align-items: start; justify-content: center; }
|
||||
.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); }
|
||||
body.dragging-any .section-grid, body.dragging-any .cards-grid { outline: 1px dashed var(--line2); outline-offset: 4px; border-radius: 14px; }
|
||||
body.dragging-any .section-grid.drop-target, body.dragging-any .cards-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; padding: 0 1rem; } }
|
||||
|
||||
/* LEFT column — OS-desktop style: icons live at absolute x,y positions
|
||||
inside the column area so the user can drag them anywhere. The grid
|
||||
is a faint 20px snap grid that appears while dragging. Minimum height
|
||||
keeps the area tall enough that the first drag has somewhere to go. */
|
||||
.section-grid { position: relative; padding: 8px 4px; min-height: 420px; border-radius: 10px; }
|
||||
.section-grid::before {
|
||||
content: ""; position: absolute; inset: 0; opacity: 0;
|
||||
background-image: linear-gradient(to right, rgba(255,255,255,.04) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(255,255,255,.04) 1px, transparent 1px);
|
||||
background-size: 20px 20px; pointer-events: none; transition: opacity .15s;
|
||||
border-radius: 10px;
|
||||
}
|
||||
body.dragging-any .section-grid::before { opacity: 1; }
|
||||
.section-grid .icon { position: absolute; }
|
||||
|
||||
.icon { display: flex; flex-direction: column; align-items: center; position: relative;
|
||||
.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; }
|
||||
.icon:hover { transform: translateY(-2px); }
|
||||
.icon .tile { display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 18px; background: var(--iconBg, #0b0e14);
|
||||
.slot:hover { transform: translateY(-2px); }
|
||||
.slot .tile { display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 16px; 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%;
|
||||
.slot .tile svg, .slot .tile img { width: 72%; height: 72%; display: block; }
|
||||
.slot .label { margin-top: 6px; font-size: 11.5px; line-height: 1.2; color: #fff;
|
||||
text-shadow: 0 1px 3px #000c, 0 0 10px #000a; max-width: 100px;
|
||||
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: 72px; height: 72px; font-size: 28px; border-radius: 16px; }
|
||||
.icon.size-large .tile { width: 96px; height: 96px; font-size: 36px; border-radius: 20px; }
|
||||
.icon .label { max-width: 100px; }
|
||||
.icon.size-small .label { max-width: 72px; font-size: 11px; margin-top: 5px; }
|
||||
body.light .slot .label { color: #0b0e14; text-shadow: 0 1px 2px #fffb, 0 0 10px #fffc; }
|
||||
.slot.size-small .tile { width: 56px; height: 56px; font-size: 22px; border-radius: 12px; }
|
||||
.slot.size-medium .tile { width: 72px; height: 72px; font-size: 28px; border-radius: 14px; }
|
||||
.slot.size-large .tile { width: 88px; height: 88px; font-size: 34px; border-radius: 18px; }
|
||||
.slot.size-small .label { font-size: 10.5px; max-width: 72px; }
|
||||
.slot.dragging { opacity: .6; }
|
||||
.slot.drag-above::before, .slot.drag-below::after {
|
||||
content: ""; position: absolute; left: 50%; transform: translateX(-50%);
|
||||
width: 60%; height: 2px; background: var(--acid); border-radius: 2px;
|
||||
box-shadow: 0 0 8px var(--acid);
|
||||
}
|
||||
.slot.drag-above::before { top: -8px; }
|
||||
.slot.drag-below::after { bottom: -8px; }
|
||||
|
||||
/* Silent Mode CARDS — icon + title + sub + badge, one per row */
|
||||
.cards-grid { display: flex; flex-direction: column; gap: 10px; padding: 10px 0; min-height: 140px; }
|
||||
.card { display: flex; align-items: center; gap: 14px; padding: 14px 18px;
|
||||
.slot-add { display: flex; align-items: center; justify-content: center;
|
||||
width: 56px; height: 56px; border-radius: 12px; border: 2px dashed var(--line2);
|
||||
background: transparent; color: var(--mut); font-size: 22px; 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); }
|
||||
|
||||
/* 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: column; align-items: center; justify-content: center;
|
||||
gap: 10px; padding: 20px 14px; min-height: 128px;
|
||||
background: var(--card); border: 1px solid var(--line); border-radius: 14px;
|
||||
text-decoration: none; color: inherit; cursor: pointer;
|
||||
transition: transform .12s, background .12s, border-color .12s; position: relative; }
|
||||
text-decoration: none; color: inherit; cursor: pointer; position: relative;
|
||||
transition: transform .12s, background .12s, border-color .12s; }
|
||||
.card:hover { background: var(--card-hi); border-color: rgba(75,123,236,.4); transform: translateY(-1px); }
|
||||
.card .card-ico { flex: none; width: 44px; height: 44px; border-radius: 10px;
|
||||
background: var(--iconBg, transparent);
|
||||
|
|
@ -122,39 +91,42 @@
|
|||
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-body { flex: 1; min-width: 0; }
|
||||
.card .card-title { font-size: 15px; font-weight: 600; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.card .card-title { font-size: 14px; font-weight: 600; color: var(--ink); text-align: center;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
|
||||
.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 { color: var(--mut); font-size: 13px; margin-top: 3px; line-height: 1.4;
|
||||
overflow: hidden; display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; }
|
||||
.card:hover .card-sub { color: var(--ink); }
|
||||
.card .card-badge { flex: none; font-size: 10.5px; padding: 2px 9px; border-radius: 999px;
|
||||
letter-spacing: .02em; background: rgb(from var(--acid) r g b / .12); color: var(--acid); }
|
||||
.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; }
|
||||
.card-add { display: flex; align-items: center; justify-content: center; min-height: 128px;
|
||||
border: 2px dashed var(--line2); border-radius: 14px; background: transparent;
|
||||
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 / .04); }
|
||||
|
||||
.ctrls { position: absolute; top: -8px; right: -8px; display: none; gap: 2px; z-index: 2; }
|
||||
.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; }
|
||||
.ctrls button.rm:hover { background: var(--rose); border-color: var(--rose); }
|
||||
.ctrls button.ed:hover { background: var(--blue); border-color: var(--blue); }
|
||||
body.editing .icon .ctrls, body.editing .card .ctrls { display: flex; }
|
||||
body.editing .icon, body.editing .card { cursor: grab; }
|
||||
body.editing .icon.dragging, body.editing .card.dragging { opacity: .85; cursor: grabbing; }
|
||||
/* 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; }
|
||||
|
||||
.add-slot { display: none; align-items: center; justify-content: center;
|
||||
border: 2px dashed var(--line2); border-radius: 14px;
|
||||
color: var(--mut); font-size: 13.5px; cursor: pointer; }
|
||||
/* Left-column + slot is positioned at the bottom of the free-drag area */
|
||||
.column.left .add-slot { position: absolute; left: 10px; bottom: 10px;
|
||||
width: 60px; height: 60px; border-radius: 14px; font-size: 11px; }
|
||||
.column.right .add-slot { width: 100%; padding: 12px; border-radius: 14px; }
|
||||
.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; }
|
||||
.column.right .add-slot { display: none; }
|
||||
body.editing .column.right .add-slot { display: flex; }
|
||||
|
||||
/* Add/Edit modal */
|
||||
.modal-bg { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 100;
|
||||
/* 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;
|
||||
|
|
@ -171,21 +143,31 @@
|
|||
.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; }
|
||||
.icon .label { color: #0b0e14; text-shadow: 0 1px 2px #fffb, 0 0 10px #fffc; }
|
||||
.modal { background: #ffffff; }
|
||||
.slot .label { color: #0b0e14; text-shadow: 0 1px 2px #fffb, 0 0 10px #fffc; }
|
||||
.modal, .ctx-menu { background: #ffffff; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="corner">
|
||||
<button id="editBtn" type="button">Edit desktop</button>
|
||||
</div>
|
||||
|
||||
<div class="hero">
|
||||
<div class="mark">
|
||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
|
|
@ -203,53 +185,39 @@
|
|||
<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="editbar">
|
||||
<div class="group">
|
||||
<span class="label">Icon size</span>
|
||||
<div class="sz">
|
||||
<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 layout">Reset</button>
|
||||
</div>
|
||||
<div class="homepage">
|
||||
<div class="left-column">
|
||||
<div class="slots-list" id="slotsList"></div>
|
||||
<button class="slot-add" id="slotAdd" title="Add link">+</button>
|
||||
</div>
|
||||
<div class="right-column">
|
||||
<div class="cards-grid" id="cardsGrid"></div>
|
||||
</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="cards-grid" id="smGrid" data-sec="sm"></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">
|
||||
|
|
@ -257,28 +225,48 @@
|
|||
<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 in cards)</label><input id="mSub" placeholder="one-line description (optional)">
|
||||
<div class="row">
|
||||
<div>
|
||||
<label>Column</label>
|
||||
<select id="mSection">
|
||||
<option value="q">Your links (left)</option>
|
||||
<option value="sm">Silent Mode (right)</option>
|
||||
<option value="q">Left column (slots)</option>
|
||||
<option value="sm">Silent Mode (3-column grid)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Badge (for cards)</label>
|
||||
<input id="mBadge" placeholder="e.g. Navigator">
|
||||
<label>Icon color (fallback)</label>
|
||||
<input id="mColor" type="color" value="#d6ff3d">
|
||||
</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">
|
||||
<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, """);
|
||||
|
|
@ -287,6 +275,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.
|
||||
|
|
@ -306,37 +298,33 @@
|
|||
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>`,
|
||||
};
|
||||
|
||||
// v4 schema. Same shape as v3 (sections.sm + sections.q) with per-item
|
||||
// `sub` + `badge` fields on cards. Main.js's migrateCards preserves these
|
||||
// fields across v3 and v4 (it treats items as opaque objects).
|
||||
// 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: 4,
|
||||
iconSize: "large",
|
||||
quickSize: "medium",
|
||||
version: 5,
|
||||
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 own address.", badge: "Navigator" },
|
||||
{ title: "SilentMode.X", url: "https://silentmode.x/", brand: "silentmode", sub: "Infrastructure development for a decentralized web.", badge: "Infrastructure" },
|
||||
{ title: "Sirius.X", url: "https://sirius.x/", brand: "sirius", sub: "Register and manage BCDN names.", badge: "Registrar" },
|
||||
{ title: "Hephaestus.X", url: "https://hephaestus.x/", brand: "hephaestus", sub: "The forge — Silent Mode's code host.", badge: "Code host" },
|
||||
{ title: "Prometheus.X", url: "https://prometheus.x/", brand: "prometheus", sub: "Decentralized app marketplace.", badge: "App store" },
|
||||
{ title: "Hermes.X", url: "https://hermes.x/", brand: "hermes", sub: "Messaging — end-to-end encrypted over Nostr.", badge: "Messaging" },
|
||||
{ title: "Helios.X", url: "https://helios.x/", brand: "helios", sub: "Search engine for the decentralized web (in design).", badge: "Search" },
|
||||
{ title: "Ariadne.X", url: "https://ariadne.x/", brand: "ariadne", sub: "BNS resolver — read decentralized names natively.", badge: "Resolver" },
|
||||
{ title: "Aegis.X", url: "https://aegis.x/", brand: "aegis", sub: "Multi-chain wallet, derived from your Theseus vault.", badge: "Wallet" },
|
||||
{ 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" },
|
||||
],
|
||||
// Your-links icons are freely positioned (x,y per icon). The defaults
|
||||
// put them in a 2-column grid near the top-left; drag anywhere to
|
||||
// rearrange, like an OS desktop.
|
||||
q: [
|
||||
{ title: "hello.bch", url: "https://hello.bch/", brand: "hellobch", x: 10, y: 10 },
|
||||
{ title: "sia.coin", url: "https://sia.coin/", brand: "siacoin", x: 110, y: 10 },
|
||||
{ title: "Pithos.sia", url: "https://pithos.sia/", brand: "pithos", x: 10, y: 110 },
|
||||
{ title: "Bergamot.X", url: "https://bergamot.x/", brand: "bergamot", x: 110, y: 110 },
|
||||
{ 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" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -347,8 +335,7 @@
|
|||
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";
|
||||
if (!s.iconSize) s.iconSize = "medium";
|
||||
return s;
|
||||
}
|
||||
function persist() { if (window.home && window.home.setCards) window.home.setCards(state); }
|
||||
|
|
@ -368,7 +355,7 @@
|
|||
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));
|
||||
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) {
|
||||
|
|
@ -394,22 +381,13 @@
|
|||
function iconUsesBrand(icon) { return !!(icon.brand && BRAND[icon.brand]); }
|
||||
|
||||
function render() {
|
||||
// LEFT column — freely positioned icon tiles. x/y are stored per icon
|
||||
// and default to a tidy auto-layout if a legacy icon lacks them.
|
||||
const q = state.sections.q || [];
|
||||
const qGrid = $("qGrid");
|
||||
qGrid.innerHTML = "";
|
||||
$("qCount").textContent = `· ${q.length}`;
|
||||
const list = $("slotsList");
|
||||
list.innerHTML = "";
|
||||
q.forEach((ic, i) => {
|
||||
const size = ic.size || state.quickSize;
|
||||
const el = document.createElement("a");
|
||||
el.className = `icon size-${size}`;
|
||||
el.className = `slot size-${state.iconSize}`;
|
||||
el.href = ic.url; el.dataset.sec = "q"; el.dataset.i = i;
|
||||
// Icons without x/y (legacy data or just-added) get placed on an
|
||||
// implicit grid near the top so they don't pile on each other.
|
||||
const autoX = 10 + (i % 2) * 100, autoY = 10 + Math.floor(i / 2) * 100;
|
||||
el.style.left = (ic.x != null ? ic.x : autoX) + "px";
|
||||
el.style.top = (ic.y != null ? ic.y : autoY) + "px";
|
||||
if (!iconUsesBrand(ic)) {
|
||||
el.style.setProperty("--iconBg", ic.color || "#d6ff3d");
|
||||
el.style.setProperty("--iconFg", isLightColor(ic.color || "#d6ff3d") ? "#0b0e14" : "#ffffff");
|
||||
|
|
@ -417,27 +395,14 @@
|
|||
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>`;
|
||||
wireIconClicks(el, "q", i);
|
||||
el.addEventListener("pointerdown", (e) => startDrag(e, "q", i, el));
|
||||
qGrid.appendChild(el);
|
||||
el.innerHTML = `<div class="tile">${iconArt(ic)}</div><div class="label">${esc(ic.title || ic.url)}</div>`;
|
||||
wireItem(el, "q", i);
|
||||
list.appendChild(el);
|
||||
});
|
||||
const qAdd = document.createElement("button");
|
||||
qAdd.className = "add-slot"; qAdd.textContent = "+"; qAdd.title = "Add to Your links";
|
||||
qAdd.onclick = () => openModal("q", -1);
|
||||
qGrid.appendChild(qAdd);
|
||||
|
||||
// RIGHT column — Silent Mode cards
|
||||
const sm = state.sections.sm || [];
|
||||
const smGrid = $("smGrid");
|
||||
smGrid.innerHTML = "";
|
||||
$("smCount").textContent = `· ${sm.length}`;
|
||||
const grid = $("cardsGrid");
|
||||
grid.innerHTML = "";
|
||||
sm.forEach((ic, i) => {
|
||||
const el = document.createElement("a");
|
||||
el.className = "card";
|
||||
|
|
@ -446,135 +411,244 @@
|
|||
el.style.setProperty("--iconBg", ic.color || "#d6ff3d");
|
||||
el.style.setProperty("--iconFg", isLightColor(ic.color || "#d6ff3d") ? "#0b0e14" : "#ffffff");
|
||||
}
|
||||
const badge = ic.badge ? `<span class="card-badge">${esc(ic.badge)}</span>` : "";
|
||||
el.innerHTML = `
|
||||
<div class="ctrls">
|
||||
<button class="ed" title="Edit">✎</button>
|
||||
<button class="rm" title="Remove">×</button>
|
||||
</div>
|
||||
<div class="card-ico">${iconArt(ic)}</div>
|
||||
<div class="card-body">
|
||||
<div class="card-title">${styledTitle(ic.title)}</div>
|
||||
<div class="card-sub">${esc(ic.sub || ic.url)}</div>
|
||||
</div>
|
||||
${badge}`;
|
||||
wireIconClicks(el, "sm", i);
|
||||
el.addEventListener("pointerdown", (e) => startDrag(e, "sm", i, el));
|
||||
smGrid.appendChild(el);
|
||||
el.innerHTML = `<div class="card-ico">${iconArt(ic)}</div><div class="card-title">${styledTitle(ic.title)}</div>`;
|
||||
wireItem(el, "sm", i);
|
||||
grid.appendChild(el);
|
||||
});
|
||||
const smAdd = document.createElement("button");
|
||||
smAdd.className = "add-slot"; smAdd.textContent = "+ Add card to Silent Mode";
|
||||
smAdd.onclick = () => openModal("sm", -1);
|
||||
smGrid.appendChild(smAdd);
|
||||
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;
|
||||
}
|
||||
|
||||
function wireIconClicks(el, sec, i) {
|
||||
// Drag-reorder: left column is a vertical stack, right column is a 3-wide
|
||||
// grid. We use the HTML5 drag-and-drop API because it's simple and the
|
||||
// user explicitly asked for drag-and-drop. A tiny move-threshold would
|
||||
// require pointer events; keeping HTML5 DnD means plain clicks navigate.
|
||||
function wireItem(el, sec, i) {
|
||||
el.draggable = true;
|
||||
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();
|
||||
const url = state.sections[sec][i].url;
|
||||
if (window.home && window.home.navigate) window.home.navigate(url);
|
||||
else window.location.href = url;
|
||||
});
|
||||
}
|
||||
|
||||
// Drag model: LEFT column uses free (x,y) positioning like an OS desktop —
|
||||
// snap to 20px, clamp inside the column. RIGHT column (cards) stays
|
||||
// grid-ordered — drag inserts at the nearest Y-midpoint. Drop into the
|
||||
// other column re-parents (becomes free or inserted into the grid as
|
||||
// appropriate).
|
||||
const SNAP = 20;
|
||||
let drag = null;
|
||||
function startDrag(e, sec, i, el) {
|
||||
if (!document.body.classList.contains("editing")) return;
|
||||
if (e.target.closest(".ctrls")) return;
|
||||
e.preventDefault();
|
||||
const rect = el.getBoundingClientRect();
|
||||
drag = { sec, i, el, grabX: e.clientX - rect.left, grabY: e.clientY - rect.top };
|
||||
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") || target.closest(".cards-grid"));
|
||||
document.querySelectorAll(".drop-target").forEach((g) => { if (g !== over) g.classList.remove("drop-target"); });
|
||||
if (over) over.classList.add("drop-target");
|
||||
// Live move when dragging within the LEFT (free-positioning) column —
|
||||
// gives OS-desktop feel. Cards stay still until drop.
|
||||
if (drag.sec === "q" && over && over.id === "qGrid") {
|
||||
const gridRect = over.getBoundingClientRect();
|
||||
const w = drag.el.offsetWidth, h = drag.el.offsetHeight;
|
||||
const nx = Math.max(0, Math.min(gridRect.width - w, e.clientX - gridRect.left - drag.grabX));
|
||||
const ny = Math.max(0, Math.min(gridRect.height - h, e.clientY - gridRect.top - drag.grabY));
|
||||
drag.el.style.left = Math.round(nx / SNAP) * SNAP + "px";
|
||||
drag.el.style.top = Math.round(ny / SNAP) * SNAP + "px";
|
||||
}
|
||||
});
|
||||
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") || target.closest(".cards-grid"));
|
||||
const targetSec = targetGrid && targetGrid.dataset ? targetGrid.dataset.sec : null;
|
||||
if (targetSec && (targetSec === "sm" || targetSec === "q")) {
|
||||
const moved = state.sections[drag.sec][drag.i];
|
||||
if (targetSec === "q") {
|
||||
// LEFT drop — set the free x,y from where the pointer landed,
|
||||
// clamped inside the grid area. From the SAME column this just
|
||||
// keeps the x,y the live-move computed; from the OTHER column
|
||||
// (RIGHT → LEFT) we move the item over and seed its x,y.
|
||||
const gridRect = targetGrid.getBoundingClientRect();
|
||||
const w = el.offsetWidth, h = el.offsetHeight;
|
||||
const nx = Math.max(0, Math.min(gridRect.width - w, e.clientX - gridRect.left - drag.grabX));
|
||||
const ny = Math.max(0, Math.min(gridRect.height - h, e.clientY - gridRect.top - drag.grabY));
|
||||
moved.x = Math.round(nx / SNAP) * SNAP;
|
||||
moved.y = Math.round(ny / SNAP) * SNAP;
|
||||
if (targetSec !== drag.sec) {
|
||||
state.sections[drag.sec].splice(drag.i, 1);
|
||||
state.sections.q.push(moved);
|
||||
}
|
||||
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-above,.drag-below,.drag-before,.drag-after")
|
||||
.forEach((n) => n.classList.remove("drag-above", "drag-below", "drag-before", "drag-after"));
|
||||
});
|
||||
el.addEventListener("dragover", (e) => {
|
||||
e.preventDefault(); e.dataTransfer.dropEffect = "move";
|
||||
const r = el.getBoundingClientRect();
|
||||
// Vertical mid-split for slots, horizontal mid-split for cards.
|
||||
if (sec === "q") {
|
||||
const above = e.clientY < r.top + r.height / 2;
|
||||
el.classList.toggle("drag-above", above);
|
||||
el.classList.toggle("drag-below", !above);
|
||||
} else {
|
||||
// RIGHT drop — grid-ordered insert by Y midpoint of siblings.
|
||||
const siblings = [...targetGrid.querySelectorAll(".card")];
|
||||
let insertAt = siblings.length;
|
||||
for (let k = 0; k < siblings.length; k++) {
|
||||
const r = siblings[k].getBoundingClientRect();
|
||||
if (e.clientY < r.top + r.height / 2) { insertAt = k; break; }
|
||||
}
|
||||
const [m] = state.sections[drag.sec].splice(drag.i, 1);
|
||||
if (targetSec === drag.sec && insertAt > drag.i) insertAt--;
|
||||
// Moving a free-positioned icon into the right column drops its
|
||||
// x,y — they'd just be noise in the card list.
|
||||
delete m.x; delete m.y;
|
||||
state.sections.sm.splice(insertAt, 0, m);
|
||||
const before = e.clientX < r.left + r.width / 2;
|
||||
el.classList.toggle("drag-before", before);
|
||||
el.classList.toggle("drag-after", !before);
|
||||
}
|
||||
persist(); render();
|
||||
});
|
||||
el.addEventListener("dragleave", () => {
|
||||
el.classList.remove("drag-above", "drag-below", "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 = (sec === "q")
|
||||
? (e.clientY < r.top + r.height / 2 ? -1 : 1)
|
||||
: (e.clientX < r.left + r.width / 2 ? -1 : 1);
|
||||
let targetIdx = i + (side === 1 ? 1 : 0);
|
||||
moveItem(payload.sec, payload.i, sec, targetIdx);
|
||||
});
|
||||
}
|
||||
|
||||
// The slots-list and cards-grid themselves accept drops at the end — this
|
||||
// handles cross-column moves when the pointer lands on empty space.
|
||||
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();
|
||||
}
|
||||
document.querySelectorAll(".drop-target").forEach((g) => g.classList.remove("drop-target"));
|
||||
document.body.classList.remove("dragging-any");
|
||||
el.classList.remove("dragging");
|
||||
drag = null;
|
||||
});
|
||||
|
||||
// add/edit/delete
|
||||
$("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: "", badge: "", color: "#d6ff3d", letter: "" }
|
||||
const c = isNew ? { title: "", url: "", color: "#d6ff3d", letter: "" }
|
||||
: state.sections[sec][i];
|
||||
$("mTitle").value = c.title || "";
|
||||
$("mUrl").value = c.url || "";
|
||||
$("mSub").value = c.sub || "";
|
||||
$("mBadge").value = c.badge || "";
|
||||
$("mSection").value = sec;
|
||||
$("mColor").value = c.color || "#d6ff3d";
|
||||
$("mLetter").value = c.letter || "";
|
||||
|
|
@ -582,13 +656,8 @@
|
|||
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();
|
||||
|
|
@ -596,8 +665,6 @@
|
|||
if (!/^[a-z]+:\/\//i.test(url) && !/^[a-z]+:/.test(url)) url = "https://" + url;
|
||||
const next = {
|
||||
title, url,
|
||||
sub: $("mSub").value.trim() || null,
|
||||
badge: $("mBadge").value.trim() || null,
|
||||
color: $("mColor").value,
|
||||
letter: $("mLetter").value.trim() || null,
|
||||
};
|
||||
|
|
@ -612,28 +679,11 @@
|
|||
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 button").forEach((btn) => {
|
||||
btn.onclick = () => {
|
||||
state.quickSize = btn.dataset.size;
|
||||
document.querySelectorAll(".editbar .sz button").forEach((b) => b.classList.toggle("on", b === btn));
|
||||
persist(); render();
|
||||
};
|
||||
});
|
||||
document.querySelectorAll(".editbar .bg button[data-bg]").forEach((btn) => {
|
||||
// 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(); };
|
||||
|
|
@ -645,7 +695,9 @@
|
|||
r.readAsDataURL(f);
|
||||
};
|
||||
|
||||
// Search form — routes through main.js (user's default engine + locale).
|
||||
// Slot-add button (bottom of the left column) and search form
|
||||
$("slotAdd").onclick = (e) => { e.preventDefault(); openModal("q", -1); };
|
||||
$("slotAdd").oncontextmenu = (e) => e.preventDefault();
|
||||
$("searchForm").addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const q = $("q").value.trim(); if (!q) return;
|
||||
|
|
@ -659,20 +711,21 @@
|
|||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function applySizeButtons() {
|
||||
document.querySelectorAll(".editbar .sz button").forEach((b) => b.classList.toggle("on", b.dataset.size === state.quickSize));
|
||||
}
|
||||
// Container-level drop handlers accept cross-column drops onto empty space.
|
||||
wireContainer($("slotsList"), "q");
|
||||
wireContainer($("cardsGrid"), "sm");
|
||||
|
||||
// Initial load
|
||||
// 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(); applySizeButtons(); render();
|
||||
applyBg(); render();
|
||||
if (window.home && window.home.onCards) {
|
||||
window.home.onCards((data) => {
|
||||
const next = ensureShape(data);
|
||||
if (next) { state = next; applyBg(); applySizeButtons(); render(); }
|
||||
if (next) { state = next; applyBg(); render(); }
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "theseus-navigator",
|
||||
"version": "0.3.91",
|
||||
"version": "0.3.92",
|
||||
"license": "MPL-2.0",
|
||||
"description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
|
||||
"author": "Silent Mode",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue