Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
The release notes on GitHub are taken from this file: the section whose
heading is the version number.

## Unreleased

- Web UI: square sponsor ads show in the sidebar at their own proportions (capped height); wide banners move to a slim strip at the top of the main area (not in the sidebar). The brand logo stays at its full size on short screens.

## 2.5.7

- Web UI: the left sidebar now fits without scrolling, even at 1366×768: language and theme sit under the logo, Help sits beside Logout, and Ledger and Account moved into Settings as tabs. The ledger no longer shows device location (the API is unchanged). Old #v=racks and #v=ledger links open the Settings ledger tab.
Expand Down
20 changes: 13 additions & 7 deletions internal/web/static/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,9 @@ nav.dash button[aria-current="page"] .ni{opacity:1}
.foot{padding:4px 12px 10px;border-top:1px solid var(--side-line)}
.footrow{display:flex;align-items:center;gap:4px}
.footrow .linkbtn{flex:1;justify-content:center}
/* The sponsor slot of an unregistered installation: in the sidebar, above
the language and theme, one small banner at a time at the sidebar's
width (sponsor.js decides, and sets the height). */
/* Sidebar sponsor: square-ish ads (ratio < 3) at the image's own
proportions; height is set by sponsor.js, capped. Wide banners go to
.sponsor-top above the page header instead. */
.sponsor{border-top:1px solid var(--side-line);padding:8px 12px 4px}
.sponsor[hidden]{display:none}
.sponsor .sp-h{font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--ink-3);padding:0 0 3px}
Expand All @@ -125,14 +125,20 @@ nav.dash button[aria-current="page"] .ni{opacity:1}
.spc-strip{display:grid;border-radius:4px;overflow:hidden}
.spc-cell{position:relative;overflow:hidden;background:var(--surface)}
.spc-cell>a,.spc-cell>div{display:block;width:100%;height:100%}
.spc-cell img{width:100%;height:100%;object-fit:cover;display:block}
.spc-cell img{width:100%;height:100%;object-fit:contain;display:block}
.spc-dots{display:flex;gap:6px;justify-content:center;padding:4px 0 5px}
.spc-dot{width:6px;height:6px;padding:0;border-radius:50%;border:0;cursor:pointer;background:var(--line-2)}
.spc-dot.on{background:var(--accent)}
@media print{.sponsor{display:none}}
/* Top strip: wide banners (ratio >= 3), slim, centred, max ~728x90. */
.sponsor-top{display:flex;justify-content:center;padding:6px 16px;border-bottom:1px solid var(--line);background:var(--surface)}
.sponsor-top[hidden]{display:none}
.sponsor-top .spc{width:100%;max-width:728px}
.sponsor-top .spc-cell{background:transparent}
.sponsor-top .spc-dots{padding:3px 0 0}
@media print{.sponsor,.sponsor-top{display:none}}
/* shorter screens: tighten the sidebar so the menu still fits without scrolling */
@media (max-height:900px) and (min-width:821px){.brand{padding:8px 14px 6px}.brand .logo{max-height:52px}nav.dash button{padding:5px 12px;font-size:14px}nav.dash hr{margin:3px 8px}aside>.pick{padding:6px 12px}.foot{padding:2px 12px 6px}#logout,#help{padding:4px}.sponsor{padding:5px 12px 2px}}
@media (max-height:780px) and (min-width:821px){.brand .logo{max-height:40px}nav.dash{padding:4px 8px}nav.dash button{padding:3.5px 12px;font-size:13.5px}nav.dash hr{margin:2px 8px}.sponsor .sp-h{padding:0 0 1px}}
@media (max-height:900px) and (min-width:821px){.brand{padding:8px 14px 6px}nav.dash button{padding:5px 12px;font-size:14px}nav.dash hr{margin:3px 8px}aside>.pick{padding:6px 12px}.foot{padding:2px 12px 6px}#logout,#help{padding:4px}.sponsor{padding:5px 12px 2px}}
@media (max-height:780px) and (min-width:821px){nav.dash{padding:4px 8px}nav.dash button{padding:3.5px 12px;font-size:13.5px}nav.dash hr{margin:2px 8px}.sponsor .sp-h{padding:0 0 1px}}
aside>.pick{display:grid;grid-template-columns:1fr auto;gap:6px;padding:8px 12px;border-bottom:1px solid var(--side-line)}
#theme{display:flex;align-items:center;gap:6px;border:1px solid var(--line-2);border-radius:6px;padding:4px 10px;background:var(--surface);color:var(--ink);cursor:pointer;font-size:13px;white-space:nowrap}
#theme i{width:12px;height:12px;border-radius:50%;background:var(--accent);box-shadow:inset 0 0 0 2px var(--surface),0 0 0 1px var(--line-2)}
Expand Down
1 change: 1 addition & 0 deletions internal/web/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
</aside>

<div class="main">
<div class="sponsor-top" id="sponsorTop" hidden></div>
<header class="topbar">
<div class="row1">
<h1 id="title"></h1>
Expand Down
173 changes: 117 additions & 56 deletions internal/web/static/sponsor.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Remote sponsor banner for unregistered installations.
// Remote sponsor banners for unregistered installations.
//
// The slides are listed in a manifest in
// https://github.com/githubflyideas/ads-sponsor (folder traffic66/), so they
Expand All @@ -8,19 +8,24 @@
// shows a built-in text box instead; a broken image is never shown.
// Registered installations never call this.
//
// Classification is by each image's natural size (no manifest field):
// width/height >= WIDE_RATIO → slim banner at the top of the main area
// otherwise → square-ish ad in the sidebar
//
// Usage: t66Sponsor.load() t66Sponsor.remove()
(function () {
'use strict';
var URL_ = 'https://raw.githubusercontent.com/githubflyideas/ads-sponsor/main/traffic66/sponsors.json';
// MAX is how many advertisers are fetched and rotated through.
var TIMEOUT_MS = 3000, ROTATE_MS = 5500, MAX = 6;
// A small banner is 728x90 (1456x180 at 2x). The slot is in the
// sidebar, as wide as it is, so only small banners are shown there: an
// image LONG_RATIO times as wide as it is high or wider is a long banner,
// too thin at that width to read, and is left out.
var SMALL_W = 728, SMALL_H = 90, LONG_RATIO = 12;
// Images this many times as wide as they are high (or wider) are wide
// banners for the top strip; the rest are square-ish for the sidebar.
var WIDE_RATIO = 3;
// Sidebar: width fills the slot, height follows the image, capped.
var SIDE_MAX_H = 220;
// Top strip: classic 728x90 proportions, capped.
var TOP_MAX_W = 728, TOP_MAX_H = 90;
var CACHE_KEY = 't66.sponsor', CACHE_MS = 30 * 60 * 1000;
var started = false, timerId = null, roId = null;
var started = false, timers = [], ros = [];

function httpURL(v, base, httpsOnly) {
if (typeof v !== 'string' || !v.trim()) return null;
Expand All @@ -47,7 +52,7 @@
var img = new Image();
img.decoding = 'async';
img.referrerPolicy = 'no-referrer';
img.onload = function () { img.naturalWidth > 0 ? ok(img) : bad(); };
img.onload = function () { img.naturalWidth > 0 && img.naturalHeight > 0 ? ok(img) : bad(); };
img.onerror = bad;
img.src = src;
});
Expand Down Expand Up @@ -81,20 +86,6 @@
return m;
});
}
// resolves with the slides whose images loaded, rejects when none did
function ready(m, base) {
var want = slidesOf(m, base);
if (!want.length) return Promise.reject();
return settle(want.map(function (s) { return loadImg(s.src); })).then(function (imgs) {
var ok = [];
want.forEach(function (s, i) {
var im = imgs[i];
if (im && im.naturalWidth < LONG_RATIO * im.naturalHeight) { s.img = im; ok.push(s); }
});
if (!ok.length) throw 0;
return ok;
});
}

