Put the interface into English, and give the notebook one way in

The interface had drifted into two languages — the frame in English, everything
added later in German — which is worse than either, since the same idea appeared
under two names depending on when that part was written. Sorting by title still
uses the German collation, so Übung 2 comes before Übung 10: that is about the
notes, not the chrome.

Two archive folders were renamed with it (bilder/ → images/, zeichnungen/ →
drawings/). The importer reads both spellings, and both the old and new marker
line for a drawing — archives exported before this are on disk, and an import
that quietly dropped their drawings would be the worst kind of tidy-up.

Making a note, uploading a PDF and importing Markdown were three buttons side by
side, two of them icons you had to hover to identify. They are the same act —
putting something new in this notebook — so they are now one + menu.

The search box moves out of the sidebar and into the note list, labelled with the
notebook it narrows. Nothing changed about what it does: it has always searched
only the notebook you are in, but from the sidebar it read as searching
everything.

A separator no longer carries a sentence explaining the order the sort menu
already names. Renaming joins removing on the right-click menu — a left-click on
the label started an edit, so the row could not be touched without falling into
one.

218 unit tests, 238 browser checks across eleven suites. Two of those caught real
breakage from this change rather than needing a translation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-11 02:17:22 +02:00
1 parent 0a7cf26b28
commit 52bd2b8f3d
27 files changed
+469 -336

No files matched your search

