diff --git a/e2e/conflict.mjs b/e2e/conflict.mjs index 63edf9b..e8ec3e1 100644 --- a/e2e/conflict.mjs +++ b/e2e/conflict.mjs @@ -4,7 +4,17 @@ * typed — but the state it buries has to survive as a version, or the other * device's work is simply gone. That is the whole point of the check, so this * verifies it end to end rather than trusting the route in isolation. */ -import { api, check, createNote, createNotebook, launch, report, setTitle, typeInEditor, withFreshUser } from "./lib.mjs"; +import { + api, + check, + createNote, + createNotebook, + launch, + report, + setTitle, + typeInEditor, + withFreshUser, +} from "./lib.mjs"; const browser = await launch(); let session; diff --git a/e2e/editor.mjs b/e2e/editor.mjs index 31384a3..442974b 100644 --- a/e2e/editor.mjs +++ b/e2e/editor.mjs @@ -49,18 +49,18 @@ try { const before = await cellCount(); await p.locator(".ProseMirror table td").first().click({ button: "right" }); await p.waitForTimeout(600); - check((await p.getByText("Zeile darunter einfügen").count()) > 0, "right-click in a table opens the table menu"); - check((await p.getByText("Tabelle löschen").count()) > 0, "and offers deleting the table"); + check((await p.getByText("Insert row below").count()) > 0, "right-click in a table opens the table menu"); + check((await p.getByText("Delete table").count()) > 0, "and offers deleting the table"); await p.screenshot({ path: `${OUT}/editor-table-menu.png` }); - await p.getByText("Zeile darunter einfügen").click(); + await p.getByText("Insert row below").click(); await p.waitForTimeout(1000); check((await cellCount()) > before, "the menu really added a row", `${before} → ${await cellCount()}`); const withRow = await cellCount(); await p.locator(".ProseMirror table td").first().click({ button: "right" }); await p.waitForTimeout(600); - await p.getByText("Zeile löschen").click(); + await p.getByText("Delete row").click(); await p.waitForTimeout(1000); check((await cellCount()) < withRow, "and removing one works too", `${withRow} → ${await cellCount()}`); @@ -68,7 +68,7 @@ try { await p.locator(".ProseMirror p").first().click({ button: "right" }); await p.waitForTimeout(500); check( - (await p.getByText("Zeile darunter einfügen").count()) === 0, + (await p.getByText("Insert row below").count()) === 0, "right-click outside a table shows no table menu" ); await p.keyboard.press("Escape"); diff --git a/e2e/export.mjs b/e2e/export.mjs index c9cb667..409f2ca 100644 --- a/e2e/export.mjs +++ b/e2e/export.mjs @@ -6,11 +6,11 @@ * can be unpacked and read here. */ import { OUT, + captureDownloads, check, + clearDownloads, createNote, createNotebook, - captureDownloads, - clearDownloads, downloads, insertTable, lastDownloadName, @@ -148,7 +148,7 @@ try { const zip = readZip(new Uint8Array(Buffer.from(files[0].base64, "base64"))); const names = Object.keys(zip); check(names.some((n) => n.endsWith(".md")), "the archive holds the Markdown file", names.join(", ")); - check(names.some((n) => /bilder\/.*\.png$/.test(n)), "and the image beside it", names.join(", ")); + check(names.some((n) => /images\/.*\.png$/.test(n)), "and the image beside it", names.join(", ")); await p.keyboard.press("Escape"); await p.waitForTimeout(600); @@ -170,24 +170,24 @@ try { await clearDownloads(p); await openExportDialog(p); check( - (await p.getByText("Was soll mit?").count()) > 0, + (await p.getByText("What should come along?").count()) > 0, "a note with a drawing is asked what should come along" ); check( - (await p.getByRole("button", { name: /Nur die Zeichnung/ }).count()) > 0, + (await p.getByRole("button", { name: /Only the drawing/ }).count()) > 0, "the drawing-only option is offered" ); await p.screenshot({ path: `${OUT}/export-wizard.png` }); // drawing only → PNG - await p.getByRole("button", { name: /Nur die Zeichnung/ }).click(); + await p.getByRole("button", { name: /Only the drawing/ }).click(); await p.waitForTimeout(500); - check((await p.getByRole("button", { name: /PNG-Bild/ }).count()) > 0, "PNG is offered for the drawing"); + check((await p.getByRole("button", { name: /PNG image/ }).count()) > 0, "PNG is offered for the drawing"); check( (await p.locator("[data-export-dialog]").getByRole("button", { name: /Markdown/ }).count()) === 0, "Markdown is not offered for a drawing, since it cannot hold one" ); - await p.getByRole("button", { name: /PNG-Bild/ }).click(); + await p.getByRole("button", { name: /PNG image/ }).click(); await p.waitForSelector("[data-export-done]", { timeout: 30000 }); await p.waitForTimeout(800); @@ -203,9 +203,9 @@ try { check(png.length > 1000, "the image has actual content, not an empty canvas", `${png.length} bytes`); // back, then text only → Markdown must carry no drawing - await p.getByRole("button", { name: /Zurück/ }).click(); + await p.getByRole("button", { name: /Back/ }).click(); await p.waitForTimeout(500); - await p.getByRole("button", { name: /Nur den Notiztext/ }).click(); + await p.getByRole("button", { name: /Only the note text/ }).click(); await p.waitForTimeout(500); await clearDownloads(p); await p.locator("[data-export-dialog]").getByRole("button", { name: /Markdown/ }).click(); diff --git a/e2e/lib.mjs b/e2e/lib.mjs index d20a9fe..de2b7ae 100644 --- a/e2e/lib.mjs +++ b/e2e/lib.mjs @@ -171,12 +171,30 @@ export async function createNotebook(page, name) { await page.waitForTimeout(1200); } +/** Making a note, importing a PDF and importing Markdown share one + menu. */ +export async function openAddMenu(page) { + await page.locator("[data-add-menu]").click(); + await page.waitForTimeout(400); +} + export async function createNote(page) { - await page.getByTitle("New Note").click(); + await openAddMenu(page); + await page.locator('[data-add-option="New note"]').click(); await page.waitForSelector(".ProseMirror", { timeout: 15000 }); await page.waitForTimeout(1200); } +/** Opens the search box in the note list and types into it. */ +export async function search(page, query) { + const box = page.locator('input[aria-label="Search in this notebook"]'); + if ((await box.count()) === 0) { + await page.locator("[data-search-toggle]").click(); + await page.waitForTimeout(400); + } + await box.fill(query); + await page.waitForTimeout(1300); +} + /** The sidebar search box is also a text input, so the title needs its own handle. */ export async function setTitle(page, title) { await page.getByLabel("Note title").fill(title); diff --git a/e2e/offline.mjs b/e2e/offline.mjs index 1d8f482..4045f5c 100644 --- a/e2e/offline.mjs +++ b/e2e/offline.mjs @@ -16,6 +16,7 @@ import { createNotebook, launch, report, + search, setTitle, typeInEditor, withFreshUser, @@ -114,12 +115,10 @@ try { /* Searching is local by design — the server holds only ciphertext — so it has to keep working when the server is not there at all. */ - await p.getByPlaceholder("Search notes...").fill("Kaffee"); - await p.waitForTimeout(1500); + await search(p, "Kaffee"); const hits = await p.locator("[data-note-row]").count(); check(hits === 1, "search still finds a note by its text, offline", `${hits} hits`); - await p.getByPlaceholder("Search notes...").fill(""); - await p.waitForTimeout(800); + await search(p, ""); /* Writing offline is queued, not lost. */ await p.locator("[data-note-row]").filter({ hasText: "Einkauf" }).first().click(); diff --git a/e2e/organise.mjs b/e2e/organise.mjs index 4fba187..4a2a3a2 100644 --- a/e2e/organise.mjs +++ b/e2e/organise.mjs @@ -12,7 +12,9 @@ import { createNote, createNotebook, launch, + openAddMenu, report, + search, setTitle, typeInEditor, withFreshUser, @@ -106,13 +108,11 @@ try { ); /* The point of the whole feature. */ - await p.getByPlaceholder("Search notes...").fill("Beta"); - await p.waitForTimeout(1200); + await search(p, "Beta"); const searched = await titles(); check(searched.length < 3, "searching narrows the list", searched.join(", ")); - await p.getByPlaceholder("Search notes...").fill(""); - await p.waitForTimeout(1500); + await search(p, ""); check( (await titles()).join(",") === arranged.join(","), "and clearing the search shows exactly the arrangement again", @@ -150,15 +150,21 @@ try { await p.waitForTimeout(1800); check((await p.locator("[data-separator]").count()) === 1, "a separator can be added"); + /* Renaming is right-click, like removing. A left-click on the label used to + start an edit, so the row could not be touched without falling into one. */ const label = p.getByLabel("Separator label"); - await p.locator("[data-separator] button").first().click(); - await p.waitForTimeout(300); + check((await label.count()) === 0, "clicking a separator does not drop straight into renaming"); + + await p.locator("[data-separator]").click({ button: "right" }); + await p.waitForTimeout(600); + await p.getByText("Rename", { exact: true }).click(); + await p.waitForTimeout(500); await label.fill("Wichtig"); await label.press("Enter"); await p.waitForTimeout(1800); check( /wichtig/i.test(await p.locator("[data-separator]").innerText()), - "and named", + "and it can be named from the same menu", await p.locator("[data-separator]").innerText() ); @@ -197,14 +203,14 @@ try { await p.locator("[data-separator]").click({ button: "right" }); await p.waitForTimeout(600); - await p.getByText("Trenner entfernen").click(); + await p.getByText("Remove separator").click(); await p.waitForTimeout(600); check( - /Notizen bleiben/.test(await p.locator("body").innerText()), + /notes stay/i.test(await p.locator("body").innerText()), "right-clicking it asks first, and says the notes are not affected" ); - await p.getByRole("button", { name: "Entfernen" }).click(); + await p.getByRole("button", { name: "Remove" }).click(); await p.waitForTimeout(2000); check((await p.locator("[data-separator]").count()) === 0, "and then it is gone"); check((await titles()).length === 3, "with every note still there", (await titles()).join(", ")); @@ -251,7 +257,7 @@ try { check(markdown.includes("- [x] erledigt"), "the export carries the ticked box", JSON.stringify(markdown.slice(-60))); check(markdown.startsWith("# Beta"), "and the title as a heading"); - await p.locator("[data-export-dialog] button").filter({ hasText: /Schließen|Fertig|Close/ }).first().click().catch(() => {}); + await p.locator("[data-export-dialog] button").filter({ hasText: /Close|Done/ }).first().click().catch(() => {}); await p.keyboard.press("Escape"); await p.waitForTimeout(600); @@ -270,14 +276,15 @@ try { }, markdown); const chooser = p.waitForEvent("filechooser"); - await p.locator("[data-import-button]").click(); + await openAddMenu(p); + await p.locator('[data-add-option="Import Markdown"]').click(); const fc = await chooser; await fc.setFiles({ name: "Beta.md", mimeType: "text/markdown", buffer: Buffer.from(markdown) }); await p.waitForTimeout(4000); const importedText = await p.locator("body").innerText(); - check(/importiert/i.test(importedText), "the import reports what it did", importedText.slice(0, 200).replace(/\n/g, " ")); - await p.getByRole("button", { name: /Fertig/ }).click(); + check(/imported/i.test(importedText), "the import reports what it did", importedText.slice(0, 200).replace(/\n/g, " ")); + await p.getByRole("button", { name: /Done/ }).click(); await p.waitForTimeout(2500); const after = (await api(p, "GET", "/notes")).body.length; @@ -307,7 +314,7 @@ try { check(await field.isVisible(), "the link dialog is a real dialog in the page"); await field.fill("javascript:alert(1)"); - await p.getByRole("button", { name: /Einfügen/ }).click(); + await p.getByRole("button", { name: /^Insert$/ }).click(); await p.waitForTimeout(400); check( await field.isVisible(), @@ -315,7 +322,7 @@ try { ); await field.fill("example.com"); - await p.getByRole("button", { name: /Einfügen/ }).click(); + await p.getByRole("button", { name: /^Insert$/ }).click(); await p.waitForTimeout(1800); const href = await p.locator(".ProseMirror a[href]").first().getAttribute("href"); check(href === "https://example.com", "a bare domain is taken as https", String(href)); @@ -341,7 +348,8 @@ try { await p.keyboard.press("Escape"); await p.locator("h2").first().click(); await p.waitForTimeout(500); - await p.getByTitle("New Note").click(); + await openAddMenu(p); + await p.locator('[data-add-option="New note"]').click(); await p.waitForTimeout(900); const books0 = (await api(p, "GET", "/notebooks")).body; @@ -352,7 +360,7 @@ try { // sort order, so "the last one" is not a stable way to mean a particular book. const target = books0.find((b) => b.name === "Zweites Buch"); await p.locator(`[data-notebook-option="${target.id}"]`).click(); - await p.getByRole("button", { name: /Weiter/ }).click(); + await p.getByRole("button", { name: /Continue/ }).click(); await p.waitForTimeout(2500); // By title, not by position: the list is ordered by a timestamp with one diff --git a/e2e/pdf.mjs b/e2e/pdf.mjs index 29e600d..8bfae0e 100644 --- a/e2e/pdf.mjs +++ b/e2e/pdf.mjs @@ -15,7 +15,9 @@ import { downloads, lastDownloadName, launch, + openAddMenu, report, + search, withFreshUser, } from "./lib.mjs"; @@ -115,14 +117,15 @@ try { /* ── import ── */ const chooser = p.waitForEvent("filechooser"); - await p.getByTitle("Import PDF").click(); + await openAddMenu(p); + await p.locator('[data-add-option="Upload PDF"]').click(); await (await chooser).setFiles(PDF_PATH); await p.waitForTimeout(4000); check((await p.locator("[data-pdf-page]").count()) > 0, "the document is shown after importing"); const pdfNoteId = (await api(p, "GET", "/notes")).body[0].id; check( - (await p.locator("[data-pdf-position]").innerText()).includes("/ 2"), + (await p.locator("[data-pdf-position]").innerText()).includes("of 2"), "both pages are counted", await p.locator("[data-pdf-position]").innerText() ); @@ -188,18 +191,15 @@ try { /* ── the document is findable ── */ await p.waitForTimeout(2500); // extraction runs after the first page is shown - await p.getByPlaceholder("Search notes...").fill("Zebrastreifen"); - await p.waitForTimeout(1500); + await search(p, "Zebrastreifen"); check( (await p.locator("[data-note-row]").count()) === 1, "searching a word from inside the PDF finds the note", `${await p.locator("[data-note-row]").count()} rows` ); - await p.getByPlaceholder("Search notes...").fill("Wortdasnichtvorkommt"); - await p.waitForTimeout(1200); + await search(p, "Wortdasnichtvorkommt"); check((await p.locator("[data-note-row]").count()) === 0, "and a word that is not in it finds nothing"); - await p.getByPlaceholder("Search notes...").fill(""); - await p.waitForTimeout(1000); + await search(p, ""); await p.locator("[data-note-row]").first().click(); await p.waitForTimeout(3000); @@ -262,12 +262,12 @@ try { }, n); const before = await inkBounds(1); - await p.getByTitle("Größer").click(); + await p.getByTitle("Zoom in").click(); await p.waitForTimeout(2500); const after = await inkBounds(1); const drift = Math.max(Math.abs(before.x - after.x), Math.abs(before.y - after.y), Math.abs(before.w - after.w)); check(drift < 0.02, "the stroke stays on the same spot when zoomed", `drift ${drift.toFixed(3)}`); - await p.getByTitle("Kleiner").click(); + await p.getByTitle("Zoom out").click(); await p.waitForTimeout(1500); /* ── hiding it ── */ @@ -325,11 +325,9 @@ try { await p.screenshot({ path: "/out/pdf-with-notes.png" }); /* And they are searchable, alongside the document's own text. */ - await p.getByPlaceholder("Search notes...").fill("Randbemerkung"); - await p.waitForTimeout(1500); + await search(p, "Randbemerkung"); check((await p.locator("[data-note-row]").count()) === 1, "notes written beside a PDF are searchable"); - await p.getByPlaceholder("Search notes...").fill(""); - await p.waitForTimeout(1000); + await search(p, ""); await p.locator("[data-note-row]").first().click(); await p.waitForTimeout(3000); @@ -345,14 +343,14 @@ try { await openExport(); check( - (await p.getByText("Was soll heraus?").count()) > 0, + (await p.getByText("What should come out?").count()) > 0, "a PDF note is asked what should come out, not offered note formats" ); await p.screenshot({ path: "/out/pdf-export-wizard.png" }); /* The untouched original. */ await clearDownloads(p); - await p.getByRole("button", { name: /PDF im Original/ }).click(); + await p.getByRole("button", { name: /The original PDF/ }).click(); await p.waitForSelector("[data-export-done]", { timeout: 30000 }); await p.waitForTimeout(900); let files = await downloads(p); @@ -369,7 +367,7 @@ try { /* The same document with the strokes written in. */ await openExport(); await clearDownloads(p); - await p.getByRole("button", { name: /PDF mit deinen Anmerkungen/ }).click(); + await p.getByRole("button", { name: /PDF with your annotations/ }).click(); await p.waitForSelector("[data-export-done]", { timeout: 30000 }); await p.waitForTimeout(1500); files = await downloads(p); @@ -378,7 +376,7 @@ try { check(stamped.subarray(0, 5).toString() === "%PDF-", "the annotated file is a PDF", stamped.subarray(0, 8).toString()); check(stamped.length > originalBytes.length, "and carries more than the original did", `${stamped.length} vs ${originalBytes.length}`); check(!stamped.equals(originalBytes), "so it is not simply a copy"); - check(/mit Notizen/.test(await lastDownloadName(p)), "named so the two cannot be confused", await lastDownloadName(p)); + check(/annotated/.test(await lastDownloadName(p)), "named so the two cannot be confused", await lastDownloadName(p)); /* The stored file must still be the original — drawing on it changes nothing on disk, or a clean copy could never be had again. */ @@ -502,7 +500,7 @@ try { check(levels.cardLeft >= levels.pageRight - 2, "and beside the page rather than over it"); await field.fill("Drehmoment beachten"); - await p.getByRole("button", { name: "Fertig" }).click(); + await p.getByRole("button", { name: "Done" }).click(); await p.waitForTimeout(2500); /* It has to be on the passage, not near it — so the position is checked @@ -520,7 +518,7 @@ try { /* Stored in PDF points, so zooming must not move it off its passage — the same property the strokes have. */ - await p.getByTitle("Größer").click(); + await p.getByTitle("Zoom in").click(); await p.waitForTimeout(2000); const zoomed = await p.evaluate(() => { const pin = document.querySelector("[data-pin]").getBoundingClientRect(); @@ -532,20 +530,18 @@ try { "and stays there when the document is zoomed", `${zoomed.x.toFixed(2)} / ${zoomed.y.toFixed(2)}` ); - await p.getByTitle("Kleiner").click(); + await p.getByTitle("Zoom out").click(); await p.waitForTimeout(1200); /* Searchable: it lives in the page overlay, not in the note body, so nothing else would ever index it. */ - await p.getByPlaceholder("Search notes...").fill("Drehmoment"); - await p.waitForTimeout(1500); + await search(p, "Drehmoment"); check( (await p.locator("[data-note-row]").count()) === 1, "a pinned note can be searched for", String(await p.locator("[data-note-row]").count()) ); - await p.getByPlaceholder("Search notes...").fill(""); - await p.waitForTimeout(1000); + await search(p, ""); /* It belongs to the note, so it has to come back from the server. */ await p.reload({ waitUntil: "networkidle" }); @@ -575,7 +571,7 @@ try { await p.waitForTimeout(400); await p.locator("[data-pdf-mode]").click(); await p.waitForTimeout(500); - await p.getByTitle("Seite leeren").click(); + await p.getByTitle("Clear page").click(); await p.waitForTimeout(2000); check((await p.locator("[data-pin]").count()) === 1, "emptying the page's ink leaves the pinned note"); await p.locator("[data-pdf-mode]").click(); @@ -595,7 +591,7 @@ try { await p.mouse.click(page1b.x + page1b.width * 0.75, page1b.y + page1b.height * 0.7); await p.waitForTimeout(700); await p.getByLabel("Pinned note").fill("Randbemerkung Zebrastreifen"); - await p.getByRole("button", { name: "Fertig" }).click(); + await p.getByRole("button", { name: "Done" }).click(); await p.waitForTimeout(2500); /* The server keeps its own judgement of "is this overlay empty". It counted @@ -618,7 +614,7 @@ try { await p.getByTitle("Export").click(); await p.waitForSelector("[data-export-dialog]", { timeout: 10000 }); await p.waitForTimeout(800); - await p.getByRole("button", { name: /PDF mit deinen Anmerkungen/ }).click(); + await p.getByRole("button", { name: /PDF with your annotations/ }).click(); await p.waitForSelector("[data-export-done]", { timeout: 30000 }); await p.waitForTimeout(1500); const withPins = Buffer.from((await downloads(p))[0].base64, "base64"); @@ -643,7 +639,7 @@ try { await p.mouse.click(pageL.x + pageL.width * 0.35, pageL.y + pageL.height * 0.2); await p.waitForTimeout(800); await p.getByLabel("Pinned note").fill("Zebrastreifen nachschlagen"); - await p.getByRole("button", { name: "Fertig" }).click(); + await p.getByRole("button", { name: "Done" }).click(); await p.waitForTimeout(2500); await p.locator("[data-pdf-toggle-notes]").click(); @@ -671,7 +667,7 @@ try { await linkEl.getAttribute("data-kind") ); check( - (await linkEl.innerText()).includes("S. 1"), + (await linkEl.innerText()).includes("p. 1"), "labelled with the page it points at", await linkEl.innerText() ); @@ -788,7 +784,7 @@ try { await p.mouse.click(pageM.x + pageM.width * 0.5, pageM.y + pageM.height * 0.4); await p.waitForTimeout(800); await p.getByLabel("Pinned note").fill("Am Rand notiert"); - await p.getByRole("button", { name: "Fertig" }).click(); + await p.getByRole("button", { name: "Done" }).click(); await p.waitForTimeout(2500); const wideMargins = await p.locator("[data-pdf-margin]").count(); @@ -865,7 +861,7 @@ try { `top ${Math.round(sheet.y)} of 820` ); - await p.getByRole("button", { name: "Fertig" }).click(); + await p.getByRole("button", { name: "Done" }).click(); await p.waitForTimeout(800); check((await p.locator("[data-pin-sheet]").count()) === 0, "and closes again"); diff --git a/e2e/search-attachments.mjs b/e2e/search-attachments.mjs index 0cab5c1..70b088d 100644 --- a/e2e/search-attachments.mjs +++ b/e2e/search-attachments.mjs @@ -8,6 +8,7 @@ import { createNotebook, launch, report, + search, setTitle, typeInEditor, withFreshUser, @@ -47,23 +48,23 @@ try { // Give the background backfill time to index the bodies. await p.waitForTimeout(3000); - const search = async (q) => { - await p.getByPlaceholder("Search notes...").fill(q); + /** Types into the note list's search box and reports what is left showing. */ + const find = async (q) => { + await search(p, q); await p.waitForTimeout(1200); return titles(p); }; - check(JSON.stringify(await search("Zebrastreifen")) === '["Notiz C"]', "a word only in the body finds its note", JSON.stringify(await titles(p))); - const hits = await search("Dichtung"); + check(JSON.stringify(await find("Zebrastreifen")) === '["Notiz C"]', "a word only in the body finds its note", JSON.stringify(await titles(p))); + const hits = await find("Dichtung"); check(hits.length === 2, "a word in one title and another body finds both", JSON.stringify(hits)); check(hits[0] === "Dichtung", "the title match ranks first", JSON.stringify(hits)); - check(JSON.stringify(await search("Belege Ordner")) === '["Notiz B"]', "multiple terms are an AND"); - check((await search("Belege Kühlschrank")).length === 0, "terms from different notes match nothing"); - check(JSON.stringify(await search("kuhlschrank")) === '["Notiz A"]', "search ignores case and umlauts"); + check(JSON.stringify(await find("Belege Ordner")) === '["Notiz B"]', "multiple terms are an AND"); + check((await find("Belege Kühlschrank")).length === 0, "terms from different notes match nothing"); + check(JSON.stringify(await find("kuhlschrank")) === '["Notiz A"]', "search ignores case and umlauts"); await p.screenshot({ path: `${OUT}/search-results.png` }); - await p.getByPlaceholder("Search notes...").fill(""); - await p.waitForTimeout(1000); + await search(p, ""); check((await titles(p)).length === seed.length, "clearing the box restores the full list"); /* ── attachments ── */ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 4fe4541..2993206 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -71,6 +71,9 @@ export default function App() { store a choice — "All Notes" is not a thing that can carry a setting — so it lasts for the session and no longer. */ const [virtualSort, setVirtualSort] = useState({}); + // Which separator is being renamed. Held here because the context menu that + // starts it lives here too. + const [renamingSeparator, setRenamingSeparator] = useState(null); const [historyFor, setHistoryFor] = useState(null); const [ctxMenu, setCtxMenu] = useState(null); const [confirmDialog, setConfirmDialog] = useState(null); @@ -209,17 +212,19 @@ export default function App() { x: e.clientX, y: e.clientY, items: [ + { label: "Rename", icon: I.edit, action: () => setRenamingSeparator(separator.id) }, + { divider: true }, { - label: "Trenner entfernen", + label: "Remove separator", icon: I.trash, danger: true, action: () => confirm({ - title: "Trenner entfernen", + title: "Remove separator", message: separator.label - ? `Trennlinie "${separator.label}" entfernen? Die Notizen bleiben.` - : "Diese Trennlinie entfernen? Die Notizen bleiben.", - confirmLabel: "Entfernen", + ? `Remove the separator "${separator.label}"? The notes stay.` + : "Remove this separator? The notes stay.", + confirmLabel: "Remove", onConfirm: () => removeSeparator(separator.id), }), }, @@ -354,28 +359,28 @@ export default function App() { const reportFailure = (message) => setConfirmDialog({ - title: "Import fehlgeschlagen", + title: "Import failed", message, confirmLabel: "OK", onConfirm: () => setConfirmDialog(null), }); const importPdf = () => - withNotebook("PDF importieren", (folderId) => + withNotebook("Upload PDF", (folderId) => chooseFiles("application/pdf,.pdf", false, async ([file]) => { try { const note = await data.importPdf(folderId, file); if (VIRTUAL_FOLDERS.includes(activeFolder)) setActiveFolder(folderId); openNote(note.id); } catch (e) { - reportFailure(e?.message || "Die Datei konnte nicht importiert werden."); + reportFailure(e?.message || "The file could not be imported."); } }) ); /** Markdown files, or a ZIP written by this app's export. */ const importMarkdown = () => - withNotebook("Notizen importieren", (folderId) => + withNotebook("Import notes", (folderId) => chooseFiles(".md,.markdown,.txt,.zip,text/markdown,application/zip", true, (files) => { if (VIRTUAL_FOLDERS.includes(activeFolder)) setActiveFolder(folderId); setImporting({ files, folderId }); @@ -383,7 +388,7 @@ export default function App() { ); const createNote = () => - withNotebook("Neue Notiz", async (folderId) => { + withNotebook("New note", async (folderId) => { const note = await data.createNote(folderId); if (VIRTUAL_FOLDERS.includes(activeFolder)) setActiveFolder(folderId); openNote(note.id); @@ -586,8 +591,6 @@ export default function App() { isMobile={isMobile} open={sidebarOpen} onClose={() => setSidebarOpen(false)} - searchQuery={searchQuery} - onSearchChange={setSearchQuery} activeFolder={activeFolder} onSelectFolder={openFolder} notebooks={notebooks} @@ -632,12 +635,16 @@ export default function App() { isTrashView={isTrashView} trashedCount={trashedNotes.length} snippetFor={search.snippetFor} + query={searchQuery} + onQueryChange={setSearchQuery} sortMode={sortMode} sortModes={sortModesFor(!!activeNotebook)} onSortModeChange={setSortMode} reorderable={canReorder(sortMode, isSearching) && !!activeNotebook} separators={separators} onAddSeparator={addSeparator} + renamingSeparator={renamingSeparator} + onStartRenameSeparator={setRenamingSeparator} onRenameSeparator={renameSeparator} onSeparatorContextMenu={separatorContextMenu} dnd={noteDnd} @@ -700,7 +707,7 @@ export default function App() { }} onExportAll={() => { setShowAccount(false); - setExporting({ label: "Alle Notizen", notes: activeNotes }); + setExporting({ label: "All Notes", notes: activeNotes }); }} dark={dark} /> diff --git a/frontend/src/editor/tableMenu.js b/frontend/src/editor/tableMenu.js index f9460c7..540bf59 100644 --- a/frontend/src/editor/tableMenu.js +++ b/frontend/src/editor/tableMenu.js @@ -33,10 +33,10 @@ export function tableMenuItems(editor) { }; const items = [ - { label: "Zeile darüber einfügen", action: run((c) => c.addRowBefore()) }, - { label: "Zeile darunter einfügen", action: run((c) => c.addRowAfter()) }, - { label: "Spalte links einfügen", action: run((c) => c.addColumnBefore()) }, - { label: "Spalte rechts einfügen", action: run((c) => c.addColumnAfter()) }, + { label: "Insert row above", action: run((c) => c.addRowBefore()) }, + { label: "Insert row below", action: run((c) => c.addRowAfter()) }, + { label: "Insert column left", action: run((c) => c.addColumnBefore()) }, + { label: "Insert column right", action: run((c) => c.addColumnAfter()) }, { divider: true }, { label: "Kopfzeile an/aus", action: run((c) => c.toggleHeaderRow()) }, ]; @@ -48,9 +48,9 @@ export function tableMenuItems(editor) { items.push( { divider: true }, - { label: "Zeile löschen", danger: true, action: run((c) => c.deleteRow()) }, - { label: "Spalte löschen", danger: true, action: run((c) => c.deleteColumn()) }, - { label: "Tabelle löschen", danger: true, action: run((c) => c.deleteTable()) } + { label: "Delete row", danger: true, action: run((c) => c.deleteRow()) }, + { label: "Delete column", danger: true, action: run((c) => c.deleteColumn()) }, + { label: "Delete table", danger: true, action: run((c) => c.deleteTable()) } ); return items; diff --git a/frontend/src/export/exportNotes.ts b/frontend/src/export/exportNotes.ts index 0dc97b6..2ca5b29 100644 --- a/frontend/src/export/exportNotes.ts +++ b/frontend/src/export/exportNotes.ts @@ -75,7 +75,7 @@ async function loadNote(meta: NoteMeta): Promise { /** Unique filename per note — two notes may well share a title. */ function nameFor(meta: NoteMeta, taken: Set): string { - const base = safeFilename(meta.title, "Notiz"); + const base = safeFilename(meta.title, "Note"); let name = base; for (let i = 2; taken.has(name.toLowerCase()); i++) name = `${base} (${i})`; taken.add(name.toLowerCase()); @@ -92,9 +92,9 @@ function nameFor(meta: NoteMeta, taken: Set): string { * so a clean copy stays available however often it has been drawn on. */ export async function exportPdfNote(meta: NoteMeta, withInk: boolean): Promise { - if (!meta.pdfAttachmentId) throw new Error("Zu dieser Notiz gehört keine PDF-Datei."); + if (!meta.pdfAttachmentId) throw new Error("This note has no PDF attached."); const bytes = await sync.loadPdfBytes(meta.pdfAttachmentId); - const name = safeFilename(meta.title, "Dokument"); + const name = safeFilename(meta.title, "Document"); if (!withInk) { download(bytesToBlob(bytes, "application/pdf"), `${name}.pdf`); @@ -111,7 +111,7 @@ export async function exportPdfNote(meta: NoteMeta, withInk: boolean): Promise { export async function exportDrawingAsPng(meta: NoteMeta): Promise { const { content } = await loadNote(meta); const png = await drawingToPng(content.drawingStrokes); - if (!png) throw new Error("Diese Notiz enthält keine Zeichnung."); + if (!png) throw new Error("This note has no drawing."); download(bytesToBlob(png, "image/png"), `${safeFilename(meta.title)}.png`); } @@ -157,7 +157,7 @@ export async function exportNoteAsMarkdown(meta: NoteMeta): Promise { title: meta.title, updatedAt: meta.updatedAt, hasDrawing, - attachmentPath: (id) => `${safeFilename(meta.title)}-bilder/${id}.png`, + attachmentPath: (id) => `${safeFilename(meta.title)}-images/${id}.png`, notePath: () => null, // a lone note has nothing to link to }); @@ -171,10 +171,10 @@ export async function exportNoteAsMarkdown(meta: NoteMeta): Promise { const entries: ZipEntry[] = [{ name: `${folder}.md`, data: md }]; for (const id of ids) { const bytes = await fetchAttachment(id); - if (bytes) entries.push({ name: `${folder}-bilder/${id}.png`, data: bytes }); + if (bytes) entries.push({ name: `${folder}-images/${id}.png`, data: bytes }); } const png = await drawingToPng(content.drawingStrokes); - if (png) entries.push({ name: `${folder}-zeichnung.png`, data: png }); + if (png) entries.push({ name: `${folder}-drawing.png`, data: png }); download(createZip(entries), `${folder}.zip`); } @@ -229,7 +229,7 @@ export async function exportNotesAsPdf( } } - onProgress?.({ done: total, total, label: "Druckansicht wird geöffnet" }); + onProgress?.({ done: total, total, label: "Opening the print view" }); await printNotes(printable, title); } @@ -275,7 +275,7 @@ export async function exportNotesAsZip({ const index: string[] = [ `# ${label}`, "", - `${notes.length} Notizen, exportiert am ${new Date().toLocaleString("de-DE")}`, + `${notes.length} notes, exported ${new Date().toLocaleString()}`, "", ]; const byNotebook = new Map(); @@ -300,24 +300,24 @@ export async function exportNotesAsZip({ title: meta.title, updatedAt: meta.updatedAt, hasDrawing, - attachmentPath: (id) => `bilder/${id}.png`, + attachmentPath: (id) => `images/${id}.png`, notePath, }), }); for (const id of attachmentIds(doc)) { - if (entries.some((e) => e.name === `bilder/${id}.png`)) continue; + if (entries.some((e) => e.name === `images/${id}.png`)) continue; const bytes = await fetchAttachment(id); - if (bytes) entries.push({ name: `bilder/${id}.png`, data: bytes }); + if (bytes) entries.push({ name: `images/${id}.png`, data: bytes }); } const png = await drawingToPng(content.drawingStrokes); - if (png) entries.push({ name: `zeichnungen/${name}.png`, data: png }); + if (png) entries.push({ name: `drawings/${name}.png`, data: png }); } catch (err) { // A note that cannot be read is named in the index rather than skipped in // silence — an export that quietly drops things is worse than useless. entries.push({ name: `${name}.md`, - data: `# ${meta.title}\n\n> Diese Notiz konnte beim Export nicht gelesen werden.\n> (${(err as Error).message})\n`, + data: `# ${meta.title}\n\n> This note could not be read when exporting.\n> (${(err as Error).message})\n`, }); } done++; @@ -325,7 +325,7 @@ export async function exportNotesAsZip({ for (const [notebookId, group] of byNotebook) { const nb = notebooks.find((b) => b.id === notebookId); - index.push("", `## ${nb?.name || "Ohne Notizbuch"}`, ""); + index.push("", `## ${nb?.name || "No notebook"}`, ""); for (const n of group) index.push(`- [${n.title || "Untitled"}](${notePath(n.id)})`); } entries.unshift({ name: "README.md", data: index.join("\n") + "\n" }); diff --git a/frontend/src/export/markdown.test.ts b/frontend/src/export/markdown.test.ts index 0ea89e8..16c4514 100644 --- a/frontend/src/export/markdown.test.ts +++ b/frontend/src/export/markdown.test.ts @@ -183,7 +183,7 @@ describe("document framing", () => { it("says when a drawing could not come along, instead of dropping it silently", () => { const out = md(doc(para(text("Text"))), { hasDrawing: true }); - expect(out).toContain("Zeichnung"); + expect(out).toContain("drawing"); }); it("collapses the blank lines the block rules emit generously", () => { @@ -208,7 +208,7 @@ describe("safeFilename", () => { }); it("falls back when nothing usable is left", () => { - expect(safeFilename("")).toBe("Notiz"); + expect(safeFilename("")).toBe("Note"); expect(safeFilename("///")).toBe("---"); }); diff --git a/frontend/src/export/markdown.ts b/frontend/src/export/markdown.ts index abbb706..bef3c90 100644 --- a/frontend/src/export/markdown.ts +++ b/frontend/src/export/markdown.ts @@ -208,7 +208,7 @@ export function noteToMarkdown(doc: DocNode, options: MarkdownOptions = {}): str if (options.title) { lines.push(`# ${options.title}`, ""); if (options.updatedAt) { - lines.push(`*${new Date(options.updatedAt).toLocaleString("de-DE")}*`, ""); + lines.push(`*${new Date(options.updatedAt).toLocaleString()}*`, ""); } } @@ -227,8 +227,8 @@ export function noteToMarkdown(doc: DocNode, options: MarkdownOptions = {}): str lines.push(...blocks); if (options.hasDrawing) { - lines.push("", "> ✏️ Diese Notiz enthält eine Zeichnung, die Markdown nicht abbilden kann."); - lines.push("> Sie liegt als PNG neben der Datei."); + lines.push("", "> ✏️ This note has a drawing, which Markdown cannot hold."); + lines.push("> It sits beside this file as a PNG."); } // Collapse runs of blank lines; the block rules emit them generously. @@ -239,7 +239,7 @@ export function noteToMarkdown(doc: DocNode, options: MarkdownOptions = {}): str } /** Filename-safe version of a note title. */ -export function safeFilename(title: string, fallback = "Notiz"): string { +export function safeFilename(title: string, fallback = "Note"): string { const cleaned = (title || "") .replace(/[/\\?%*:|"<>]/g, "-") .replace(/\s+/g, " ") diff --git a/frontend/src/features/notes/NoteList.jsx b/frontend/src/features/notes/NoteList.jsx index 91ebf72..b8f2336 100644 --- a/frontend/src/features/notes/NoteList.jsx +++ b/frontend/src/features/notes/NoteList.jsx @@ -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 }) { }} /> ) : ( - + {separator.label || "Unnamed"} + )}
@@ -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) => ( + + ); + + return ( +
+ + {open && ( +
+ {item("New note", I.plus, onCreateNote)} + {item("Upload PDF", I.pdf, onImportPdf)} + {item("Import Markdown", I.import, onImportMarkdown)} +
+ )} +
+ ); +} + /** 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 (
} {!isTrashView && ( )} {!isTrashView && ( - - )} - {!isTrashView && ( - + )} {isTrashView && trashedCount > 0 && (
- {/* 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 && ( +
+ 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", + }} + /> +
+ )} + + {/* Just the one control. The sentence that used to sit here explained the + order the sort menu already names, on every visit, forever. */} + {reorderable && (
- - {reorderable - ? "Eigene Reihenfolge — zum Umsortieren ziehen" - : "Eigene Reihenfolge — Suche aktiv, Ziehen pausiert"} - - {reorderable && ( - - )} +
)} @@ -477,6 +581,8 @@ export function NoteList({ diff --git a/frontend/src/features/notes/noteSort.ts b/frontend/src/features/notes/noteSort.ts index 98d0aba..05cef55 100644 --- a/frontend/src/features/notes/noteSort.ts +++ b/frontend/src/features/notes/noteSort.ts @@ -17,11 +17,11 @@ import type { NoteMeta, Separator, SortMode } from "../../types"; export const DEFAULT_SORT: SortMode = "updated"; export const SORT_LABELS: Record = { - 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[]; diff --git a/frontend/src/features/pdf/PdfMargin.jsx b/frontend/src/features/pdf/PdfMargin.jsx index 29622cc..5b90653 100644 --- a/frontend/src/features/pdf/PdfMargin.jsx +++ b/frontend/src/features/pdf/PdfMargin.jsx @@ -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
@@ -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"} )} diff --git a/frontend/src/features/pdf/PdfNoteSheet.jsx b/frontend/src/features/pdf/PdfNoteSheet.jsx index b7594be..d48bbd9 100644 --- a/frontend/src/features/pdf/PdfNoteSheet.jsx +++ b/frontend/src/features/pdf/PdfNoteSheet.jsx @@ -68,7 +68,7 @@ export function PdfNoteSheet({ T, note, number, onChange, onClose, onRemove }) { > {number} - Notiz zu dieser Stelle + Note on this passage @@ -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 ); diff --git a/frontend/src/features/pdf/PdfNotesPanel.jsx b/frontend/src/features/pdf/PdfNotesPanel.jsx index b798b93..5778ed4 100644 --- a/frontend/src/features/pdf/PdfNotesPanel.jsx +++ b/frontend/src/features/pdf/PdfNotesPanel.jsx @@ -84,7 +84,7 @@ export function PdfNotesPanel({ ) : ( - {I.text} Notizen + {I.text} Notes )} {editor && } diff --git a/frontend/src/features/pdf/PdfViewer.jsx b/frontend/src/features/pdf/PdfViewer.jsx index 082481c..f1713ab 100644 --- a/frontend/src/features/pdf/PdfViewer.jsx +++ b/frontend/src/features/pdf/PdfViewer.jsx @@ -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", }} > - - {state.status === "ready" ? `Seite ${pageNo} / ${state.pageCount}` : "Lädt…"} + {state.status === "ready" ? `Page ${pageNo} of ${state.pageCount}` : "Loading…"}
@@ -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"} {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
- @@ -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"} {notesToggle}
- {Math.round(zoom * 100)}% - @@ -745,10 +745,10 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc )} {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 )}
@@ -772,7 +772,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
- PDF wird geladen… + Loading PDF…
)} {state.status === "ready" && ( diff --git a/frontend/src/features/pdf/PdfWorkspace.jsx b/frontend/src/features/pdf/PdfWorkspace.jsx index fa83ee6..4eaeab9 100644 --- a/frontend/src/features/pdf/PdfWorkspace.jsx +++ b/frontend/src/features/pdf/PdfWorkspace.jsx @@ -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 ); diff --git a/frontend/src/ui/ExportDialog.jsx b/frontend/src/ui/ExportDialog.jsx index 70f1894..49b072b 100644 --- a/frontend/src/ui/ExportDialog.jsx +++ b/frontend/src/ui/ExportDialog.jsx @@ -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 = () => ( <> -
Was soll heraus?
+
What should come out?
{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 = () => ( <> -
Was soll mit?
- {card(() => setParts("both"), parts === "both", "Notiz mit Zeichnung", [ - "Text und Zeichnung in einem Dokument — nur als PDF möglich.", +
What should come along?
+ {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 )} -
In welcher Form?
+
In what form?
{options} ); @@ -368,7 +368,7 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) { )}
- Alles läuft im Browser — es geht nichts an einen fremden Dienst. + Everything happens in the browser — nothing goes to an outside service.
diff --git a/frontend/src/ui/ImportDialog.jsx b/frontend/src/ui/ImportDialog.jsx index 279790e..4565a1e 100644 --- a/frontend/src/ui/ImportDialog.jsx +++ b/frontend/src/ui/ImportDialog.jsx @@ -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…"} {!done && ( @@ -101,7 +101,7 @@ export function ImportDialog({ files, folderId, onDone, onClose, dark }) { />
- {progress.total ? `${progress.done} von ${progress.total}` : "Archiv wird gelesen…"} + {progress.total ? `${progress.done} of ${progress.total}` : "Reading the archive…"} {progress.label ? ` — ${progress.label}` : ""}
@@ -113,29 +113,29 @@ export function ImportDialog({ files, folderId, onDone, onClose, dark }) {
{result.notes.length}{" "} - {result.notes.length === 1 ? "Notiz importiert" : "Notizen importiert"} + {result.notes.length === 1 ? "note imported" : "notes imported"}
{result.flattenedDrawings > 0 && (
{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.
)} {result.missingImages > 0 && (
- {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.
)} {result.failed.length > 0 && (
- Nicht gelesen: {result.failed.join(", ")} + Could not be read: {result.failed.join(", ")}
)}
@@ -156,7 +156,7 @@ export function ImportDialog({ files, folderId, onDone, onClose, dark }) { fontWeight: 600, }} > - {done ? "Fertig" : "Läuft…"} + {done ? "Done" : "Working…"} diff --git a/frontend/src/ui/LinkDialog.jsx b/frontend/src/ui/LinkDialog.jsx index a3da0d6..4a6f26c 100644 --- a/frontend/src/ui/LinkDialog.jsx +++ b/frontend/src/ui/LinkDialog.jsx @@ -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"}
- {error || "http, https oder mailto"} + {error || "http, https or mailto"}
{initialUrl && ( )}
diff --git a/frontend/src/ui/NotebookModals.jsx b/frontend/src/ui/NotebookModals.jsx index e96b00f..7234665 100644 --- a/frontend/src/ui/NotebookModals.jsx +++ b/frontend/src/ui/NotebookModals.jsx @@ -108,8 +108,8 @@ export function EditNotebookModal({ notebook, onClose, onSave, dark }) { }} />
- Ä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.
diff --git a/frontend/src/ui/NotebookPicker.jsx b/frontend/src/ui/NotebookPicker.jsx index 92f2531..b49498d 100644 --- a/frontend/src/ui/NotebookPicker.jsx +++ b/frontend/src/ui/NotebookPicker.jsx @@ -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}
-
In welches Notizbuch soll es?
+
Which notebook should it go in?
{nb.name} - {nb.id === remembered && ( - zuletzt benutzt - )} + {nb.id === remembered && last used} ); })} @@ -168,7 +166,7 @@ export function NotebookPicker({ notebooks, title, confirmLabel = "Weiter", onPi fontSize: 13, }} > - Abbrechen + Cancel
-
-
- {I.search} - onSearchChange(e.target.value)} - placeholder="Search notes..." - style={{ - border: "none", - background: "transparent", - outline: "none", - flex: 1, - color: T.text, - fontSize: 13, - fontFamily: FONT_UI, - }} - /> -
-
+ {/* 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. */}