function slidesOf(m, base) {
var list = m && Array.isArray(m.slides) ? m.slides : [], out = [];
Expand All @@ -108,36 +99,53 @@
return out;
}

// The slot shows one advertiser at a time and rotates between them, at
// the sidebar's width and the banner's own proportions.
function build(slot, slides) {
var car = slot.querySelector('.spc');
car.hidden = false;
// Load images, drop broken ones, split into square (sidebar) and wide (top).
function ready(m, base) {
var want = slidesOf(m, base);
if (!want.length) return Promise.reject();
return settle(want.map(function (s) { return loadImg(s.src); })).then(function (imgs) {
var square = [], wide = [];
want.forEach(function (s, i) {
var im = imgs[i];
if (!im) return;
s.img = im;
if (im.naturalWidth >= WIDE_RATIO * im.naturalHeight) wide.push(s);
else square.push(s);
});
if (!square.length && !wide.length) throw 0;
return {square: square, wide: wide};
});
}

function slideEl(s) {
var a = document.createElement(s.link ? 'a' : 'div');
if (s.link) { a.href = s.link; a.target = '_blank'; a.rel = 'noopener noreferrer sponsored'; }
if (s.title || s.alt) a.title = s.title || s.alt;
var img = s.img.cloneNode(false);
img.alt = s.alt;
a.appendChild(img);
return a;
}

// One rotating carousel inside `car`. heightFn(carWidth, slide) → px height.
function carousel(car, slides, heightFn) {
var strip = document.createElement('div'); strip.className = 'spc-strip';
var dots = document.createElement('div'); dots.className = 'spc-dots';
var ds = [], cur = 0, paused = false;

function slideEl(s) {
var a = document.createElement(s.link ? 'a' : 'div');
if (s.link) { a.href = s.link; a.target = '_blank'; a.rel = 'noopener noreferrer sponsored'; }
if (s.title || s.alt) a.title = s.title || s.alt;
var img = s.img.cloneNode(false);
img.alt = s.alt;
a.appendChild(img);
return a;
}
function draw() {
var s = slides[cur];
var w = car.clientWidth || SMALL_W;
var w = car.clientWidth || TOP_MAX_W;
strip.textContent = '';
var box = document.createElement('div'); box.className = 'spc-cell';
box.style.height = Math.round(w * SMALL_H / SMALL_W) + 'px';
box.style.height = Math.max(1, Math.round(heightFn(w, s))) + 'px';
box.appendChild(slideEl(s));
strip.appendChild(box);
ds.forEach(function (d, i) { d.classList.toggle('on', i === cur); });
}
function show(i) { cur = ((i % slides.length) + slides.length) % slides.length; draw(); }

car.hidden = false;
car.textContent = '';
car.appendChild(strip);
if (slides.length > 1) {
Expand All @@ -151,35 +159,88 @@
car.appendChild(dots);
['mouseenter', 'focusin'].forEach(function (e) { car.addEventListener(e, function () { paused = true; }); });
['mouseleave', 'focusout'].forEach(function (e) { car.addEventListener(e, function () { paused = false; }); });
timerId = setInterval(function () {
timers.push(setInterval(function () {
if (!paused && !document.hidden) show(cur + 1);
}, ROTATE_MS);
}, ROTATE_MS));
}
slot.hidden = false;
show(0);
if (window.ResizeObserver) { roId = new ResizeObserver(draw); roId.observe(car); }
else window.addEventListener('resize', draw);
if (window.ResizeObserver) {
var ro = new ResizeObserver(draw);
ro.observe(car);
ros.push(ro);
} else {
window.addEventListener('resize', draw);
}
}

function sideHeight(w, s) {
var ratio = s.img.naturalHeight / s.img.naturalWidth;
return Math.min(SIDE_MAX_H, w * ratio);
}
function topHeight(w, s) {
var ratio = s.img.naturalHeight / s.img.naturalWidth;
return Math.min(TOP_MAX_H, w * ratio);
}

function showDefault(slot) {
if (!slot) return;
var t = slot.querySelector('.sp-text');
if (t) t.hidden = false;
var c = slot.querySelector('.spc');
if (c) { c.hidden = true; c.textContent = ''; }
slot.hidden = false;
}

function buildSidebar(slot, slides) {
if (!slot) return;
if (!slides.length) { showDefault(slot); return; }
var t = slot.querySelector('.sp-text');
if (t) t.hidden = true;
carousel(slot.querySelector('.spc'), slides, sideHeight);
slot.hidden = false;
}

function buildTop(slot, slides) {
if (!slot) return;
if (!slides.length) { slot.hidden = true; slot.textContent = ''; return; }
slot.textContent = '';
var wrap = document.createElement('div'); wrap.className = 'spc';
slot.appendChild(wrap);
carousel(wrap, slides, topHeight);
slot.hidden = false;
}

function clearTimers() {
timers.forEach(function (id) { clearInterval(id); });
timers = [];
ros.forEach(function (ro) { try { ro.disconnect(); } catch (e) {} });
ros = [];
}

window.t66Sponsor = {
load: function () {
var slot = document.getElementById('sponsor');
if (started || !slot || !window.URL || !window.Promise) return;
var side = document.getElementById('sponsor');
var top = document.getElementById('sponsorTop');
if (started || !side || !window.URL || !window.Promise) return;
started = true;
remote().then(function (m) { return ready(m, URL_); })
.then(function (ok) { if (started) build(slot, ok); },
function () {
if (!started) return;
slot.querySelector('.sp-text').hidden = false;
slot.hidden = false;
});
.then(function (parts) {
if (!started) return;
buildSidebar(side, parts.square);
buildTop(top, parts.wide);
}, function () {
if (!started) return;
showDefault(side);
if (top) top.hidden = true;
});
},
remove: function () {
started = false;
if (timerId) clearInterval(timerId);
if (roId) { roId.disconnect(); roId = null; }
var slot = document.getElementById('sponsor');
if (slot) slot.remove();
clearTimers();
var side = document.getElementById('sponsor');
if (side) side.remove();
var top = document.getElementById('sponsorTop');
if (top) top.remove();
}
};
})();
Loading