201 lines
7.3 KiB
JavaScript
201 lines
7.3 KiB
JavaScript
// ─────────────────────────────────────────────────────────────
|
|
// public/entrance.js — client-side behaviour for the entrance page.
|
|
// Loaded via <script src="/entrance.js" defer></script> 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'),
|
|
};
|
|
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, 'Filed. Opening your archive\u2026', false);
|
|
setTimeout(() => { window.location.href = '/'; }, 500);
|
|
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);
|
|
});
|