// Bearer tokens stay in memory. The server's HttpOnly refresh cookie restores login.
const state = { token: null, user: null, selected: null, notes: [], total: 0, offset: 0, limit: 5, mode: 'login' };
const $ = id => document.getElementById(id);
let listSequence = 0;
let selectionSequence = 0;
let searchTimer;
let sessionGeneration = 0;
let refreshPromise = null;
function notice(message, error = false) {
$('notice').textContent = message;
$('notice').classList.toggle('error', error);
$('notice').hidden = !message;
}
// This one client helper handles JSON, bearer auth, errors, and empty 204 responses.
async function api(path, { method = 'GET', data, authenticated = true, retry = true } = {}) {
const token = state.token;
const generation = sessionGeneration;
// Cookie-using endpoints require this header; cross-origin HTML forms cannot set it.
const headers = { 'X-Notebook-Request': '1' };
if (data !== undefined) headers['Content-Type'] = 'application/json';
if (authenticated && token) headers.Authorization = `Bearer ${token}`;
let response;
try {
response = await fetch(path, { method, headers, credentials: 'same-origin', cache: 'no-store',
body: data === undefined ? undefined : JSON.stringify(data) });
} catch {
throw new Error('Could not reach the notebook. Check that the server is running and try again.');
}
const result = response.status === 204 ? null : await response.json();
// Ignore a response from a previous login rather than exposing it in another account.
if (authenticated && generation !== sessionGeneration) throw new DOMException('Login changed', 'AbortError');
// Share one refresh across concurrent requests, then retry the original request once.
if (response.status === 401 && authenticated && retry) {
try { await renewLogin(); }
catch (error) {
if (error.status === 401) {
leaveNotebook();
throw new Error('Your login has ended. Log in again to continue.');
}
throw error;
}
return api(path, { method, data, authenticated, retry: false });
}
if (response.status === 401 && authenticated) {
leaveNotebook();
throw new Error('Your login has ended. Log in again to continue.');
}
if (!response.ok) {
const wait = Number(response.headers.get('Retry-After')) || result.retry_after || 0;
const error = new Error((result.error || `Request failed (${response.status}).`) + (wait ? `. Try again in ${wait} seconds.` : ''));
error.status = response.status;
error.retryAfter = wait;
throw error;
}
return { data: result, headers: response.headers };
}
function renewLogin() {
if (!refreshPromise) {
const generation = sessionGeneration;
const previousUser = state.user?.id;
refreshPromise = api('/api/refresh', { method: 'POST', authenticated: false })
.then(({ data }) => {
if (generation !== sessionGeneration) throw new DOMException('Login changed', 'AbortError');
// Another tab may have logged into a different account using the shared cookie.
// Never retry an old account's pending write using that new account's identity.
if (previousUser && previousUser !== data.user.id) {
leaveNotebook();
throw new Error('This browser changed accounts. Reload to open the current notebook.');
}
state.token = data.access_token;
return data;
}).finally(() => { refreshPromise = null; });
}
return refreshPromise;
}
async function enterNotebook(login, message) {
sessionGeneration++;
state.token = login.access_token;
const { data: user } = await api('/api/me');
state.user = user;
$('auth-password').value = '';
$('username').textContent = user.id;
$('welcome').hidden = true;
$('workspace').hidden = false;
$('account').hidden = false;
showNote(null);
await loadNotes();
notice(message);
}
async function restoreNotebook() {
const buttons = [$('auth-submit'), $('login-mode'), $('signup-mode')];
buttons.forEach(button => { button.disabled = true; });
notice('Checking your login…');
try { await enterNotebook(await renewLogin(), 'Welcome back.'); }
catch (error) {
leaveNotebook();
// A new visitor simply has no refresh cookie; other failures deserve a visible message.
notice(error.status === 401 ? '' : error.message, error.status !== 401);
}
finally { buttons.forEach(button => { button.disabled = false; }); }
}
async function action(callback, button) {
if (button) { button.disabled = true; button.setAttribute('aria-busy', 'true'); }
try { await callback(); }
catch (error) { if (error.name !== 'AbortError') notice(error.message, true); }
finally { if (button) { button.disabled = false; button.removeAttribute('aria-busy'); } }
}
function authMode(mode) {
state.mode = mode;
const signup = mode === 'signup';
$('signup-mode').setAttribute('aria-pressed', String(signup));
$('login-mode').setAttribute('aria-pressed', String(!signup));
$('auth-title').textContent = signup ? 'Start your notebook' : 'Open your notebook';
$('auth-description').textContent = signup ? 'Choose a username and a long password. Your notes belong to you.' : 'Log in to pick up where you left off.';
$('auth-submit').textContent = signup ? 'Create account' : 'Log in';
$('auth-password').autocomplete = signup ? 'new-password' : 'current-password';
notice('');
}
function leaveNotebook() {
sessionGeneration++;
state.token = null;
state.user = null;
state.notes = [];
state.total = 0;
state.offset = 0;
listSequence++;
selectionSequence++;
$('note-list').replaceChildren();
$('search').value = '';
$('pinned-filter').checked = false;
showNote(null);
$('workspace').hidden = true;
$('account').hidden = true;
$('welcome').hidden = false;
$('username').textContent = '';
$('auth-password').value = '';
}
function dirty() {
const original = state.selected || { title: '', body: '', pinned: false };
return $('note-title').value !== original.title || $('note-body').value !== original.body || $('note-pinned').checked !== original.pinned;
}
function mayDiscard() {
return !dirty() || window.confirm('Discard your unsaved changes?');
}
function updateDraftState() {
$('character-count').textContent = `${$('note-body').value.length.toLocaleString()} / 10,000`;
$('save-state').textContent = dirty() ? 'Unsaved changes' : state.selected ? 'Saved' : 'Ready when you are';
}
function showNote(note) {
state.selected = note ? structuredClone(note) : null;
$('note-title').value = note?.title || '';
$('note-body').value = note?.body || '';
$('note-pinned').checked = note?.pinned || false;
$('editor-heading').textContent = note ? 'Edit note' : 'New note';
$('save-note').textContent = note ? 'Save changes' : 'Save note';
$('discard').textContent = note ? 'Discard changes' : 'Clear draft';
$('delete-note').hidden = !note;
$('note-date').textContent = note ? `Last saved ${new Date(note.updated_at).toLocaleString()}` : '';
updateDraftState();
}
function renderNotes() {
$('note-list').replaceChildren();
for (const note of state.notes) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'note-item';
button.setAttribute('aria-current', String(note.id === state.selected?.id));
const title = document.createElement('strong');
title.textContent = note.title;
const excerpt = document.createElement('span');
excerpt.className = 'excerpt';
excerpt.textContent = note.body.replace(/\s+/g, ' ').slice(0, 85) || 'Empty note';
const meta = document.createElement('span');
meta.className = 'note-meta';
meta.textContent = `${note.pinned ? 'Pinned · ' : ''}${new Date(note.updated_at).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}`;
button.append(title, excerpt, meta);
button.addEventListener('click', () => action(() => selectNote(note.id)));
$('note-list').append(button);
}
$('empty-list').hidden = state.total !== 0;
$('empty-list').textContent = $('search').value || $('pinned-filter').checked
? 'No notes match this filter. Try another search or turn off Pinned only.'
: 'Your first note starts in the editor. Give it a title and save it.';
$('list-summary').textContent = state.total ? `${state.offset + 1}–${Math.min(state.offset + state.limit, state.total)} of ${state.total} notes` : 'No notes yet';
$('pagination').hidden = state.total <= state.limit;
$('previous').disabled = state.offset === 0;
$('next').disabled = state.offset + state.limit >= state.total;
$('page-number').textContent = `Page ${Math.floor(state.offset / state.limit) + 1}`;
}
async function loadNotes() {
const sequence = ++listSequence;
const query = new URLSearchParams({ limit: state.limit, offset: state.offset });
const search = $('search').value.trim();
if (search) query.set('q', search);
if ($('pinned-filter').checked) query.set('pinned', 'true');
const { data } = await api(`/api/notes?${query}`);
if (sequence !== listSequence) return;
// If deletion emptied the last page, show the last remaining page instead.
if (data.total && state.offset >= data.total) {
state.offset = Math.floor((data.total - 1) / state.limit) * state.limit;
return loadNotes();
}
if (!data.total) state.offset = 0;
state.notes = data.items;
state.total = data.total;
renderNotes();
}
async function selectNote(id) {
if (id === state.selected?.id || !mayDiscard()) return;
const sequence = ++selectionSequence;
const { data } = await api(`/api/notes/${encodeURIComponent(id)}`);
if (sequence !== selectionSequence) return;
showNote(data);
renderNotes();
notice('');
if (window.matchMedia('(max-width: 760px)').matches) $('editor-heading').scrollIntoView({ block: 'start' });
}
$('login-mode').addEventListener('click', () => authMode('login'));
$('signup-mode').addEventListener('click', () => authMode('signup'));
$('auth-form').addEventListener('submit', event => {
event.preventDefault();
const credentials = { username: $('auth-username').value, password: $('auth-password').value };
const signup = state.mode === 'signup';
action(async () => {
if (signup) await api('/api/signup', { method: 'POST', data: credentials, authenticated: false });
const { data: login } = await api('/api/login', { method: 'POST', data: credentials, authenticated: false });
await enterNotebook(login, signup ? 'Your notebook is ready. Write your first note.' : 'Welcome back.');
$('note-title').focus();
}, $('auth-submit'));
});
$('note-form').addEventListener('input', updateDraftState);
$('note-form').addEventListener('submit', event => {
event.preventDefault();
const id = state.selected?.id;
const values = { title: $('note-title').value, body: $('note-body').value, pinned: $('note-pinned').checked };
action(async () => {
const { data } = await api(id ? `/api/notes/${encodeURIComponent(id)}` : '/api/notes', { method: id ? 'PATCH' : 'POST', data: values });
selectionSequence++;
showNote(data);
state.offset = 0;
await loadNotes();
notice('Note saved.');
}, $('save-note'));
});
$('new-note').addEventListener('click', () => {
if (!mayDiscard()) return;
selectionSequence++;
showNote(null);
renderNotes();
notice('');
$('note-title').focus();
});
$('discard').addEventListener('click', () => {
if (!mayDiscard()) return;
showNote(state.selected);
notice('');
});
$('delete-note').addEventListener('click', () => {
const id = state.selected?.id;
if (!id || !window.confirm('Delete this note? This cannot be undone.')) return;
action(async () => {
await api(`/api/notes/${encodeURIComponent(id)}`, { method: 'DELETE' });
selectionSequence++;
showNote(null);
await loadNotes();
notice('Note deleted.');
}, $('delete-note'));
});
$('search').addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => { state.offset = 0; action(loadNotes); }, 250);
});
$('pinned-filter').addEventListener('change', () => { state.offset = 0; action(loadNotes); });
$('previous').addEventListener('click', () => { state.offset = Math.max(0, state.offset - state.limit); action(loadNotes); });
$('next').addEventListener('click', () => { state.offset += state.limit; action(loadNotes); });
$('export').addEventListener('click', () => action(async () => {
const { data } = await api('/api/notes/export');
const url = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }));
const link = document.createElement('a');
link.href = url;
link.download = 'notes.json';
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
notice('Your notes have been exported.');
}, $('export')));
$('logout').addEventListener('click', () => action(async () => {
if (!mayDiscard()) return;
await api('/api/logout', { method: 'POST' });
leaveNotebook();
notice('Logged out.');
}, $('logout')));
// A harmless endpoint makes the request limit visible without exhausting login attempts.
$('rate-demo').addEventListener('click', () => action(async () => {
let accepted = 0;
let limited = 0;
let wait = 0;
for (let index = 0; index < 5; index++) {
try { await api('/api/rate-limit-demo', { authenticated: false }); accepted++; }
catch (error) {
if (error.status !== 429) throw error;
limited++;
wait = Math.max(wait, error.retryAfter);
}
}
$('rate-result').textContent = `${accepted} accepted; ${limited} limited (HTTP 429).${wait ? ` Try again in ${wait} seconds.` : ''}`;
}, $('rate-demo')));
// The browser sends the HttpOnly cookie automatically; JS never reads its value.
restoreNotebook();