// ───────────────────────────────────────────────────────────── // public/entrance.js — client-side behaviour for the entrance page. // Loaded via so CSP // can stay at `script-src 'self'` — no inline scripts. // ───────────────────────────────────────────────────────────── /* ───── Topographic currents ───── */ (function drawCurrents() { const wrap = document.getElementById('currents'); if (!wrap) return; const W = 1400, H = 1400, cx = W * 0.55, cy = H * 0.45; const svgNS = 'http://www.w3.org/2000/svg'; const svg = document.createElementNS(svgNS, 'svg'); svg.setAttribute('viewBox', `0 0 ${W} ${H}`); svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); const RINGS = 26, BASE_R = 90, STEP = 32, AMP = 26; for (let i = 0; i < RINGS; i++) { const r = BASE_R + i * STEP, segs = 260; const p1 = (i * 0.6) % (Math.PI * 2); const p2 = (i * 1.3 + 1.2) % (Math.PI * 2); const a1 = AMP * (0.9 + (i % 5) * 0.08); const a2 = AMP * 0.35; let d = ''; for (let s = 0; s <= segs; s++) { const t = (s / segs) * Math.PI * 2; const rr = r + a1 * Math.sin(t * 3 + p1 + i * 0.15) + a2 * Math.sin(t * 5 + p2 + i * 0.22) + AMP * 0.18 * Math.sin(t * 7 + i); const x = cx + Math.cos(t) * rr; const y = cy + Math.sin(t) * rr * 0.92; d += (s === 0 ? 'M' : 'L') + x.toFixed(1) + ' ' + y.toFixed(1); } d += ' Z'; const path = document.createElementNS(svgNS, 'path'); path.setAttribute('d', d); path.setAttribute('fill', 'none'); path.setAttribute('stroke', '#383831'); path.setAttribute('stroke-width', '1'); path.setAttribute('stroke-linejoin', 'round'); path.setAttribute('opacity', (i % 3 === 0 ? 0.095 : 0.055).toString()); svg.appendChild(path); } wrap.appendChild(svg); requestAnimationFrame(() => setTimeout(() => wrap.classList.add('is-ready'), 120)); })(); /* ───── Step transitions ───── */ const steps = { email: document.getElementById('step-email'), code: document.getElementById('step-code'), welcome: document.getElementById('step-welcome'), }; function showStep(name) { Object.entries(steps).forEach(([k, el]) => { el.classList.toggle('is-active', k === name); }); } /* ───── Step 1: email ───── */ const emailForm = document.getElementById('email-form'); const emailInput = document.getElementById('email-input'); const emailAck = document.getElementById('email-ack'); let rememberedEmail = ''; function setAck(el, text, isError) { el.textContent = text; el.classList.toggle('is-error', !!isError); el.classList.toggle('is-visible', !!text); } emailInput.addEventListener('input', () => { emailInput.classList.remove('is-error'); setAck(emailAck, '', false); }); emailForm.addEventListener('submit', async (e) => { e.preventDefault(); const email = emailInput.value.trim(); if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { emailInput.classList.add('is-error'); setAck(emailAck, 'Please enter a valid email address.', true); return; } emailInput.disabled = true; setAck(emailAck, 'Sending\u2026', false); try { const res = await fetch('/auth/request-code', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), }); if (res.status === 429) { emailInput.disabled = false; setAck(emailAck, 'Too many attempts. Try again in a little while.', true); return; } if (!res.ok) { emailInput.disabled = false; setAck(emailAck, 'Something went wrong. Try again.', true); return; } } catch (err) { emailInput.disabled = false; setAck(emailAck, 'Could not reach the archive. Retry?', true); return; } rememberedEmail = email; setAck(emailAck, '', false); showStep('code'); setTimeout(() => codeCells[0].focus(), 300); }); /* ───── Step 2: code ───── */ const codeCells = Array.from(document.querySelectorAll('.code-cell')); const codeForm = document.getElementById('code-form'); const codeAck = document.getElementById('code-ack'); function codeValue() { return codeCells.map(c => c.value).join(''); } codeCells.forEach((cell, i) => { cell.addEventListener('input', () => { cell.value = cell.value.replace(/\D/g, '').slice(-1); cell.classList.toggle('is-filled', !!cell.value); cell.classList.remove('is-error'); setAck(codeAck, '', false); if (cell.value && i < codeCells.length - 1) codeCells[i + 1].focus(); if (codeValue().length === 6) submitCode(); }); cell.addEventListener('keydown', (e) => { if (e.key === 'Backspace' && !cell.value && i > 0) { codeCells[i - 1].focus(); codeCells[i - 1].value = ''; codeCells[i - 1].classList.remove('is-filled'); e.preventDefault(); } else if (e.key === 'ArrowLeft' && i > 0) { codeCells[i - 1].focus(); e.preventDefault(); } else if (e.key === 'ArrowRight' && i < codeCells.length - 1) { codeCells[i + 1].focus(); e.preventDefault(); } }); cell.addEventListener('paste', (e) => { const text = (e.clipboardData || window.clipboardData).getData('text').replace(/\D/g, ''); if (!text) return; e.preventDefault(); for (let j = 0; j < codeCells.length; j++) { codeCells[j].value = text[j] || ''; codeCells[j].classList.toggle('is-filled', !!codeCells[j].value); } (codeCells[Math.min(text.length, codeCells.length - 1)] || codeCells[0]).focus(); if (codeValue().length === 6) submitCode(); }); }); codeForm.addEventListener('submit', (e) => { e.preventDefault(); submitCode(); }); let submitting = false; async function submitCode() { if (submitting) return; if (codeValue().length !== 6) return; submitting = true; codeCells.forEach(c => c.disabled = true); setAck(codeAck, 'Reading\u2026', false); try { const res = await fetch('/auth/verify-code', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: rememberedEmail, code: codeValue() }), }); if (res.ok) { setAck(codeAck, '', false); showStep('welcome'); return; } codeCells.forEach(c => { c.classList.add('is-error'); c.disabled = false; }); if (res.status === 429) { setAck(codeAck, 'Too many attempts. Request a new code.', true); } else { setAck(codeAck, 'That code doesn\u2019t match. Try again.', true); } submitting = false; } catch (err) { codeCells.forEach(c => c.disabled = false); setAck(codeAck, 'Could not reach the archive. Retry?', true); submitting = false; } } document.getElementById('use-different').addEventListener('click', () => { codeCells.forEach(c => { c.value = ''; c.classList.remove('is-filled', 'is-error'); c.disabled = false; }); setAck(codeAck, '', false); submitting = false; emailInput.disabled = false; emailInput.value = rememberedEmail; showStep('email'); setTimeout(() => emailInput.focus(), 300); }); /* ───── Step 3: welcome → timeline ───── */ document.getElementById('welcome-continue').addEventListener('click', () => { // Cross-document View Transitions animate this nav automatically on // supported browsers (Chrome/Safari). Firefox falls back to a plain nav. window.location.href = '/timeline'; }); /* ───── On-load routing ───── */ // `/` always serves this entrance shell. Decide which step to show based // on whether the visitor already has a valid session. (async function routeOnLoad() { let authed = false; try { const res = await fetch('/auth/me', { credentials: 'same-origin' }); authed = res.ok; } catch { /* offline — fall through to email */ } if (authed) { showStep('welcome'); } else { showStep('email'); setTimeout(() => emailInput.focus(), 300); } document.body.classList.remove('is-pending'); })();