Initial commit

This commit is contained in:
nke4 committed 2026-10-23 18:54:25 +02:00
commit 7b51551856
27 files changed
+911

No files matched your search

+133
View File
@@ -0,0 +1,133 @@
const $ = (s, r = document) => r.querySelector(s);
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const safe = u => /^(https?:|mailto:)/i.test(u || '') ? esc(u) : '';
const host = u => { try { return new URL(u).host } catch { return '' } };
// light follows the cursor across a card's glass
document.addEventListener('pointermove', e => {
const el = e.target.closest?.('.shard'); if (!el) return;
const r = el.getBoundingClientRect();
el.style.setProperty('--mx', e.clientX - r.left + 'px');
el.style.setProperty('--my', e.clientY - r.top + 'px');
});
document.addEventListener('pointerout', e => {
const el = e.target.closest?.('.shard');
if (el && !el.contains(e.relatedTarget)) { el.style.removeProperty('--mx'); el.style.removeProperty('--my'); }
});
const load = src => fetch(src).then(r => r.json()).catch(() => []);
const empty = file => `<p class="empty">Nothing here yet. Add entries to <code>public/data/${file}.json</code>.</p>`;
// services: filter + reachability dots + admin section when signed in
const q = $('#q');
const watch = async card => {
const url = card.dataset.check, dot = document.createElement('span');
dot.className = 'dot'; card.prepend(dot);
if (location.protocol === 'https:' && url.startsWith('http:')) { dot.title = 'Cannot check http services from an https page'; return; }
const ctl = new AbortController(), timer = setTimeout(() => ctl.abort(), 4000);
try { await fetch(url, { mode: 'no-cors', signal: ctl.signal, cache: 'no-store' }); dot.dataset.s = 'up'; dot.title = 'Reachable'; }
catch { dot.dataset.s = 'down'; dot.title = 'Not reachable from this browser'; }
clearTimeout(timer);
};
if (q) {
document.addEventListener('keydown', e => {
if (e.key === '/' && !/INPUT|TEXTAREA/.test(document.activeElement.tagName)) { e.preventDefault(); q.focus(); }
if (e.key === 'Escape' && document.activeElement === q) { q.value = ''; q.dispatchEvent(new Event('input')); q.blur(); }
});
q.addEventListener('input', () => {
const t = q.value.trim().toLowerCase();
document.querySelectorAll('.shard').forEach(c => c.hidden = !c.textContent.toLowerCase().includes(t));
document.querySelectorAll('.group').forEach(g => g.hidden = !g.querySelector('.shard:not([hidden])'));
});
document.querySelectorAll('[data-check]').forEach(watch);
fetch('/api/session').then(r => r.json()).then(async s => {
if (!s.admin) return;
const r = await fetch('/api/admin/services'); if (!r.ok) return;
const items = await r.json(); if (!items.length) return;
const sec = document.createElement('section'); sec.className = 'group';
sec.innerHTML = `<h2>Admin only <small>signed in</small></h2><div class="grid">` + items.map(i => `
<a class="shard admin" data-check="${safe(i.url)}" href="${safe(i.url)}" target="_blank" rel="noopener">
<span class="badge">Admin only</span><h3>${esc(i.name)}</h3><p>${esc(i.desc)}</p><span class="host">${esc(host(i.url))}</span></a>`).join('') + '</div>';
$('main').append(sec); sec.querySelectorAll('[data-check]').forEach(watch);
if (q.value) q.dispatchEvent(new Event('input'));
}).catch(() => {});
}
// socials: rendered from JSON
const list = $('#list[data-src]');
if (list) load(list.dataset.src).then(items => {
if (!items.length) { list.innerHTML = empty('socials'); return; }
list.innerHTML = items.map(s => {
const link = safe(s.url), t = link ? 'a' : 'button', c = esc(s.color || '');
const attrs = link ? ` href="${link}" target="_blank" rel="noopener"` : ` type="button" data-copy="${esc(s.copy || s.handle)}"`;
return `<${t} class="shard social"${attrs} style="--brand:${c}">
<span class="ico" style="--i:url(/assets/icons/${esc(s.icon)}.svg)"></span>
<h3>${esc(s.name)}</h3><p>${esc(s.handle)}</p>
<span class="host">${link ? esc(host(s.url)) : 'Click to copy'}</span></${t}>`;
}).join('');
list.addEventListener('click', e => {
const b = e.target.closest('[data-copy]'); if (!b) return;
const h = $('.host', b), old = h.textContent;
navigator.clipboard?.writeText(b.dataset.copy).then(() => { h.textContent = 'Copied'; setTimeout(() => h.textContent = old, 1500); });
});
});
// contact: info list with copy buttons + form
const info = $('#info');
if (info) load(info.dataset.src).then(items => {
info.innerHTML = items.map(i => `<li><span>${esc(i.label)}</span>${safe(i.href) ? `<a href="${safe(i.href)}">${esc(i.value)}</a>` : esc(i.value)}
<button class="chip" type="button" data-copy="${esc(i.value)}">Copy</button></li>`).join('');
info.addEventListener('click', e => {
const b = e.target.closest('[data-copy]'); if (!b) return;
navigator.clipboard?.writeText(b.dataset.copy).then(() => { b.textContent = 'Copied'; setTimeout(() => b.textContent = 'Copy', 1400); });
});
});
const form = $('#form');
if (form) form.addEventListener('submit', async e => {
e.preventDefault();
const out = $('#out'), btn = $('.btn', form); out.removeAttribute('data-ok');
if (!form.checkValidity()) { out.textContent = 'Fill in name, a valid email and a message of at least 5 characters.'; return; }
btn.disabled = true; out.textContent = 'Sending...';
try {
const r = await fetch('/api/contact', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Object.fromEntries(new FormData(form))) });
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.error || 'Could not send. Try again later.');
form.reset(); out.dataset.ok = 1; out.textContent = 'Message sent.';
} catch (err) { out.textContent = err.message; }
btn.disabled = false;
});
// admin: sign in + contact requests
const adm = $('#admin');
if (adm) {
const post = (u, d) => fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(d || {}) });
const login = enabled => {
adm.innerHTML = enabled === false
? '<p class="empty">Admin is disabled. Set the <code>ADMIN_PASSWORD</code> environment variable and restart the server.</p>'
: `<form class="form login" id="lf"><label>Password <input type="password" name="password" autocomplete="current-password" required autofocus></label>
<button class="btn" type="submit">Sign in</button><p id="out" role="status"></p></form>`;
$('#lf')?.addEventListener('submit', async e => {
e.preventDefault(); const out = $('#out'); out.textContent = '';
const r = await post('/api/login', { password: e.target.password.value });
if (r.ok) return show();
out.textContent = (await r.json().catch(() => ({}))).error || 'Sign in failed.';
});
};
const show = async () => {
const s = await fetch('/api/session').then(r => r.json()).catch(() => ({}));
if (!s.admin) return login(s.enabled);
const r = await fetch('/api/admin/messages'), msgs = r.ok ? await r.json() : [];
adm.innerHTML = `<div class="adminbar"><h2>${msgs.length} message${msgs.length === 1 ? '' : 's'}</h2><button class="chip" id="lo" type="button">Sign out</button></div>` +
(msgs.length ? msgs.map(m => `<article class="msg" data-id="${esc(m.id || m.at)}">
<header><strong>${esc(m.name)}</strong><a href="mailto:${esc(m.email)}">${esc(m.email)}</a><time>${esc(new Date(m.at).toLocaleString())}</time></header>
<p>${esc(m.message)}</p>
<div class="tags"><a class="chip" href="mailto:${esc(m.email)}?subject=Re:%20your%20message%20to%20nkelabs">Reply</a><button class="chip" type="button" data-del>Delete</button></div>
</article>`).join('') : '<p class="empty">No messages yet.</p>');
};
adm.addEventListener('click', async e => {
if (e.target.closest('#lo')) { await post('/api/logout'); return login(true); }
const d = e.target.closest('[data-del]'); if (!d || !confirm('Delete this message?')) return;
await fetch('/api/admin/messages/' + encodeURIComponent(d.closest('.msg').dataset.id), { method: 'DELETE' }); show();
});
show();
}