+184 -78
View File
@@ -44,17 +44,20 @@ const MIDDLE_W = 132;
* Draggable like a note, because a divider that cannot move with what it
* divides is worse than none.
*
* Removed by right-click rather than by a × on the row. The × sat a few pixels
* from the label you click to rename, so the gesture that tidies the list and
* the gesture that destroys part of it were neighbours — and right-click is
* already how everything else in this list is acted on.
* Both renaming and removing are right-click, which is how everything else in
* this list is acted on. A left-click on the label started renaming, so the row
* could not be clicked — to select it, to drag from it — without falling into an
* edit; and the × for removal sat a few pixels from that same target.
*/
function SeparatorRow({ T, separator, onRename, onContextMenu }) {
const [editing, setEditing] = useState(false);
function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContextMenu }) {
const [draft, setDraft] = useState(separator.label);
useEffect(() => {
if (editing) setDraft(separator.label);
}, [editing, separator.label]);
const commit = () => {
setEditing(false);
onStartRename(null);
if (draft !== separator.label) onRename(separator.id, draft);
};
@@ -86,7 +89,7 @@ function SeparatorRow({ T, separator, onRename, onContextMenu }) {
if (e.key === "Enter") commit();
if (e.key === "Escape") {
setDraft(separator.label);
setEditing(false);
onStartRename(null);
}
}}
aria-label="Separator label"
@@ -109,12 +112,8 @@ function SeparatorRow({ T, separator, onRename, onContextMenu }) {
}}
/>
) : (
<button
onClick={() => {
setDraft(separator.label);
setEditing(true);
}}
title="Umbenennen"
<div
title="Right-click for options"
style={{
width: "100%",
border: "1px solid transparent",
@@ -125,15 +124,14 @@ function SeparatorRow({ T, separator, onRename, onContextMenu }) {
letterSpacing: 0.6,
textTransform: "uppercase",
textAlign: "center",
cursor: "text",
padding: "1px 4px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{separator.label || "Benennen"}
</button>
{separator.label || "Unnamed"}
</div>
)}
</div>
<div style={{ height: 1, background: T.border, flex: 1, minWidth: 8 }} />
@@ -221,6 +219,100 @@ function SortMenu({ T, mode, modes, onChange, disabled }) {
);
}
/**
* One way into the notebook.
*
* Making a note, importing a PDF and importing Markdown are the same act —
* putting something new in here — and they had grown into three buttons side by
* side, two of which were icons you had to hover to identify.
*/
function AddMenu({ T, onCreateNote, onImportPdf, onImportMarkdown }) {
const [open, setOpen] = useState(false);
const box = useRef(null);
useEffect(() => {
if (!open) return;
const close = (e) => {
if (!box.current?.contains(e.target)) setOpen(false);
};
document.addEventListener("pointerdown", close);
return () => document.removeEventListener("pointerdown", close);
}, [open]);
const item = (label, icon, action) => (
<button
data-add-option={label}
onClick={() => {
setOpen(false);
action();
}}
style={{
display: "flex",
alignItems: "center",
gap: 9,
width: "100%",
padding: "8px 10px",
borderRadius: 8,
border: "none",
background: "transparent",
color: T.text,
cursor: "pointer",
fontSize: 12.5,
textAlign: "left",
whiteSpace: "nowrap",
}}
>
<span style={{ display: "flex", color: T.muted }}>{icon}</span>
{label}
</button>
);
return (
<div style={{ position: "relative" }} ref={box}>
<button
onClick={() => setOpen((v) => !v)}
title="New Note"
data-add-menu={open ? "on" : "off"}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 32,
height: 32,
borderRadius: 10,
border: "none",
background: T.accent,
cursor: "pointer",
color: "#fff",
boxShadow: `0 2px 8px ${T.accent}44`,
}}
>
{I.plus}
</button>
{open && (
<div
style={{
position: "absolute",
top: 38,
right: 0,
minWidth: 190,
background: T.card,
border: `1px solid ${T.border}`,
borderRadius: 12,
padding: 6,
zIndex: 300,
boxShadow: "0 10px 26px rgba(0,0,0,.28)",
}}
>
{item("New note", I.plus, onCreateNote)}
{item("Upload PDF", I.pdf, onImportPdf)}
{item("Import Markdown", I.import, onImportMarkdown)}
</div>
)}
</div>
);
}
/** The middle column: every note in the selected folder, in the chosen order. */
export function NoteList({
T,
@@ -234,9 +326,13 @@ export function NoteList({
sortMode,
sortModes,
onSortModeChange,
query,
onQueryChange,
reorderable,
separators,
onAddSeparator,
renamingSeparator,
onStartRenameSeparator,
onRenameSeparator,
onSeparatorContextMenu,
dnd,
@@ -248,6 +344,10 @@ export function NoteList({
onImportMarkdown,
onEmptyTrash,
}) {
// Opened by the button and kept open while there is something typed in it, so
// a search never disappears because the box collapsed under it.
const [searching, setSearching] = useState(false);
return (
<div
style={{
@@ -307,39 +407,22 @@ export function NoteList({
{!isTrashView && <SortMenu T={T} mode={sortMode} modes={sortModes} onChange={onSortModeChange} />}
{!isTrashView && (
<button
onClick={onImportMarkdown}
title="Notizen importieren (Markdown oder ZIP)"
data-import-button
style={iconButton(T)}
onClick={() => setSearching((v) => !v)}
data-search-toggle={searching || query ? "on" : "off"}
title="Search this notebook"
aria-label="Search notes"
style={{ ...iconButton(T), color: searching || query ? T.accent : T.muted }}
>
{I.import}
{I.search}
</button>
)}
{!isTrashView && (
<button onClick={onImportPdf} title="Import PDF" style={iconButton(T)}>
{I.pdf}
</button>
)}
{!isTrashView && (
<button
onClick={onCreateNote}
title="New Note"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 32,
height: 32,
borderRadius: 10,
border: "none",
background: T.accent,
cursor: "pointer",
color: "#fff",
boxShadow: `0 2px 8px ${T.accent}44`,
}}
>
{I.plus}
</button>
<AddMenu
T={T}
onCreateNote={onCreateNote}
onImportPdf={onImportPdf}
onImportMarkdown={onImportMarkdown}
/>
)}
{isTrashView && trashedCount > 0 && (
<button
@@ -365,44 +448,65 @@ export function NoteList({
</div>
</div>
{/* Said once, where it matters: the arrangement is being edited, and the
search box would otherwise silently take that ability away. */}
{sortMode === "manual" && !isTrashView && (
{/* The search box lives with the list it narrows, not in the sidebar.
Searching has always been scoped to the notebook you are in; from the
sidebar it read as searching everything, which it never did. */}
{(searching || query) && !isTrashView && (
<div style={{ padding: "8px 12px", borderBottom: `1px solid ${T.borderLight}` }}>
<input
autoFocus
value={query}
onChange={(e) => onQueryChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
onQueryChange("");
setSearching(false);
}
}}
placeholder={`Search in ${title}...`}
aria-label="Search in this notebook"
style={{
width: "100%",
padding: "7px 10px",
borderRadius: 9,
border: `1px solid ${T.border}`,
background: T.inputBg,
color: T.text,
fontSize: 12.5,
outline: "none",
}}
/>
</div>
)}
{/* Just the one control. The sentence that used to sit here explained the
order the sort menu already names, on every visit, forever. */}
{reorderable && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "5px 10px 5px 16px",
fontSize: 10.5,
color: T.faint,
justifyContent: "flex-end",
padding: "5px 10px",
borderBottom: `1px solid ${T.borderLight}`,
}}
>
<span style={{ flex: 1 }}>
{reorderable
? "Eigene Reihenfolge — zum Umsortieren ziehen"
: "Eigene Reihenfolge — Suche aktiv, Ziehen pausiert"}
</span>
{reorderable && (
<button
onClick={onAddSeparator}
data-add-separator
title="Trennlinie einfügen"
style={{
border: `1px solid ${T.border}`,
background: "transparent",
color: T.muted,
borderRadius: 8,
padding: "2px 8px",
fontSize: 10.5,
cursor: "pointer",
whiteSpace: "nowrap",
}}
>
+ Trenner
</button>
)}
<button
onClick={onAddSeparator}
data-add-separator
title="Insert a separator line"
style={{
border: `1px solid ${T.border}`,
background: "transparent",
color: T.muted,
borderRadius: 8,
padding: "2px 8px",
fontSize: 10.5,
cursor: "pointer",
whiteSpace: "nowrap",
}}
>
+ Separator
</button>
</div>
)}
@@ -477,6 +581,8 @@ export function NoteList({
<SeparatorRow
T={T}
separator={entry.separator}
editing={renamingSeparator === entry.id}
onStartRename={onStartRenameSeparator}
onRename={onRenameSeparator}
onContextMenu={onSeparatorContextMenu}
/>
+5 -5
View File
@@ -17,11 +17,11 @@ import type { NoteMeta, Separator, SortMode } from "../../types";
export const DEFAULT_SORT: SortMode = "updated";
export const SORT_LABELS: Record<SortMode, string> = {
manual: "Eigene Reihenfolge",
updated: "Zuletzt geändert",
oldest: "Am ältesten",
created: "Zuletzt erstellt",
title: "Titel A–Z",
manual: "My own order",
updated: "Last changed",
oldest: "Oldest first",
created: "Newest created",
title: "Title A–Z",
};
export const SORT_MODES = Object.keys(SORT_LABELS) as SortMode[];
+4 -4
View File
@@ -107,7 +107,7 @@ function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) {
}
}}
aria-label="Pinned note"
placeholder="Notiz zu dieser Stelle…"
placeholder="Note on this passage…"
rows={4}
style={{
width: "100%",
@@ -136,7 +136,7 @@ function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) {
padding: 0,
}}
>
Entfernen
Remove
</button>
<span style={{ flex: 1 }} />
<button
@@ -155,7 +155,7 @@ function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) {
padding: "3px 10px",
}}
>
Fertig
Done
</button>
</div>
</>
@@ -173,7 +173,7 @@ function Card({ T, note, top, number, open, onOpen, onChange, onRemove }) {
wordBreak: "break-word",
}}
>
{note.text || "Leer — zum Schreiben antippen"}
{note.text || "Empty — tap to write"}
</div>
)}
</div>
+4 -4
View File
@@ -68,7 +68,7 @@ export function PdfNoteSheet({ T, note, number, onChange, onClose, onRemove }) {
>
{number}
</span>
<span style={{ fontSize: 11.5, color: T.muted, flex: 1 }}>Notiz zu dieser Stelle</span>
<span style={{ fontSize: 11.5, color: T.muted, flex: 1 }}>Note on this passage</span>
<button
onClick={onRemove}
data-pin-remove
@@ -81,7 +81,7 @@ export function PdfNoteSheet({ T, note, number, onChange, onClose, onRemove }) {
padding: 0,
}}
>
Entfernen
Remove
</button>
</div>
@@ -92,7 +92,7 @@ export function PdfNoteSheet({ T, note, number, onChange, onClose, onRemove }) {
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
aria-label="Pinned note"
placeholder="Notiz zu dieser Stelle…"
placeholder="Note on this passage…"
rows={3}
style={{
width: "100%",
@@ -124,7 +124,7 @@ export function PdfNoteSheet({ T, note, number, onChange, onClose, onRemove }) {
padding: "9px 0",
}}
>
Fertig
Done
</button>
</div>
);
+3 -3
View File
@@ -84,7 +84,7 @@ export function PdfNotesPanel({
<button
data-pdf-back-to-document
onClick={onBackToDocument}
title="Zurück zum Dokument"
title="Back to the document"
style={{
display: "flex",
alignItems: "center",
@@ -99,11 +99,11 @@ export function PdfNotesPanel({
padding: "3px 8px",
}}
>
{I.back} Dokument
{I.back} Document
</button>
) : (
<span style={{ display: "flex", alignItems: "center", gap: 5, paddingRight: 4, flexShrink: 0 }}>
{I.text} Notizen
{I.text} Notes
</span>
)}
{editor && <EditorToolbar editor={editor} T={T} icons={I} compact dark={dark} />}
+22 -22
View File
@@ -376,7 +376,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
/* ── load ── */
useEffect(() => {
if (!note?.pdfAttachmentId) {
setState({ status: "error", message: "Zu dieser Notiz gehört keine PDF-Datei." });
setState({ status: "error", message: "This note has no PDF attached." });
return;
}
let cancelled = false;
@@ -404,8 +404,8 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
status: "error",
message:
err?.message === "AUTH"
? "Sitzung abgelaufen — bitte neu anmelden."
: "Die Datei konnte nicht geöffnet werden. Offline und noch nie geladen?",
? "Session expired — please sign in again."
: "The file could not be opened. Offline and never loaded?",
});
}
});
@@ -575,18 +575,18 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
overflowX: "auto",
}}
>
<button onClick={() => goToPage(Math.max(1, pageNo - 1))} title="Vorherige Seite" style={barButton}>
<button onClick={() => goToPage(Math.max(1, pageNo - 1))} title="Previous page" style={barButton}>
↑
</button>
<button
onClick={() => goToPage(Math.min(state.pageCount || 1, pageNo + 1))}
title="Nächste Seite"
title="Next page"
style={barButton}
>
↓
</button>
<span data-pdf-position style={{ minWidth: 74 }}>
{state.status === "ready" ? `Seite ${pageNo} / ${state.pageCount}` : "Lädt…"}
{state.status === "ready" ? `Page ${pageNo} of ${state.pageCount}` : "Loading…"}
</span>
<div style={{ width: 1, height: 18, background: T.border, margin: "0 2px" }} />
@@ -598,7 +598,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
setDrawing((v) => !v);
setPlacing(false);
}}
title={drawing ? "Zeichnen beenden" : "Zeichnen"}
title={drawing ? "Stop drawing" : "Draw"}
style={{
...barButton,
width: "auto",
@@ -609,7 +609,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
fontWeight: 600,
}}
>
{I.pen} {drawing ? "Zeichnen" : "Lesen"}
{I.pen} {drawing ? "Drawing" : "Reading"}
</button>
{drawing && (
@@ -619,7 +619,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
key={t}
data-pdf-tool={t}
onClick={() => setTool(t)}
title={t === "pen" ? "Stift" : t === "highlighter" ? "Marker" : "Radierer"}
title={t === "pen" ? "Pen" : t === "highlighter" ? "Highlighter" : "Eraser"}
style={{ ...barButton, background: tool === t ? T.accent + "22" : "transparent" }}
>
{t === "pen" ? I.pen : t === "highlighter" ? I.highlight : I.eraser}
@@ -629,7 +629,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
<div style={{ position: "relative" }}>
<button
onClick={() => setShowPalette((v) => !v)}
title="Farbe"
title="Colour"
style={{ ...barButton, width: 22, height: 22, background: color, borderRadius: "50%" }}
/>
{showPalette && (
@@ -673,7 +673,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
<select
value={sizeIdx}
onChange={(e) => setSizeIdx(Number(e.target.value))}
title="Strichstärke"
title="Stroke width"
style={{
background: "transparent",
color: T.muted,
@@ -690,10 +690,10 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
))}
</select>
<button onClick={undo} title="Rückgängig" style={barButton}>
<button onClick={undo} title="Undo" style={barButton}>
{I.undo}
</button>
<button onClick={() => clearPage(pageNo)} title="Seite leeren" style={barButton}>
<button onClick={() => clearPage(pageNo)} title="Clear page" style={barButton}>
{I.trash}
</button>
</>
@@ -707,7 +707,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
setDrawing(false);
setOpenPin(null);
}}
title={placing ? "Stelle anklicken — oder abbrechen" : "Notiz an eine Stelle heften"}
title={placing ? "Click the passage — or cancel" : "Pin a note to a passage"}
style={{
...barButton,
width: "auto",
@@ -718,17 +718,17 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
fontWeight: 600,
}}
>
{I.pin} {placing ? "Stelle wählen" : "Randnotiz"}
{I.pin} {placing ? "Pick a spot" : "Margin note"}
</button>
{notesToggle}
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 6 }}>
<button onClick={() => stepZoom(-1)} title="Kleiner" style={barButton}>
<button onClick={() => stepZoom(-1)} title="Zoom out" style={barButton}>
−
</button>
<span style={{ minWidth: 42, textAlign: "center" }}>{Math.round(zoom * 100)}%</span>
<button onClick={() => stepZoom(1)} title="Größer" style={barButton}>
<button onClick={() => stepZoom(1)} title="Zoom in" style={barButton}>
+
</button>
@@ -745,10 +745,10 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
<button
data-pdf-hide-ink={hideInk ? "on" : "off"}
onClick={() => setHideInk((v) => !v)}
title={hideInk ? "Zeichnung einblenden" : "Zeichnung ausblenden"}
title={hideInk ? "Show drawing" : "Hide drawing"}
style={visibilityButton(T, !hideInk)}
>
{hideInk ? I.eyeOff : I.eye} Zeichnung
{hideInk ? I.eyeOff : I.eye} Drawing
</button>
)}
{totalPinnedNotes(ink) > 0 && (
@@ -758,10 +758,10 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
setShowPins((v) => !v);
setOpenPin(null);
}}
title={showPins ? "Randnotizen ausblenden" : "Randnotizen einblenden"}
title={showPins ? "Hide margin notes" : "Show margin notes"}
style={visibilityButton(T, showPins)}
>
{showPins ? I.eye : I.eyeOff} Randnotizen
{showPins ? I.eye : I.eyeOff} Margin notes
</button>
)}
</div>
@@ -772,7 +772,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
<div
style={{ color: T.muted, fontSize: 13, fontFamily: FONT_UI, textAlign: "center", padding: 40 }}
>
PDF wird geladen…
Loading PDF…
</div>
)}
{state.status === "ready" && (
+3 -3
View File
@@ -52,7 +52,7 @@ export function PdfWorkspace({ note, dark, onUpdate, onOpenNote, searchLinkTarge
const pinLabel = useCallback((pin) => {
const text = (pin.text || "").trim().replace(/\s+/g, " ");
return text ? `S. ${pin.page}: ${text.slice(0, 40)}` : `S. ${pin.page}: Randnotiz`;
return text ? `p. ${pin.page}: ${text.slice(0, 40)}` : `p. ${pin.page}: margin note`;
}, []);
const searchTargets = useCallback(
@@ -94,7 +94,7 @@ export function PdfWorkspace({ note, dark, onUpdate, onOpenNote, searchLinkTarge
<button
data-pdf-toggle-notes={showNotes ? "on" : "off"}
onClick={() => setShowNotes((v) => !v)}
title={showNotes ? "Notizen ausblenden" : "Notizen einblenden"}
title={showNotes ? "Hide notes" : "Show notes"}
style={{
display: "flex",
alignItems: "center",
@@ -111,7 +111,7 @@ export function PdfWorkspace({ note, dark, onUpdate, onOpenNote, searchLinkTarge
fontWeight: 600,
}}
>
{I.text} Notizen
{I.text} Notes
</button>
);