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>
This commit is contained in:
@@ -37,19 +37,21 @@ const ui = {
|
||||
loginError: document.getElementById('login-error'),
|
||||
app: document.getElementById('app'),
|
||||
tabNotes: document.getElementById('tab-notes'),
|
||||
tabSearch: document.getElementById('tab-search'),
|
||||
tabSettings: document.getElementById('tab-settings'),
|
||||
viewNotes: document.getElementById('view-notes'),
|
||||
viewSearch: document.getElementById('view-search'),
|
||||
viewSettings: document.getElementById('view-settings'),
|
||||
searchForm: document.getElementById('search-form'),
|
||||
searchInput: document.getElementById('search-input'),
|
||||
searchStatus: document.getElementById('search-status'),
|
||||
searchResults: document.getElementById('search-results'),
|
||||
searchNote: document.getElementById('search-note'),
|
||||
searchNoteTitle: document.getElementById('search-note-title'),
|
||||
searchNoteBody: document.getElementById('search-note-body'),
|
||||
searchBack: document.getElementById('search-back'),
|
||||
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'),
|
||||
@@ -203,6 +205,18 @@ function setStatus(message, kind) {
|
||||
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.
|
||||
@@ -291,6 +305,7 @@ async function loadDay(date) {
|
||||
|
||||
describeLessons(info);
|
||||
ui.fill.hidden = !day.missing;
|
||||
markOpenRow();
|
||||
}
|
||||
|
||||
function describeLessons(info) {
|
||||
@@ -330,9 +345,12 @@ async function saveDay(automatic) {
|
||||
// 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);
|
||||
@@ -429,82 +447,112 @@ function addFact(term, value) {
|
||||
ui.serverState.append(dt, dd);
|
||||
}
|
||||
|
||||
// --- search --------------------------------------------------------------
|
||||
// --- the note list -------------------------------------------------------
|
||||
|
||||
/*
|
||||
* The notes themselves, read from disk by the server rather than from the
|
||||
* index. A lesson written this morning is findable this morning, which is most
|
||||
* of what anyone searches their own notes for — the MCP `search` tool is the
|
||||
* other half, spanning Schulcloud and the class register at the cost of being
|
||||
* only as fresh as the last crawl.
|
||||
* 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 runSearch(query) {
|
||||
window.clearTimeout(searchTimer);
|
||||
const value = query.trim();
|
||||
ui.searchNote.hidden = true;
|
||||
if (value.length < 2) {
|
||||
ui.searchResults.replaceChildren();
|
||||
ui.searchStatus.textContent = value ? 'Mindestens zwei Zeichen.' : '';
|
||||
return;
|
||||
}
|
||||
ui.searchStatus.textContent = 'Wird gesucht …';
|
||||
async function loadRail() {
|
||||
ui.railStatus.textContent = 'Wird geladen …';
|
||||
try {
|
||||
const result = await api('/api/notes/search?q=' + encodeURIComponent(value) + '&limit=60');
|
||||
searchTerms = value.split(/\s+/).filter(Boolean);
|
||||
showHits(result.hits);
|
||||
ui.searchStatus.textContent =
|
||||
result.count === 0
|
||||
? 'Nichts gefunden. Die Suche braucht jedes Wort — und kennt keine Wortformen.'
|
||||
: result.count + ' Treffer' + (result.count >= 60 ? ' (mehr vorhanden)' : '') + '.';
|
||||
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.searchResults.replaceChildren();
|
||||
ui.searchStatus.textContent = error.status ? error.message : 'Offline — die Suche braucht den Server.';
|
||||
ui.railList.replaceChildren();
|
||||
ui.railStatus.textContent = error.status ? error.message : 'Offline — die Liste braucht den Server.';
|
||||
}
|
||||
}
|
||||
|
||||
function showHits(hits) {
|
||||
/** 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 hit of hits) {
|
||||
const card = document.createElement('button');
|
||||
card.type = 'button';
|
||||
card.className = 'hit';
|
||||
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 head = document.createElement('div');
|
||||
head.className = 'hit-head';
|
||||
const subject = document.createElement('span');
|
||||
subject.className = 'hit-subject';
|
||||
// The lesson if there is one, else the note — never just "Freitag".
|
||||
subject.textContent = hit.heading || hit.subject || hit.title;
|
||||
const when = document.createElement('span');
|
||||
when.className = 'hit-date';
|
||||
when.textContent = hit.date ? germanDate(hit.date) : hit.path;
|
||||
head.append(subject, when);
|
||||
const title = document.createElement('div');
|
||||
title.className = 'row-title';
|
||||
if (row.mark) highlight(title, row.title);
|
||||
else title.textContent = row.title;
|
||||
item.append(title);
|
||||
|
||||
const snippet = document.createElement('p');
|
||||
snippet.className = 'hit-snippet';
|
||||
highlight(snippet, hit.snippet);
|
||||
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);
|
||||
}
|
||||
|
||||
card.append(head, snippet);
|
||||
card.addEventListener('click', () => openHit(hit));
|
||||
list.append(card);
|
||||
item.addEventListener('click', () => void openRow(row));
|
||||
list.append(item);
|
||||
}
|
||||
ui.searchResults.replaceChildren(list);
|
||||
ui.railList.replaceChildren(list);
|
||||
markOpenRow();
|
||||
}
|
||||
|
||||
/**
|
||||
* The matched words marked, without building HTML from them.
|
||||
*
|
||||
* A snippet is the user's own text, but it reaches here through a URL and a
|
||||
* JSON response, and `innerHTML` on anything that has been round-tripped is
|
||||
* how an editor ends up rendering what it should be showing.
|
||||
* 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);
|
||||
@@ -522,18 +570,18 @@ function highlight(target, text) {
|
||||
marks.sort((a, b) => a[0] - b[0]);
|
||||
|
||||
let cursor = 0;
|
||||
for (const [start, end] of marks) {
|
||||
if (start < cursor) continue;
|
||||
target.append(text.slice(cursor, start));
|
||||
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(start, end);
|
||||
mark.textContent = text.slice(from, to);
|
||||
target.append(mark);
|
||||
cursor = end;
|
||||
cursor = to;
|
||||
}
|
||||
target.append(text.slice(cursor));
|
||||
}
|
||||
|
||||
/** Lowercase without accents, the same folding the server searches with. */
|
||||
/** Lowercase without accents — the same folding the server searches with. */
|
||||
function fold(value) {
|
||||
return value.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
|
||||
}
|
||||
@@ -543,33 +591,103 @@ function germanDate(date) {
|
||||
return parts[2] + '.' + parts[1] + '.' + parts[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* A result, opened.
|
||||
*
|
||||
* A day note 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 openHit(hit) {
|
||||
const day = DAY_NOTE.exec(hit.path);
|
||||
if (day) {
|
||||
showTab('notes');
|
||||
await loadDay(day[1]);
|
||||
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.searchStatus.textContent = 'Wird geöffnet …';
|
||||
ui.railStatus.textContent = 'Wird gesucht …';
|
||||
try {
|
||||
const note = await api('/api/notes?path=' + encodeURIComponent(hit.path));
|
||||
ui.searchNoteTitle.textContent = note.title;
|
||||
// The note is Markdown from our own store, and markdownToHtml escapes
|
||||
// everything it did not produce itself — the same parser the editor
|
||||
// trusts with the same input.
|
||||
ui.searchNoteBody.innerHTML = markdownToHtml(note.text ?? '');
|
||||
ui.searchNote.hidden = false;
|
||||
ui.searchStatus.textContent = note.path;
|
||||
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.searchStatus.textContent = 'Konnte die Notiz nicht öffnen: ' + error.message;
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,16 +705,12 @@ function showApp() {
|
||||
}
|
||||
|
||||
function showTab(name) {
|
||||
for (const [tab, view, id] of [
|
||||
[ui.tabNotes, ui.viewNotes, 'notes'],
|
||||
[ui.tabSearch, ui.viewSearch, 'search'],
|
||||
[ui.tabSettings, ui.viewSettings, 'settings'],
|
||||
]) {
|
||||
view.hidden = name !== id;
|
||||
tab.setAttribute('aria-current', name === id ? 'page' : 'false');
|
||||
}
|
||||
if (name === 'settings') void loadSettings();
|
||||
if (name === 'search') ui.searchInput.focus();
|
||||
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 --------------------------------------------------------------
|
||||
@@ -609,6 +723,8 @@ ui.loginForm.addEventListener('submit', async (event) => {
|
||||
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.';
|
||||
@@ -622,7 +738,6 @@ ui.logout.addEventListener('click', async () => {
|
||||
});
|
||||
|
||||
ui.tabNotes.addEventListener('click', () => showTab('notes'));
|
||||
ui.tabSearch.addEventListener('click', () => showTab('search'));
|
||||
ui.tabSettings.addEventListener('click', () => showTab('settings'));
|
||||
|
||||
ui.searchForm.addEventListener('submit', (event) => {
|
||||
@@ -638,14 +753,18 @@ ui.searchInput.addEventListener('input', () => {
|
||||
searchTimer = window.setTimeout(() => void runSearch(value), SEARCH_DEBOUNCE_MS);
|
||||
});
|
||||
|
||||
ui.searchBack.addEventListener('click', () => {
|
||||
ui.searchNote.hidden = true;
|
||||
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.prev.addEventListener('click', () => void loadDay(shiftDate(day.date, -1)));
|
||||
ui.next.addEventListener('click', () => void loadDay(shiftDate(day.date, 1)));
|
||||
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 loadDay(ui.dayDate.value);
|
||||
if (ui.dayDate.value) void openDay(ui.dayDate.value);
|
||||
});
|
||||
|
||||
ui.save.addEventListener('click', () => void saveDay(false));
|
||||
@@ -708,4 +827,8 @@ void (async () => {
|
||||
}
|
||||
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();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user