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:
1 parent
0a7cf26b28
commit
52bd2b8f3d
27 files changed
+469
-336
No files matched your search
@@ -90,22 +90,22 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
|
||||
await exportPdfNote(notes[0], kind === "pdf-stamped");
|
||||
setDone(
|
||||
kind === "pdf-stamped"
|
||||
? "PDF mit deinen Anmerkungen wurde heruntergeladen."
|
||||
: "Das unveränderte Original wurde heruntergeladen."
|
||||
? "The PDF with your annotations was downloaded."
|
||||
: "The untouched original was downloaded."
|
||||
);
|
||||
} else if (kind === "png") {
|
||||
await exportDrawingAsPng(notes[0]);
|
||||
setDone("Zeichnung wurde als PNG heruntergeladen.");
|
||||
setDone("The drawing was downloaded as a PNG.");
|
||||
} else if (kind === "markdown") {
|
||||
if (single) await exportNoteAsMarkdown(notes[0]);
|
||||
else await exportNotesAsZip({ label, notes, notebooks, onProgress: setProgress });
|
||||
setDone(single ? "Datei wurde heruntergeladen." : "Archiv wurde heruntergeladen.");
|
||||
setDone(single ? "The file was downloaded." : "The archive was downloaded.");
|
||||
} else {
|
||||
await exportNotesAsPdf(notes, label, setProgress, parts || "both");
|
||||
setDone("Druckansicht war geöffnet — wähle dort „Als PDF sichern“.");
|
||||
setDone("The print view was opened — choose “Save as PDF” there.");
|
||||
}
|
||||
} catch (e) {
|
||||
setErr(e?.message || "Export fehlgeschlagen");
|
||||
setErr(e?.message || "Export failed");
|
||||
}
|
||||
setBusy(null);
|
||||
setProgress(null);
|
||||
@@ -145,22 +145,22 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
|
||||
/* ── a PDF note: the document, or the notes written beside it ── */
|
||||
const pdfOptions = () => (
|
||||
<>
|
||||
<div style={{ fontSize: 12, color: T.muted, marginBottom: 10 }}>Was soll heraus?</div>
|
||||
<div style={{ fontSize: 12, color: T.muted, marginBottom: 10 }}>What should come out?</div>
|
||||
{card(
|
||||
() => run("pdf-stamped"),
|
||||
busy === "pdf-stamped",
|
||||
"PDF mit deinen Anmerkungen",
|
||||
"PDF with your annotations",
|
||||
[
|
||||
"Striche und Randnotizen werden fest ins Dokument gerechnet.",
|
||||
hasInk === false ? "Auf diesem Dokument ist noch nichts angemerkt." : null,
|
||||
"Strokes and margin notes are written into the document.",
|
||||
hasInk === false ? "Nothing has been marked on this document yet." : null,
|
||||
],
|
||||
hasInk === false
|
||||
)}
|
||||
{card(() => run("pdf-original"), busy === "pdf-original", "PDF im Original", [
|
||||
"Genau die Datei, die du importiert hast — ohne deine Anmerkungen.",
|
||||
{card(() => run("pdf-original"), busy === "pdf-original", "The original PDF", [
|
||||
"Exactly the file you imported — without your annotations.",
|
||||
])}
|
||||
{card(() => run("markdown"), busy === "markdown", "Nur die Notizen daneben (Markdown)", [
|
||||
"Was du neben dem Dokument geschrieben hast, ohne das PDF selbst.",
|
||||
{card(() => run("markdown"), busy === "markdown", "Only the notes beside it (Markdown)", [
|
||||
"What you wrote next to the document, without the PDF itself.",
|
||||
])}
|
||||
</>
|
||||
);
|
||||
@@ -168,15 +168,15 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
|
||||
/* ── step one: what should come along ── */
|
||||
const stepParts = () => (
|
||||
<>
|
||||
<div style={{ fontSize: 12, color: T.muted, marginBottom: 10 }}>Was soll mit?</div>
|
||||
{card(() => setParts("both"), parts === "both", "Notiz mit Zeichnung", [
|
||||
"Text und Zeichnung in einem Dokument — nur als PDF möglich.",
|
||||
<div style={{ fontSize: 12, color: T.muted, marginBottom: 10 }}>What should come along?</div>
|
||||
{card(() => setParts("both"), parts === "both", "Note with the drawing", [
|
||||
"Text and drawing in one document — only possible as a PDF.",
|
||||
])}
|
||||
{card(() => setParts("text"), parts === "text", "Nur den Notiztext", [
|
||||
"Ohne die Zeichnung. Als PDF oder als Markdown.",
|
||||
{card(() => setParts("text"), parts === "text", "Only the note text", [
|
||||
"Without the drawing. As a PDF or as Markdown.",
|
||||
])}
|
||||
{card(() => setParts("drawing"), parts === "drawing", "Nur die Zeichnung", [
|
||||
"Als PNG-Bild oder als PDF-Seite.",
|
||||
{card(() => setParts("drawing"), parts === "drawing", "Only the drawing", [
|
||||
"As a PNG image or as a PDF page.",
|
||||
])}
|
||||
</>
|
||||
);
|
||||
@@ -189,27 +189,27 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
|
||||
card(
|
||||
() => run("markdown"),
|
||||
busy === "markdown",
|
||||
single ? "Markdown (.md)" : "Markdown-Archiv (ZIP)",
|
||||
single ? "Markdown (.md)" : "Markdown archive (ZIP)",
|
||||
[
|
||||
single
|
||||
? "Bleibt anderswo bearbeitbar. Tabellen und Aufgabenhaken kommen mit."
|
||||
: "Je Notiz eine .md-Datei, dazu Bilder und ein Inhaltsverzeichnis.",
|
||||
parts === "both" ? "Die Zeichnung liegt als PNG daneben — Markdown kann sie nicht halten." : null,
|
||||
? "Stays editable elsewhere. Tables and task boxes come along."
|
||||
: "One .md file per note, plus the images and a table of contents.",
|
||||
parts === "both" ? "The drawing sits beside it as a PNG — Markdown cannot hold one." : null,
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
if (parts === "drawing") {
|
||||
options.push(
|
||||
card(() => run("png"), busy === "png", "PNG-Bild (.png)", [
|
||||
"Die Zeichnung in voller Auflösung, auf weißem Grund.",
|
||||
card(() => run("png"), busy === "png", "PNG image (.png)", [
|
||||
"The drawing at full resolution, on white paper.",
|
||||
])
|
||||
);
|
||||
}
|
||||
options.push(
|
||||
card(() => run("pdf"), busy === "pdf", "PDF", [
|
||||
"Öffnet den Druckdialog — dort „Als PDF sichern“ wählen.",
|
||||
parts === "both" ? "Die Zeichnung steht mit im Dokument." : null,
|
||||
"Opens the print dialog — choose “Save as PDF” there.",
|
||||
parts === "both" ? "The drawing is in the document too." : null,
|
||||
])
|
||||
);
|
||||
return (
|
||||
@@ -232,10 +232,10 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{I.back} Zurück
|
||||
{I.back} Back
|
||||
</button>
|
||||
)}
|
||||
<div style={{ fontSize: 12, color: T.muted, marginBottom: 10 }}>In welcher Form?</div>
|
||||
<div style={{ fontSize: 12, color: T.muted, marginBottom: 10 }}>In what form?</div>
|
||||
{options}
|
||||
</>
|
||||
);
|
||||
@@ -368,7 +368,7 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
|
||||
)}
|
||||
|
||||
<div style={{ padding: "8px 22px 18px", fontSize: 11, color: T.faint, lineHeight: 1.5 }}>
|
||||
Alles läuft im Browser — es geht nichts an einen fremden Dienst.
|
||||
Everything happens in the browser — nothing goes to an outside service.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ export function ImportDialog({ files, folderId, onDone, onClose, dark }) {
|
||||
setResult(r);
|
||||
onDone?.(r);
|
||||
})
|
||||
.catch((e) => setError(e?.message || "Der Import ist fehlgeschlagen."));
|
||||
.catch((e) => setError(e?.message || "The import failed."));
|
||||
}, [files, folderId, onDone]);
|
||||
|
||||
const done = result || error;
|
||||
@@ -85,7 +85,7 @@ export function ImportDialog({ files, folderId, onDone, onClose, dark }) {
|
||||
fontFamily: "'Newsreader',Georgia,serif",
|
||||
}}
|
||||
>
|
||||
{done ? "Import abgeschlossen" : "Notizen werden importiert…"}
|
||||
{done ? "Import finished" : "Importing notes…"}
|
||||
</div>
|
||||
|
||||
{!done && (
|
||||
@@ -101,7 +101,7 @@ export function ImportDialog({ files, folderId, onDone, onClose, dark }) {
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: T.muted, marginTop: 8 }}>
|
||||
{progress.total ? `${progress.done} von ${progress.total}` : "Archiv wird gelesen…"}
|
||||
{progress.total ? `${progress.done} of ${progress.total}` : "Reading the archive…"}
|
||||
{progress.label ? ` — ${progress.label}` : ""}
|
||||
</div>
|
||||
</>
|
||||
@@ -113,29 +113,29 @@ export function ImportDialog({ files, folderId, onDone, onClose, dark }) {
|
||||
<div style={{ fontSize: 13, lineHeight: 1.7 }}>
|
||||
<div>
|
||||
<strong>{result.notes.length}</strong>{" "}
|
||||
{result.notes.length === 1 ? "Notiz importiert" : "Notizen importiert"}
|
||||
{result.notes.length === 1 ? "note imported" : "notes imported"}
|
||||
</div>
|
||||
|
||||
{result.flattenedDrawings > 0 && (
|
||||
<div style={{ color: T.warning, marginTop: 10, fontSize: 12.5 }}>
|
||||
{result.flattenedDrawings === 1
|
||||
? "Eine Zeichnung kam als Bild zurück"
|
||||
: `${result.flattenedDrawings} Zeichnungen kamen als Bilder zurück`}{" "}
|
||||
— im Archiv liegen sie als PNG, und aus Pixeln lassen sich die Striche nicht zurückrechnen.
|
||||
Sie sind sichtbar und exportierbar, aber nicht mehr mit dem Stift zu bearbeiten.
|
||||
? "One drawing came back as a picture"
|
||||
: `${result.flattenedDrawings} drawings came back as pictures`}{" "}
|
||||
— the archive holds them as PNGs, and strokes cannot be recovered from pixels. They are
|
||||
visible and exportable, but the pen can no longer edit them.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.missingImages > 0 && (
|
||||
<div style={{ color: T.warning, marginTop: 10, fontSize: 12.5 }}>
|
||||
{result.missingImages} {result.missingImages === 1 ? "Bild lag" : "Bilder lagen"} nicht im
|
||||
Archiv. Die Stelle in der Notiz bleibt erhalten, das Bild fehlt.
|
||||
{result.missingImages} {result.missingImages === 1 ? "image was" : "images were"} not in the
|
||||
archive. Its place in the note is kept; the picture is missing.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.failed.length > 0 && (
|
||||
<div style={{ color: T.danger, marginTop: 10, fontSize: 12.5 }}>
|
||||
Nicht gelesen: {result.failed.join(", ")}
|
||||
Could not be read: {result.failed.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -156,7 +156,7 @@ export function ImportDialog({ files, folderId, onDone, onClose, dark }) {
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{done ? "Fertig" : "Läuft…"}
|
||||
{done ? "Done" : "Working…"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -52,14 +52,14 @@ export function LinkDialog({ initialUrl = "", onSubmit, onRemove, onClose, dark
|
||||
e?.preventDefault();
|
||||
const value = url.trim();
|
||||
if (!value) {
|
||||
setError("Bitte eine Adresse eingeben — oder den Link entfernen.");
|
||||
setError("Enter an address — or remove the link.");
|
||||
return;
|
||||
}
|
||||
// A bare "example.com" is what people type; assume https rather than
|
||||
// refusing something whose meaning is perfectly clear.
|
||||
const candidate = /^[a-z][a-z0-9+.-]*:/i.test(value) ? value : `https://${value}`;
|
||||
if (!ALLOWED.test(candidate)) {
|
||||
setError("Nur http-, https- und mailto-Adressen sind erlaubt.");
|
||||
setError("Only http, https and mailto addresses are allowed.");
|
||||
return;
|
||||
}
|
||||
onSubmit(candidate);
|
||||
@@ -113,7 +113,7 @@ export function LinkDialog({ initialUrl = "", onSubmit, onRemove, onClose, dark
|
||||
fontFamily: "'Newsreader',Georgia,serif",
|
||||
}}
|
||||
>
|
||||
{initialUrl ? "Link bearbeiten" : "Link einfügen"}
|
||||
{initialUrl ? "Edit link" : "Insert link"}
|
||||
</div>
|
||||
|
||||
<input
|
||||
@@ -140,20 +140,20 @@ export function LinkDialog({ initialUrl = "", onSubmit, onRemove, onClose, dark
|
||||
}}
|
||||
/>
|
||||
<div style={{ minHeight: 18, marginTop: 6, fontSize: 11.5, color: error ? T.danger : T.muted }}>
|
||||
{error || "http, https oder mailto"}
|
||||
{error || "http, https or mailto"}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end", marginTop: 12 }}>
|
||||
{initialUrl && (
|
||||
<button type="button" onClick={onRemove} style={{ ...button("danger"), marginRight: "auto" }}>
|
||||
Link entfernen
|
||||
Remove link
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={onClose} style={button()}>
|
||||
Abbrechen
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" style={button("primary")}>
|
||||
{initialUrl ? "Ändern" : "Einfügen"}
|
||||
{initialUrl ? "Change" : "Insert"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -108,8 +108,8 @@ export function EditNotebookModal({ notebook, onClose, onSave, dark }) {
|
||||
}}
|
||||
/>
|
||||
<div style={{ fontSize: 11, color: T.muted, marginTop: 6, lineHeight: 1.5 }}>
|
||||
Ältere Fassungen werden erst gelöscht, wenn neue sie verdrängen — nicht nach Zeit. Leer lassen für
|
||||
die Vorgabe des Servers.
|
||||
Older versions are only deleted once newer ones push them out — never by age. Leave empty for the
|
||||
server's default.
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: 22 }}>
|
||||
|
||||
@@ -32,7 +32,7 @@ const recalledNotebook = () => {
|
||||
}
|
||||
};
|
||||
|
||||
export function NotebookPicker({ notebooks, title, confirmLabel = "Weiter", onPick, onClose, dark }) {
|
||||
export function NotebookPicker({ notebooks, title, confirmLabel = "Continue", onPick, onClose, dark }) {
|
||||
const remembered = recalledNotebook();
|
||||
const [selected, setSelected] = useState(
|
||||
() => notebooks.find((nb) => nb.id === remembered)?.id || notebooks[0]?.id || null
|
||||
@@ -103,7 +103,7 @@ export function NotebookPicker({ notebooks, title, confirmLabel = "Weiter", onPi
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: T.muted, marginBottom: 14 }}>In welches Notizbuch soll es?</div>
|
||||
<div style={{ fontSize: 12, color: T.muted, marginBottom: 14 }}>Which notebook should it go in?</div>
|
||||
|
||||
<div
|
||||
role="listbox"
|
||||
@@ -147,9 +147,7 @@ export function NotebookPicker({ notebooks, title, confirmLabel = "Weiter", onPi
|
||||
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{nb.name}
|
||||
</span>
|
||||
{nb.id === remembered && (
|
||||
<span style={{ fontSize: 10, color: T.muted }}>zuletzt benutzt</span>
|
||||
)}
|
||||
{nb.id === remembered && <span style={{ fontSize: 10, color: T.muted }}>last used</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -168,7 +166,7 @@ export function NotebookPicker({ notebooks, title, confirmLabel = "Weiter", onPi
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Abbrechen
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={confirm}
|
||||
|
||||
@@ -67,8 +67,6 @@ export function Sidebar({
|
||||
isMobile,
|
||||
open,
|
||||
onClose,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
activeFolder,
|
||||
onSelectFolder,
|
||||
notebooks,
|
||||
@@ -157,36 +155,9 @@ export function Sidebar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "0 14px 12px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 12px",
|
||||
borderRadius: 10,
|
||||
background: T.inputBg,
|
||||
border: `1px solid ${T.borderLight}`,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: T.faint }}>{I.search}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Search notes..."
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
outline: "none",
|
||||
flex: 1,
|
||||
color: T.text,
|
||||
fontSize: 13,
|
||||
fontFamily: FONT_UI,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* The search box moved into the note list. From up here it read as
|
||||
searching everything, while it has always narrowed only the notebook
|
||||
you are in — the box now sits with the list it actually filters. */}
|
||||
|
||||
<div style={{ padding: "0 10px" }}>
|
||||
<button
|
||||
|
||||
Reference in new issue
Block a user