Some checks failed
Two marks (M and R) side by side, EN + DE, with the site-header and real 16 px favicon context for each. Votes go to ops/poll/pollsvc.py, a stdlib Python service behind nginx at /api/poll/ (SQLite, systemd DynamicUser, rate-limited). Devices are counted once via a salted hash of IP + user agent; the privacy pages document it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG
57 lines
2.1 KiB
JavaScript
57 lines
2.1 KiB
JavaScript
// furtka.org logo poll — no cookies, no storage. The server tells us
|
||
// what this device already chose (salted hash of IP + user agent).
|
||
(function () {
|
||
var root = document.querySelector('.poll');
|
||
if (!root || !window.fetch) return;
|
||
var api = root.dataset.api;
|
||
var status = root.querySelector('.poll-status');
|
||
var t = status.dataset;
|
||
var buttons = root.querySelectorAll('[data-vote]');
|
||
var busy = false;
|
||
|
||
function pct(n, total) { return total ? Math.round((n / total) * 100) : 0; }
|
||
|
||
function render(d) {
|
||
var total = d.total || 0;
|
||
root.classList.toggle('poll--voted', !!d.yours);
|
||
Object.keys(d.counts).forEach(function (k) {
|
||
var card = root.querySelector('[data-choice="' + k + '"]');
|
||
if (!card) return;
|
||
var n = d.counts[k], p = pct(n, total);
|
||
card.classList.toggle('is-yours', d.yours === k);
|
||
card.querySelector('.poll-bar i').style.width = p + '%';
|
||
card.querySelector('[data-count]').textContent = d.yours ? p + ' % · ' + n : '';
|
||
var b = card.querySelector('[data-vote]');
|
||
b.disabled = d.yours === k;
|
||
b.textContent = d.yours === k ? t.tVoted : (d.yours ? t.tChange : b.dataset.label);
|
||
});
|
||
status.textContent = d.yours ? t.tTotal.replace('{n}', total) : '';
|
||
}
|
||
|
||
function load() {
|
||
fetch(api, { headers: { Accept: 'application/json' } })
|
||
.then(function (r) { if (!r.ok) throw r; return r.json(); })
|
||
.then(render)
|
||
.catch(function () { status.textContent = t.tError; });
|
||
}
|
||
|
||
buttons.forEach(function (b) {
|
||
b.dataset.label = b.textContent;
|
||
b.addEventListener('click', function () {
|
||
if (busy) return;
|
||
busy = true; b.disabled = true;
|
||
fetch(api, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||
body: JSON.stringify({ choice: b.dataset.vote })
|
||
})
|
||
.then(function (r) { if (!r.ok) throw r; return r.json(); })
|
||
.then(render)
|
||
.catch(function () { status.textContent = t.tError; b.disabled = false; })
|
||
.then(function () { busy = false; });
|
||
});
|
||
});
|
||
|
||
status.textContent = '';
|
||
load();
|
||
})();
|