const $ = (s, r = document) => r.querySelector(s);
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 => `
Nothing here yet. Add entries to public/data/${file}.json.
`;
// 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 = `Admin only signed in
';
$('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}">
${esc(s.name)}
${esc(s.handle)}
${link ? esc(host(s.url)) : 'Click to copy'}${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 => `${esc(i.label)}${safe(i.href) ? `${esc(i.value)}` : esc(i.value)}
`).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
? 'Admin is disabled. Set the ADMIN_PASSWORD environment variable and restart the server.
'
: ``;
$('#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 = `${msgs.length} message${msgs.length === 1 ? '' : 's'}
` +
(msgs.length ? msgs.map(m => `
${esc(m.message)}
`).join('') : 'No messages yet.
');
};
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();
}