Files
Schulcloud-MCP/src/http/app/app.js
MechaCat02 48c6cf39d5 Lay the notes screen out like a notes app
The list of notes and the note being written are one screen now, two
panes: the notes on the left, the open one on the right, side by side
where there is room and one at a time on a phone, where the back button
returns to the list.

The search box moved into the top of that list, and its results *are*
the list — searching is a way of finding a note, not a separate place to
be, and a tab for it was a tab too many. Emptying the box brings the
whole list back. Opening a hit opens that day at the lesson that
matched, rather than at the top of a day with six of them.

A row has to say what the note holds, so the listing carries it: the
subjects a day covers, how many lessons, and the first line actually
written in it. One request for the whole list rather than one per note.

`plainText` is now one rule in one place for wherever a note is shown
rather than edited — the search snippet and the list row both went
through their own half-copy of it, and the row's copy rendered a table
as `| | |` and left `_Fazit_` wearing its markers. It strips one leading
marker, not each in turn, because `## 1. Deutsch` keeps its lesson
number and the list rule was eating it.

Driven in Firefox at both widths: the list, the search, opening a hit,
the jump to the lesson, and the phone's list-then-note. 390 unit tests,
116/117 smoke.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 18:22:47 +02:00

835 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createEditor } from './editor.js';
import { markdownToHtml } from './markdown.js';
/*
* The notes app.
*
* One school day is one note, one lesson is one `##` heading, and the server
* builds the headings from WebUntis — so opening the app during a free period
* gives you the day already laid out rather than an empty box. That shape is
* also what makes each lesson separately searchable afterwards, which is the
* whole reason the notes are worth writing here rather than in Notes.app.
*
* Three rules this file exists to honour:
*
* - **Never lose what was typed.** Every keystroke goes to localStorage, and a
* draft that is newer than the server's copy survives a dead connection, a
* locked phone and a closed tab. A note taken in a lesson cannot be retaken.
* - **Never silently overwrite.** Saves carry the modification time the editor
* loaded; the server refuses one that would clobber a version this editor
* never saw, and the banner then makes it the person's decision.
* - **Say what state it is in.** "Gespeichert 14:02", "Nicht gespeichert",
* "Offline — lokal gesichert". A silent editor over a flaky connection is
* indistinguishable from one that is losing your work.
*
* What the person sees is formatted text with a toolbar; what is written to
* disk is Markdown. `editor.js` is the whole of that translation — everything
* here deals in Markdown strings and never touches the document.
*/
const AUTOSAVE_MS = 2500;
const DRAFT_PREFIX = 'schulcloud-mcp/draft/';
const ui = {
login: document.getElementById('login'),
loginForm: document.getElementById('login-form'),
password: document.getElementById('password'),
loginError: document.getElementById('login-error'),
app: document.getElementById('app'),
tabNotes: document.getElementById('tab-notes'),
tabSettings: document.getElementById('tab-settings'),
viewNotes: document.getElementById('view-notes'),
viewSettings: document.getElementById('view-settings'),
searchForm: document.getElementById('search-form'),
searchInput: document.getElementById('search-input'),
railStatus: document.getElementById('rail-status'),
railList: document.getElementById('rail-list'),
today: document.getElementById('today'),
back: document.getElementById('back'),
daybar: document.querySelector('.daybar'),
actions: document.querySelector('.actions'),
notePreview: document.getElementById('note-preview'),
notePreviewTitle: document.getElementById('note-preview-title'),
notePreviewPath: document.getElementById('note-preview-path'),
notePreviewBody: document.getElementById('note-preview-body'),
prev: document.getElementById('prev'),
next: document.getElementById('next'),
dayTitle: document.getElementById('day-title'),
dayDate: document.getElementById('day-date'),
dayStatus: document.getElementById('day-status'),
conflict: document.getElementById('day-conflict'),
toolbar: document.getElementById('toolbar'),
editor: document.getElementById('editor'),
source: document.getElementById('source'),
editorHint: document.getElementById('editor-hint'),
save: document.getElementById('save'),
fill: document.getElementById('fill'),
lessonsHint: document.getElementById('lessons-hint'),
tokenState: document.getElementById('token-state'),
tokenForm: document.getElementById('token-form'),
jwt: document.getElementById('jwt'),
tokenResult: document.getElementById('token-result'),
serverState: document.getElementById('server-state'),
logout: document.getElementById('logout'),
};
/** Everything about the day currently open. */
const day = {
date: today(),
path: '',
/** The server's modification time for the loaded note, or null if there is none. */
modifiedAt: null,
/** The text as the server last confirmed it, to tell "dirty" from "saved". */
saved: '',
/** Headings the timetable has and the note does not. */
missing: '',
dirty: false,
conflicted: false,
timer: 0,
};
/**
* The formatted editor over the two elements that hold a note.
*
* It owns the document and the toolbar; this file only ever asks it for
* Markdown and hands it Markdown back.
*/
const editor = createEditor({
rich: ui.editor,
source: ui.source,
toolbar: ui.toolbar,
onInput: markDirty,
onModeChange: (mode) => {
// Switching by hand is not a warning, so the automatic one goes away.
hint(mode === 'source' ? 'Markdown-Ansicht. „MD" führt zurück.' : '');
},
});
function hint(message) {
ui.editorHint.textContent = message;
ui.editorHint.hidden = !message;
}
// --- plumbing ------------------------------------------------------------
async function api(path, options) {
const response = await fetch(path, {
credentials: 'same-origin',
...options,
headers: { accept: 'application/json', ...(options && options.headers) },
});
if (response.status === 401) {
showLogin();
throw new Error('unauthorized');
}
let body = null;
try {
body = await response.json();
} catch (error) {
body = null;
}
if (!response.ok) {
const failure = new Error((body && (body.message || body.error)) || 'HTTP ' + response.status);
failure.status = response.status;
failure.body = body;
throw failure;
}
return body;
}
function json(method, path, payload) {
return api(path, { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) });
}
function today() {
// The device's own date. The server keeps school dates in Europe/Berlin, but
// the phone in the lesson is in that timezone by definition.
const now = new Date();
return [now.getFullYear(), pad(now.getMonth() + 1), pad(now.getDate())].join('-');
}
function pad(value) {
return String(value).padStart(2, '0');
}
function shiftDate(date, days) {
// Noon, so a daylight-saving change cannot push the result onto the
// neighbouring day.
const at = new Date(date + 'T12:00:00');
at.setDate(at.getDate() + days);
return [at.getFullYear(), pad(at.getMonth() + 1), pad(at.getDate())].join('-');
}
function clock() {
const now = new Date();
return pad(now.getHours()) + ':' + pad(now.getMinutes());
}
// --- drafts: the safety net ---------------------------------------------
function draftKey(date) {
return DRAFT_PREFIX + date;
}
function saveDraft(text) {
try {
const value = text === undefined ? editor.getMarkdown() : text;
localStorage.setItem(draftKey(day.date), JSON.stringify({ text: value, at: Date.now() }));
} catch (error) {
// A full or disabled localStorage must not break typing; the server copy
// is still the real one.
}
}
function readDraft(date) {
try {
const raw = localStorage.getItem(draftKey(date));
return raw ? JSON.parse(raw) : null;
} catch (error) {
return null;
}
}
function clearDraft(date) {
try {
localStorage.removeItem(draftKey(date));
} catch (error) {
// Nothing to do: a stale draft is only ever offered, never forced.
}
}
// --- the day -------------------------------------------------------------
function setStatus(message, kind) {
ui.dayStatus.textContent = message;
ui.dayStatus.className = 'status' + (kind ? ' ' + kind : '');
}
/** A school day, in the editor, with the list showing which one. */
async function openDay(date) {
ui.notePreview.hidden = true;
showEditor(true);
await loadDay(date);
}
/** Where a day's note lives, which is also its id in the list. */
function dayPathFor(date) {
return date.slice(0, 4) + '/' + date + '.md';
}
async function loadDay(date) {
// Anything unsaved goes to the draft before the view moves, or switching
// days would be a way to lose a lesson.
if (day.dirty) saveDraft();
window.clearTimeout(day.timer);
day.date = date;
day.conflicted = false;
ui.conflict.hidden = true;
ui.dayDate.value = date;
editor.setEnabled(false);
hint('');
setStatus('Wird geladen …');
let info;
try {
info = await api('/api/notes/day?date=' + encodeURIComponent(date));
} catch (error) {
if (error.message === 'unauthorized') return;
ui.dayTitle.textContent = date;
// A reply with a status is the server saying no — most often that it keeps
// no notes at all — and reporting that as "offline" would send someone
// looking at their signal instead of at NOTES_DIR.
if (error.status) {
editor.setMarkdown('');
editor.setEnabled(false);
setStatus(error.message, 'error');
ui.lessonsHint.textContent = '';
return;
}
// No status: the request never arrived. Fall back to whatever this device
// has, rather than an empty editor that looks like a day with no notes.
const draft = readDraft(date);
editor.setEnabled(true);
editor.setMarkdown(draft ? draft.text : '');
day.saved = '';
day.modifiedAt = null;
day.dirty = Boolean(draft);
setStatus(
draft ? 'Offline — lokale Fassung, nicht gespeichert.' : 'Offline — keine Verbindung zum Server.',
'warn',
);
return;
}
day.path = info.path;
day.modifiedAt = info.modifiedAt;
day.missing = info.missing || '';
ui.dayTitle.textContent = info.title;
const server = info.exists ? info.text : info.skeleton;
const draft = readDraft(date);
// A draft only wins when it differs from what the server holds; otherwise it
// is just the last save echoed back and offering it would be noise.
const useDraft = draft && draft.text !== server && draft.text.trim() !== '';
editor.setEnabled(true);
// The server's text first, and what the editor makes of it is the baseline.
// Opening a note the editor would tidy — a table typed unevenly, `*` for
// italics — must not count as an edit, or simply looking at a day would
// rewrite the file.
const loaded = editor.setMarkdown(server);
day.saved = info.exists ? editor.getMarkdown() : '';
if (useDraft) editor.setMarkdown(draft.text);
day.dirty = editor.getMarkdown() !== day.saved;
// One note in a hundred: something the formatted view cannot hold without
// changing it. It opens as Markdown rather than being quietly reduced.
hint(
!loaded.faithful
? 'Diese Notiz enthält Formatierung, die die formatierte Ansicht nicht unverändert halten kann — deshalb Markdown.'
: editor.mode === 'source'
? 'Markdown-Ansicht. „MD" führt zurück.'
: '',
);
if (useDraft) {
setStatus('Lokale, noch nicht gespeicherte Fassung wiederhergestellt.', 'warn');
} else if (info.exists) {
setStatus('Gespeichert.');
} else if (info.skeleton) {
setStatus('Neuer Tag — Stunden aus WebUntis eingetragen.');
} else {
setStatus('Neuer Tag.');
}
describeLessons(info);
ui.fill.hidden = !day.missing;
markOpenRow();
}
function describeLessons(info) {
if (info.timetable === 'off') {
ui.lessonsHint.textContent = 'Ohne WebUntis-Schlüssel: Überschriften selbst anlegen.';
return;
}
if (info.timetable === 'unavailable') {
ui.lessonsHint.textContent = 'WebUntis nicht erreichbar — Stunden fehlen.';
return;
}
const count = (info.lessons || []).length;
ui.lessonsHint.textContent = count === 0 ? 'Kein Unterricht an diesem Tag.' : count + ' Stunde(n) laut Stundenplan.';
}
function markDirty() {
const text = editor.getMarkdown();
day.dirty = text !== day.saved;
saveDraft(text);
if (day.conflicted) return;
if (day.dirty) setStatus('Nicht gespeichert …');
window.clearTimeout(day.timer);
day.timer = window.setTimeout(() => void saveDay(true), AUTOSAVE_MS);
}
async function saveDay(automatic) {
window.clearTimeout(day.timer);
if (!day.dirty && automatic) return;
const text = editor.getMarkdown();
setStatus('Wird gespeichert …');
try {
const result = await json('PUT', '/api/notes/day', {
date: day.date,
text,
// Absent for a note that does not exist yet: there is nothing to clash
// with, and sending null would look like "I saw no version".
...(day.modifiedAt ? { expectedModifiedAt: day.modifiedAt } : {}),
});
const isNew = !day.modifiedAt;
day.saved = text;
day.modifiedAt = result.modifiedAt;
day.dirty = false;
// A day that had no note until now is not in the list yet.
if (isNew && !ui.searchInput.value.trim()) void loadRail();
day.conflicted = false;
ui.conflict.hidden = true;
clearDraft(day.date);
setStatus('Gespeichert ' + clock() + '.', 'ok');
} catch (error) {
if (error.message === 'unauthorized') return;
if (error.status === 409) {
// Stop autosaving: every further attempt would fail the same way, and
// the choice of which version wins is not ours to make.
day.conflicted = true;
ui.conflict.hidden = false;
ui.conflict.textContent =
'Diese Notiz wurde anderswo geändert, seit sie hier geöffnet wurde. ' +
'„Neu laden" verwirft, was hier steht; „Trotzdem speichern" überschreibt die andere Fassung. ' +
'Deine Fassung ist lokal gesichert.';
ensureConflictButtons();
setStatus('Nicht gespeichert — Konflikt.', 'error');
return;
}
if (error.status === 403) {
setStatus('Der Server nimmt keine Änderungen an (NOTES_READONLY).', 'error');
return;
}
setStatus('Nicht gespeichert — ' + error.message + '. Lokal gesichert.', 'error');
}
}
/** The two ways out of a conflict, added once and only when one happens. */
function ensureConflictButtons() {
if (document.getElementById('conflict-reload')) return;
const reload = document.createElement('button');
reload.id = 'conflict-reload';
reload.type = 'button';
reload.textContent = 'Neu laden';
reload.addEventListener('click', () => {
clearDraft(day.date);
void loadDay(day.date);
});
const force = document.createElement('button');
force.id = 'conflict-force';
force.type = 'button';
force.textContent = 'Trotzdem speichern';
force.addEventListener('click', () => {
day.modifiedAt = null;
day.conflicted = false;
ui.conflict.hidden = true;
void saveDay(false);
});
ui.conflict.append(document.createElement('br'), reload, document.createTextNode(' '), force);
}
// --- settings ------------------------------------------------------------
async function loadSettings() {
ui.tokenState.textContent = 'Wird geladen …';
try {
const info = await api('/api/token');
const budget = info.keepalive && info.keepalive.budgetSeconds;
ui.tokenState.textContent =
'Noch ' + info.daysLeft + ' Tag(e) gültig' +
(budget ? ', Sitzung noch ' + Math.round(budget / 60) + ' min' : '') +
' (' + info.source + ').';
ui.tokenState.className = 'status' + (info.daysLeft <= 3 ? ' warn' : '');
} catch (error) {
if (error.message === 'unauthorized') return;
ui.tokenState.textContent = 'Token-Status nicht lesbar: ' + error.message;
ui.tokenState.className = 'status error';
}
ui.serverState.replaceChildren();
try {
const status = await api('/api/status');
addFact('Index', status.crawlId ? 'Stand ' + status.crawlId + ', ' + status.nodes + ' Einträge' : 'leer');
addFact('Dateien', status.files + ' (' + status.extracted + ' mit Text)');
if (status.indexer && status.indexer.running) addFact('Gerade', 'Durchlauf läuft');
} catch (error) {
addFact('Index', 'nicht verfügbar');
}
try {
const notes = await api('/api/notes?limit=1');
addFact('Notizen', notes.count + ' · ' + notes.root + (notes.writable ? '' : ' (schreibgeschützt)'));
} catch (error) {
addFact('Notizen', 'nicht verfügbar');
}
}
function addFact(term, value) {
const dt = document.createElement('dt');
dt.textContent = term;
const dd = document.createElement('dd');
dd.textContent = value;
ui.serverState.append(dt, dd);
}
// --- the note list -------------------------------------------------------
/*
* The rail: every note, newest first, with the open one marked — and the
* search box at the top of it, because searching your notes is a way of
* finding one, not a separate place to be.
*
* Searching reads the **files** on the server rather than the Postgres index.
* Notes reach that index only on a full crawl, so a lesson written this
* morning would not be findable this morning, which is most of what anyone
* searches their own notes for. The `search` tool in Claude is the other half:
* it spans Schulcloud and the class register too, at the cost of being only as
* fresh as the last crawl.
*/
const SEARCH_DEBOUNCE_MS = 350;
/** Day notes live at `2026/2026-09-04.md`; anything else opens read-only. */
const DAY_NOTE = /^\d{4}\/(\d{4}-\d{2}-\d{2})\.md$/;
let searchTimer = 0;
let searchTerms = [];
async function loadRail() {
ui.railStatus.textContent = 'Wird geladen …';
try {
const listing = await api('/api/notes?limit=400');
searchTerms = [];
showRows(listing.notes.map(noteRow));
ui.railStatus.textContent =
listing.count === 0
? 'Noch keine Notizen. öffnet den heutigen Tag.'
: listing.count + ' Notiz(en)' + (listing.notes.length < listing.count ? ', neueste 400' : '');
} catch (error) {
if (error.message === 'unauthorized') return;
ui.railList.replaceChildren();
ui.railStatus.textContent = error.status ? error.message : 'Offline — die Liste braucht den Server.';
}
}
/** One note as a row: what it is, and the first thing written in it. */
function noteRow(note) {
const lessons = note.lessons > 0 ? note.lessons + ' Stunde' + (note.lessons === 1 ? '' : 'n') : '';
const subjects = (note.subjects || []).join(' · ');
return {
path: note.path,
date: note.date,
title: note.title,
// Subjects say more than the date repeated, and the preview says more
// than either when a note is a single page of prose.
line: subjects || lessons || note.preview || '',
second: subjects && note.preview ? note.preview : '',
};
}
/** One search hit as a row: the lesson it matched in, and why. */
function hitRow(hit) {
return {
path: hit.path,
date: hit.date,
heading: hit.heading,
title: hit.heading || hit.subject || hit.title,
line: hit.date ? germanDate(hit.date) : hit.path,
second: hit.snippet,
mark: true,
};
}
function showRows(rows) {
const list = document.createDocumentFragment();
for (const row of rows) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'row';
item.dataset.path = row.path;
if (row.heading) item.dataset.heading = row.heading;
const title = document.createElement('div');
title.className = 'row-title';
if (row.mark) highlight(title, row.title);
else title.textContent = row.title;
item.append(title);
for (const [text, clamp] of [
[row.line, false],
[row.second, true],
]) {
if (!text) continue;
const line = document.createElement('p');
line.className = 'row-line' + (clamp ? ' clamp' : '');
if (row.mark) highlight(line, text);
else line.textContent = text;
item.append(line);
}
item.addEventListener('click', () => void openRow(row));
list.append(item);
}
ui.railList.replaceChildren(list);
markOpenRow();
}
/**
* The matched words marked, without building HTML from them.
*
* A snippet is the user's own text, and text that has been through a URL and a
* JSON response is exactly what should not be handed to `innerHTML`.
*/
function highlight(target, text) {
const terms = searchTerms.map(fold).filter((term) => term.length > 1);
if (terms.length === 0) {
target.textContent = text;
return;
}
const folded = fold(text);
const marks = [];
for (const term of terms) {
for (let at = folded.indexOf(term); at !== -1; at = folded.indexOf(term, at + term.length)) {
marks.push([at, at + term.length]);
}
}
marks.sort((a, b) => a[0] - b[0]);
let cursor = 0;
for (const [from, to] of marks) {
if (from < cursor) continue;
target.append(text.slice(cursor, from));
const mark = document.createElement('mark');
mark.textContent = text.slice(from, to);
target.append(mark);
cursor = to;
}
target.append(text.slice(cursor));
}
/** Lowercase without accents — the same folding the server searches with. */
function fold(value) {
return value.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
}
function germanDate(date) {
const parts = date.split('-');
return parts[2] + '.' + parts[1] + '.' + parts[0];
}
async function runSearch(query) {
window.clearTimeout(searchTimer);
const value = query.trim();
if (value.length === 0) return loadRail();
if (value.length < 2) {
ui.railStatus.textContent = 'Mindestens zwei Zeichen.';
return;
}
ui.railStatus.textContent = 'Wird gesucht …';
try {
const result = await api('/api/notes/search?q=' + encodeURIComponent(value) + '&limit=100');
searchTerms = value.split(/\s+/).filter(Boolean);
showRows(result.hits.map(hitRow));
ui.railStatus.textContent =
result.count === 0
? 'Nichts gefunden — jedes Wort muss vorkommen.'
: result.count + ' Treffer' + (result.count >= 100 ? ' (mehr vorhanden)' : '');
} catch (error) {
if (error.message === 'unauthorized') return;
ui.railList.replaceChildren();
ui.railStatus.textContent = error.status ? error.message : 'Offline — die Suche braucht den Server.';
}
}
/**
* A row, opened.
*
* A school day opens in the editor, because that is where it is written.
* Anything else — an imported note, a page of revision — has no day to open, so
* it is shown read-only rather than forced into a day-shaped screen.
*/
async function openRow(row) {
const day = DAY_NOTE.exec(row.path);
showPane('note');
if (day) {
ui.notePreview.hidden = true;
showEditor(true);
await loadDay(day[1]);
// A search hit names a lesson, so put that lesson on screen rather than
// the top of a day with six of them.
if (row.heading) scrollToHeading(row.heading);
return;
}
await showNoteReadOnly(row.path);
}
function scrollToHeading(heading) {
const wanted = fold(heading).trim();
for (const element of ui.editor.querySelectorAll('h2')) {
if (fold(element.textContent).trim() === wanted) {
element.scrollIntoView({ block: 'start' });
return;
}
}
}
async function showNoteReadOnly(path) {
showEditor(false);
ui.notePreview.hidden = false;
ui.notePreviewTitle.textContent = '…';
try {
const note = await api('/api/notes?path=' + encodeURIComponent(path));
ui.notePreviewTitle.textContent = note.title;
ui.notePreviewPath.textContent = note.path + ' — schreibgeschützt, weil diese Notiz kein Schultag ist.';
// Markdown from our own store, through the parser the editor trusts with
// the same input: it escapes everything it did not produce itself.
ui.notePreviewBody.innerHTML = markdownToHtml(note.text ?? '');
day.path = note.path;
markOpenRow();
} catch (error) {
if (error.message === 'unauthorized') return;
ui.notePreviewTitle.textContent = 'Konnte die Notiz nicht öffnen';
ui.notePreviewPath.textContent = error.message;
}
}
/** The day editor and everything that belongs to it, on or off. */
function showEditor(on) {
for (const element of [ui.daybar, ui.dayStatus, ui.toolbar, ui.actions]) element.hidden = !on;
ui.editor.hidden = !on || editor.mode !== 'rich';
ui.source.hidden = !on || editor.mode !== 'source';
// The hint belongs to whatever is loaded next; leaving it visible and empty
// would cost a line of editor for nothing.
if (!on) {
ui.conflict.hidden = true;
ui.editorHint.hidden = true;
}
}
function showPane(pane) {
ui.viewNotes.dataset.pane = pane;
}
/** Marks the row whose note is open, whichever list is showing. */
function markOpenRow() {
for (const row of ui.railList.querySelectorAll('.row')) {
row.setAttribute('aria-current', String(row.dataset.path === day.path));
}
}
// --- views ---------------------------------------------------------------
function showLogin() {
ui.app.hidden = true;
ui.login.hidden = false;
ui.password.focus();
}
function showApp() {
ui.login.hidden = true;
ui.app.hidden = false;
}
function showTab(name) {
const notes = name !== 'settings';
ui.viewNotes.hidden = !notes;
ui.viewSettings.hidden = notes;
ui.tabNotes.setAttribute('aria-current', notes ? 'page' : 'false');
ui.tabSettings.setAttribute('aria-current', notes ? 'false' : 'page');
if (!notes) void loadSettings();
}
// --- wiring --------------------------------------------------------------
ui.loginForm.addEventListener('submit', async (event) => {
event.preventDefault();
ui.loginError.textContent = '';
try {
await json('POST', '/app/login', { password: ui.password.value });
ui.password.value = '';
showApp();
await loadDay(day.date);
if (window.matchMedia('(min-width: 721px)').matches) showPane('note');
await loadRail();
} catch (error) {
ui.loginError.textContent =
error.status === 429 ? 'Zu viele Versuche. ' + error.message : 'Passwort falsch.';
}
});
ui.logout.addEventListener('click', async () => {
// The draft stays: logging out is not the same as discarding a lesson.
await json('POST', '/app/logout', {}).catch(() => {});
showLogin();
});
ui.tabNotes.addEventListener('click', () => showTab('notes'));
ui.tabSettings.addEventListener('click', () => showTab('settings'));
ui.searchForm.addEventListener('submit', (event) => {
event.preventDefault();
void runSearch(ui.searchInput.value);
});
ui.searchInput.addEventListener('input', () => {
// As you type, but not on every keystroke: each search re-reads the notes
// directory on the server.
window.clearTimeout(searchTimer);
const value = ui.searchInput.value;
searchTimer = window.setTimeout(() => void runSearch(value), SEARCH_DEBOUNCE_MS);
});
ui.today.addEventListener('click', () => {
// The day you are in, whether or not it has a note yet — the one thing the
// list cannot show, because an empty day is not a note.
void openRow({ path: dayPathFor(today()) });
});
ui.back.addEventListener('click', () => showPane('list'));
ui.prev.addEventListener('click', () => void openDay(shiftDate(day.date, -1)));
ui.next.addEventListener('click', () => void openDay(shiftDate(day.date, 1)));
ui.dayDate.addEventListener('change', () => {
if (ui.dayDate.value) void openDay(ui.dayDate.value);
});
ui.save.addEventListener('click', () => void saveDay(false));
ui.fill.addEventListener('click', () => {
// Appended, never merged into place: the person's own text is not something
// to reorder, and a heading in the wrong order is trivial to move.
editor.append(day.missing);
day.missing = '';
ui.fill.hidden = true;
});
// A phone locking, the app going to the background, or the tab closing: all of
// them end the session without a "save" ever being pressed.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' && day.dirty) {
saveDraft();
if (!day.conflicted) void saveDay(true);
}
});
window.addEventListener('beforeunload', (event) => {
if (!day.dirty) return;
saveDraft();
event.preventDefault();
event.returnValue = '';
});
ui.tokenForm.addEventListener('submit', async (event) => {
event.preventDefault();
ui.tokenResult.textContent = 'Wird geprüft …';
ui.tokenResult.className = 'status';
try {
const result = await json('PUT', '/api/token', { jwt: ui.jwt.value });
ui.jwt.value = '';
ui.tokenResult.textContent = result.changed
? 'Ersetzt. Noch ' + result.daysLeft + ' Tag(e) gültig.' + (result.persisted ? '' : ' (Nicht dauerhaft gespeichert.)')
: 'Das ist der Token, der bereits benutzt wird.';
ui.tokenResult.className = 'status ok';
void loadSettings();
} catch (error) {
if (error.message === 'unauthorized') return;
ui.tokenResult.textContent = error.message;
ui.tokenResult.className = 'status error';
}
});
// --- start ---------------------------------------------------------------
void (async () => {
try {
const session = await api('/app/session');
if (!session.authenticated) {
showLogin();
return;
}
} catch (error) {
showLogin();
return;
}
showApp();
await loadDay(day.date);
// Wide enough for both panes: the day is already open beside the list.
// Narrow: the list comes first, the way a notes app opens.
if (window.matchMedia('(min-width: 721px)').matches) showPane('note');
await loadRail();
})();