Write the notes in an app, a school day at a time
The notes existed but there was nowhere to write them: a CLI command on a laptop, a tool call through Claude, or a file in a Docker volume. None of those is reachable from a phone in a lesson, which is where notes are actually taken. So: `/app`, served only when WEB_PASSWORD is set. A login, the day's notes, and a settings page for the Schulcloud token — the one surface here meant for a person rather than a program. The shape follows how the notes are written: one note per school day, one `##` heading per lesson, prose and lists and tables beneath. That turns out to be the design decision that matters, twice over. First, it is what lets WebUntis earn its keep. Opening a day with no note fills in that day's lessons — numbered, with times, teacher and room, cancellations dropped and substitutions marked. Retyping the timetable is exactly the work the second upstream exists to avoid, and "Stunden ergänzen" tops up a note started before the day ended without touching what is already written. Second, it changes how notes are indexed. A day note is indexed per lesson, not whole: search answers "my own note, Deutsch, 18.09.2026" rather than "my own note, Friday", and `list_notes subject=Deutsch` finds a day whose frontmatter names no subject at all. Indexed whole, every hit would read as a weekday and "what did we do in Deutsch" would match notes whose other five lessons were something else. `lessonHeading` and `subjectFromHeading` are a loop — the app writes the heading, the indexer reads the subject back out — and a test holds them to it. Notes taken in a lesson cannot be retaken, so the editor is built around not losing them: autosave, every keystroke mirrored to local storage, a save when the phone locks, and a fallback to the local copy when the request never arrives. A save that would overwrite a version the editor never saw is refused and the choice handed back — the notes folder is synced and open in more than one place, and a phone must not silently win over a laptop. `replaceNote` is separate from `writeNote` for that reason: never-overwrite is right for `add_note` and exactly wrong for an editor. WEB_PASSWORD is the first credential here a human types, so it is the first that can be guessed: scrypt at startup, never stored or compared in the clear, per-address rate limiting — which is not decoration, since the scrypt cost is itself a denial-of-service vector without it. The session is a signed HttpOnly SameSite=Strict cookie whose key is derived from the password, so changing it logs everyone out and there is no second secret to keep. It opens /api, because a session is the user, and never /mcp, because nothing in a browser speaks MCP. Also here, because the app made them matter: frontmatter now reads the indented `- item` list form editors write, so an Obsidian vault round-trips its tags; and a four-digit folder is a filing scheme, not a subject, so `2026/` does not file a school year under one. 357 tests; 106/107 smoke against the local instance, the one failure being the H5P service that instance does not run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
188
src/http/app/app.css
Normal file
188
src/http/app/app.css
Normal file
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* The app is used with one thumb, in a lesson, on a phone that may be at 10%.
|
||||
* Everything below follows from that: one column, large touch targets, the
|
||||
* editor taking every pixel that is not navigation, and no webfont — the CSP
|
||||
* forbids outside resources anyway, and a font that has not loaded is a blank
|
||||
* screen in a classroom with no signal.
|
||||
*/
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #ffffff;
|
||||
--fg: #1f2328;
|
||||
--muted: #656d76;
|
||||
--line: #d0d7de;
|
||||
--accent: #1f6feb;
|
||||
--ok: #1a7f37;
|
||||
--error: #cf222e;
|
||||
--warn: #9a6700;
|
||||
--card: #f6f8fa;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--fg: #e6edf3;
|
||||
--muted: #8b949e;
|
||||
--line: #30363d;
|
||||
--accent: #4493f8;
|
||||
--ok: #3fb950;
|
||||
--error: #f85149;
|
||||
--warn: #d29922;
|
||||
--card: #161b22;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
/* Fills the viewport on a phone, where 100vh lies about the toolbar. */
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||||
}
|
||||
|
||||
.screen { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
/* --- chrome ------------------------------------------------------------ */
|
||||
|
||||
header { border-bottom: 1px solid var(--line); }
|
||||
|
||||
.tabs { display: flex; }
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 0.9rem 0.5rem;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab[aria-current="page"] { color: var(--fg); border-bottom-color: var(--accent); }
|
||||
|
||||
.view { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 0.75rem; gap: 0.5rem; }
|
||||
|
||||
/* --- the day bar ------------------------------------------------------- */
|
||||
|
||||
.daybar { display: flex; align-items: center; gap: 0.5rem; }
|
||||
|
||||
.daybar button {
|
||||
flex: 0 0 auto;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--card);
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.daybar-centre { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 0.15rem; }
|
||||
.daybar-centre strong { font-size: 1.05rem; }
|
||||
.daybar-centre input { border: 0; background: none; color: var(--muted); font: inherit; font-size: 0.85rem; }
|
||||
|
||||
/* --- the editor -------------------------------------------------------- */
|
||||
|
||||
textarea {
|
||||
flex: 1;
|
||||
min-height: 12rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
/* Monospace: the notes are Markdown, and headings and list markers have to
|
||||
line up to be read back as structure. */
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.actions { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
||||
|
||||
button {
|
||||
padding: 0.65rem 1rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--card);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
#save, #login-form button, #token-form button {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* --- text -------------------------------------------------------------- */
|
||||
|
||||
.status { margin: 0; color: var(--muted); font-size: 0.85rem; min-height: 1.2em; }
|
||||
.hint { color: var(--muted); font-size: 0.85rem; }
|
||||
.ok { color: var(--ok); }
|
||||
.error { color: var(--error); margin: 0.5rem 0 0; }
|
||||
.warn { color: var(--warn); }
|
||||
|
||||
.conflict {
|
||||
margin: 0;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid var(--warn);
|
||||
border-radius: 0.5rem;
|
||||
color: var(--warn);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* --- cards (login, settings) ------------------------------------------- */
|
||||
|
||||
.card {
|
||||
margin: 0.75rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.card h1, .card h2 { margin-top: 0; font-size: 1.15rem; }
|
||||
|
||||
label { display: block; margin: 0.75rem 0 0.25rem; font-weight: 600; font-size: 0.9rem; }
|
||||
|
||||
input[type="password"], input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
#login { justify-content: center; }
|
||||
#login .card { width: min(24rem, 100%); align-self: center; }
|
||||
#login button { width: 100%; margin-top: 1rem; }
|
||||
|
||||
.steps { margin: 0.5rem 0; padding-left: 1.1rem; color: var(--muted); font-size: 0.85rem; line-height: 1.5; }
|
||||
.steps code { font-family: ui-monospace, monospace; }
|
||||
|
||||
#token-form button, #logout { margin-top: 0.75rem; }
|
||||
|
||||
dl { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: 0.35rem 0.75rem; font-size: 0.9rem; }
|
||||
dt { color: var(--muted); }
|
||||
dd { margin: 0; }
|
||||
490
src/http/app/app.js
Normal file
490
src/http/app/app.js
Normal file
@@ -0,0 +1,490 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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'),
|
||||
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'),
|
||||
editor: document.getElementById('editor'),
|
||||
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,
|
||||
};
|
||||
|
||||
// --- 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() {
|
||||
try {
|
||||
localStorage.setItem(draftKey(day.date), JSON.stringify({ text: ui.editor.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 : '');
|
||||
}
|
||||
|
||||
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;
|
||||
ui.editor.disabled = true;
|
||||
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) {
|
||||
ui.editor.value = '';
|
||||
ui.editor.disabled = true;
|
||||
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);
|
||||
ui.editor.disabled = false;
|
||||
ui.editor.value = 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() !== '';
|
||||
|
||||
ui.editor.value = useDraft ? draft.text : server;
|
||||
ui.editor.disabled = false;
|
||||
day.saved = info.exists ? info.text : '';
|
||||
day.dirty = ui.editor.value !== day.saved;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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() {
|
||||
day.dirty = ui.editor.value !== day.saved;
|
||||
saveDraft();
|
||||
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 = ui.editor.value;
|
||||
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 } : {}),
|
||||
});
|
||||
day.saved = text;
|
||||
day.modifiedAt = result.modifiedAt;
|
||||
day.dirty = false;
|
||||
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);
|
||||
}
|
||||
|
||||
// --- 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);
|
||||
} 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.prev.addEventListener('click', () => void loadDay(shiftDate(day.date, -1)));
|
||||
ui.next.addEventListener('click', () => void loadDay(shiftDate(day.date, 1)));
|
||||
ui.dayDate.addEventListener('change', () => {
|
||||
if (ui.dayDate.value) void loadDay(ui.dayDate.value);
|
||||
});
|
||||
|
||||
ui.editor.addEventListener('input', markDirty);
|
||||
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.
|
||||
const separator = ui.editor.value.trim() ? '\n\n' : '';
|
||||
ui.editor.value = ui.editor.value.replace(/\s*$/, '') + separator + day.missing;
|
||||
day.missing = '';
|
||||
ui.fill.hidden = true;
|
||||
markDirty();
|
||||
});
|
||||
|
||||
// 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);
|
||||
})();
|
||||
6
src/http/app/icon.svg
Normal file
6
src/http/app/icon.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Notizen">
|
||||
<rect width="64" height="64" rx="14" fill="#1f6feb"/>
|
||||
<g fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round">
|
||||
<path d="M18 20h28M18 32h28M18 44h18"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 277 B |
87
src/http/app/index.html
Normal file
87
src/http/app/index.html
Normal file
@@ -0,0 +1,87 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#1f6feb">
|
||||
<title>Schulcloud — Notizen</title>
|
||||
<link rel="icon" href="icon.svg" type="image/svg+xml">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<link rel="stylesheet" href="app.css">
|
||||
</head>
|
||||
<body>
|
||||
<noscript>Diese Seite braucht JavaScript.</noscript>
|
||||
|
||||
<!-- Login. Shown until /app/session says otherwise; everything else stays hidden. -->
|
||||
<section id="login" class="screen" hidden>
|
||||
<form id="login-form" class="card">
|
||||
<h1>Anmelden</h1>
|
||||
<label for="password">Passwort</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required autofocus>
|
||||
<button type="submit">Anmelden</button>
|
||||
<p id="login-error" class="error" role="alert" aria-live="assertive"></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div id="app" class="screen" hidden>
|
||||
<header>
|
||||
<nav class="tabs">
|
||||
<button type="button" id="tab-notes" class="tab" aria-current="page">Notizen</button>
|
||||
<button type="button" id="tab-settings" class="tab">Einstellungen</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- Notes: one school day per note, one heading per lesson. -->
|
||||
<main id="view-notes" class="view">
|
||||
<div class="daybar">
|
||||
<button type="button" id="prev" aria-label="Vorheriger Tag">‹</button>
|
||||
<div class="daybar-centre">
|
||||
<strong id="day-title">…</strong>
|
||||
<input id="day-date" type="date" aria-label="Datum">
|
||||
</div>
|
||||
<button type="button" id="next" aria-label="Nächster Tag">›</button>
|
||||
</div>
|
||||
|
||||
<p id="day-status" class="status" role="status" aria-live="polite"></p>
|
||||
<p id="day-conflict" class="conflict" role="alert" hidden></p>
|
||||
|
||||
<textarea id="editor" spellcheck="true" autocapitalize="sentences"
|
||||
placeholder="Noch nichts für diesen Tag." aria-label="Notizen des Tages"></textarea>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" id="save">Speichern</button>
|
||||
<button type="button" id="fill" hidden>Stunden ergänzen</button>
|
||||
<span id="lessons-hint" class="hint"></span>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Settings: the Schulcloud token, and what the server is doing. -->
|
||||
<main id="view-settings" class="view" hidden>
|
||||
<section class="card">
|
||||
<h2>Schulcloud-Token</h2>
|
||||
<p id="token-state" class="status">…</p>
|
||||
<ol class="steps">
|
||||
<li>In einem privaten Fenster bei der Schulcloud anmelden.</li>
|
||||
<li>DevTools → Application → Cookies → Wert des Cookies <code>jwt</code> kopieren.</li>
|
||||
<li>Hier einsetzen und speichern. Der Server prüft ihn erst bei der Schulcloud.</li>
|
||||
<li><strong>Das private Fenster schließen</strong> — offen gelassen meldet es den Token nach etwa zwei Stunden ab.</li>
|
||||
</ol>
|
||||
<form id="token-form">
|
||||
<label for="jwt">Neuer jwt-Cookie</label>
|
||||
<input id="jwt" type="password" autocomplete="off" spellcheck="false">
|
||||
<button type="submit">Token ersetzen</button>
|
||||
</form>
|
||||
<p id="token-result" class="status" role="status" aria-live="polite"></p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Server</h2>
|
||||
<dl id="server-state"></dl>
|
||||
<button type="button" id="logout">Abmelden</button>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
14
src/http/app/manifest.webmanifest
Normal file
14
src/http/app/manifest.webmanifest
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "Schulcloud Notizen",
|
||||
"short_name": "Notizen",
|
||||
"description": "Notizen zum Schultag, Stunde für Stunde.",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#1f6feb",
|
||||
"icons": [
|
||||
{ "src": "icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user