From 8586664faedc0121be0e7e3d01c1430d6942e823 Mon Sep 17 00:00:00 2001 From: marc Date: Sat, 19 Sep 2026 20:59:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20R=C3=BCckmeldung=20zu=20Ende=20verd?= =?UTF-8?q?rahtet=20=E2=80=93=20und=20zwei=20L=C3=BCgen=20entfernt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Meldungen sprachen bisher nur im Dateibrowser. Papierkorb, Freigabe-Links, „Für mich freigegeben", Profil, Theming, Audit und die Nutzerliste schwiegen weiter – und das war schlechter als vorher: Wer gelernt hat, dass Aether Bescheid gibt, hält Schweigen für Erfolg. Nicht blind verdrahtet. Sechs Stellen bleiben absichtlich still, samt Grund in TESTING.md: die Suche (bricht bei jedem Anschlag die vorige ab), das Kopieren der Prüfsumme (ohne HTTPS gibt es keine Zwischenablage, der Wert steht ja da), die Theming-Vorgaben, die Absender-Vorschläge, die OCR-Abfrage im Hintergrund und useAuth. Wo ein Dialog seinen Fehler bei sich anzeigt, bleibt es dabei – dort gehört er hin, nicht an den Bildschirmrand. Dabei zwei Fälle gefunden, die schlimmer sind als Schweigen: **ProfilePage meldete „gespeichert", wenn das Speichern fehlgeschlagen war.** `updateProfile(...).catch(() => {})`, danach ungeprüft das Häkchen. **TagEditDialog riet die Ursache:** Jeder Fehler meldete „That name is already taken" – auch ein Netzausfall, auch ein 500er. Jetzt steht dort der Satz des Servers. Im TagDialog war genau das schon einmal aufgefallen und behoben worden; hier stand es noch. Dazu zwei Aufrufe ganz ohne Absicherung: das Wiederherstellen aus dem Papierkorb und das Löschen eines Freigabe-Links. Und das Kopieren eines Links scheiterte ohne HTTPS stumm – der Knopf tat schlicht nichts. Eigener Fehler nebenbei behoben: Ich hatte deutsche Anführungszeichen in englische Meldungen gesetzt. Geprüft: tsc sauber, 41 Vitest-Tests, alle 234 Prüfungen der sieben Selbsttest-Suiten grün. Co-Authored-By: Claude Opus 5 --- TESTING.md | 37 +++++++++++++++++++++++ frontend/src/components/TagEditDialog.tsx | 17 ++++++++--- frontend/src/pages/AdminPage.tsx | 4 ++- frontend/src/pages/FileBrowser.tsx | 16 +++++----- frontend/src/pages/ProfilePage.tsx | 28 +++++++++++++---- frontend/src/pages/SharedPage.tsx | 28 +++++++++++++---- frontend/src/pages/SharedWithMePage.tsx | 3 +- frontend/src/pages/TrashPage.tsx | 20 +++++++++--- frontend/src/pages/admin/AuditTab.tsx | 7 +++-- frontend/src/pages/admin/ThemingTab.tsx | 8 +++-- 10 files changed, 133 insertions(+), 35 deletions(-) diff --git a/TESTING.md b/TESTING.md index a4d1615..9550750 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1487,6 +1487,43 @@ klappte – nichts. - [ ] Fünfte Meldung verdrängt die erste (höchstens vier stehen) - [ ] Betriebssystem auf „Bewegung reduzieren" → keine Einblendung +### Wo es weiterhin still bleibt – und warum + +Nicht jeder verschluckte Fehler ist einer. Diese bleiben absichtlich stumm, +damit niemand sie später "nachrüstet": + +| Stelle | Grund | +|---|---| +| `SearchBox` | Bricht bei jedem Tastendruck die vorige Suche ab. Eine Meldung je Anschlag wäre nur im Weg. | +| `FileInfoDialog` (Prüfsumme kopieren) | Ohne HTTPS gibt es keine Zwischenablage – der Wert steht vollständig da und lässt sich markieren. | +| `useAppSettings` | Erreichbarkeit des Servers ist nicht Sache des Themings; die Vorgaben bleiben stehen. | +| `TagDialog` (Absender-Vorschläge) | Ohne Vorschläge kann man trotzdem tippen. | +| `ProfilePage` (OCR-Stand) | Hintergrundabfrage im Sekundentakt. | +| `useAuth` | Kein Konto = abgemeldet, das ist die Antwort und kein Fehler. | + +Wo ein Dialog seinen Fehler **bei sich** anzeigt (ShareWithDialog, ThemingTab +beim Hochladen, TagDialog, CalendarPage, OfficeEditor, FilePreview), bleibt es +dabei: Dort gehört die Meldung hin, nicht an den unteren Bildschirmrand. + +### Zwei Fehler, die schlimmer waren als Schweigen + +**ProfilePage meldete "gespeichert", wenn das Speichern fehlgeschlagen war.** +`updateProfile(...).catch(() => {})`, danach ungeprüft das Häkchen. Das ist die +übelste Sorte: Sie behauptet das Gegenteil dessen, was geschehen ist. + +**TagEditDialog riet die Ursache.** Jeder Fehler meldete "That name is already +taken" – auch ein Netzausfall, auch ein 500er. Jetzt steht dort der Satz des +Servers. Im TagDialog war genau das schon einmal aufgefallen und behoben +worden; hier stand es noch. + +- [ ] Netz trennen, Profilnamen speichern -> rote Meldung, **kein** Häkchen +- [ ] Netz trennen, Etikett umbenennen -> "No connection to the server.", + nicht "That name is already taken" +- [ ] Papierkorb: Wiederherstellen und Leeren melden sich +- [ ] Freigabe-Link kopieren ohne HTTPS -> Hinweis auf HTTPS statt gar nichts +- [ ] Freigabe-Link löschen -> Meldung, **kein** Rückgängig (ein neuer Link + bekäme ein neues Token; der alte bliebe tot) + ## Der Zurück-Fehler und was der Test **nicht** kann (16.–18.09.2026) Auf „Edit" in der PDF-Ansicht schloss sich die Datei, statt den Editor zu diff --git a/frontend/src/components/TagEditDialog.tsx b/frontend/src/components/TagEditDialog.tsx index 720dac3..ac522f2 100644 --- a/frontend/src/components/TagEditDialog.tsx +++ b/frontend/src/components/TagEditDialog.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { tagsApi, correspondentsApi, notifyTagsChanged, type Correspondent, type Tag } from '../api/client' import { tagPalette, tagColor } from './TagChip' +import { fehlertext } from './Toasts' /** * Ein Etikett umbenennen, einfärben oder löschen. @@ -28,8 +29,12 @@ export default function TagEditDialog({ tag, onClose, onChanged }: { notifyTagsChanged() onChanged(updated) onClose() - } catch { - setError('That name is already taken') + } catch (err) { + // **Nicht mehr raten.** Die feste Meldung „That name is already taken" + // behauptete eine Ursache und lag bei jedem anderen Fehler daneben – + // Netz weg, Server kaputt, Recht fehlt. Der Server schreibt seine + // Meldungen selbst; dieselbe Entscheidung wie im TagDialog. + setError(fehlertext(err, 'Could not save the change')) setBusy(false) } } @@ -242,8 +247,12 @@ export function CorrespondentRenameDialog({ correspondent, onClose, onChanged }: notifyTagsChanged() onChanged(updated) onClose() - } catch { - setError('That name is already taken') + } catch (err) { + // **Nicht mehr raten.** Die feste Meldung „That name is already taken" + // behauptete eine Ursache und lag bei jedem anderen Fehler daneben – + // Netz weg, Server kaputt, Recht fehlt. Der Server schreibt seine + // Meldungen selbst; dieselbe Entscheidung wie im TagDialog. + setError(fehlertext(err, 'Could not save the change')) setBusy(false) } } diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index d8d49fe..710b8b2 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react' import { adminApi, type AdminUser } from '../api/client' import PageHeader from '../components/PageHeader' import Tabs from '../components/Tabs' +import { toast, fehlertext } from '../components/Toasts' type Tab = 'users' | 'groups' | 'theming' | 'audit' import UsersTab from './admin/UsersTab' @@ -14,7 +15,8 @@ export default function AdminPage() { const [allUsers, setAllUsers] = useState([]) useEffect(() => { - adminApi.listUsers().then(data => setAllUsers(data ?? [])).catch(() => {}) + adminApi.listUsers().then(data => setAllUsers(data ?? [])) + .catch(err => toast.error(fehlertext(err, 'Could not load the user list'))) }, []) const tabs: { id: Tab; label: string }[] = [ diff --git a/frontend/src/pages/FileBrowser.tsx b/frontend/src/pages/FileBrowser.tsx index 5717277..af93b5e 100644 --- a/frontend/src/pages/FileBrowser.tsx +++ b/frontend/src/pages/FileBrowser.tsx @@ -400,7 +400,7 @@ export default function FileBrowser({ // Löschen ist bei uns der Papierkorb, Zurückholen ein einziger Aufruf. // Deshalb kostet das Rückgängig hier fast nichts – und nimmt dem // Löschen den Schrecken. - toast.undo(`Deleted „${file.name}"`, async () => { + toast.undo(`Deleted “${file.name}”`, async () => { try { await trashApi.restore(file.id) await load() @@ -410,7 +410,7 @@ export default function FileBrowser({ } }) } catch (err) { - toast.error(fehlertext(err, `Could not delete „${file.name}"`)) + toast.error(fehlertext(err, `Could not delete “${file.name}”`)) } } @@ -455,7 +455,7 @@ export default function FileBrowser({ const updated = await filesApi.rename(file.id, name) setFiles(f => f.map(x => x.id === file.id ? updated : x)) const alterName = file.name - toast.undo(`Renamed to „${name}"`, async () => { + toast.undo(`Renamed to “${name}”`, async () => { try { const zurueck = await filesApi.rename(file.id, alterName) setFiles(f => f.map(x => x.id === file.id ? zurueck : x)) @@ -482,7 +482,7 @@ export default function FileBrowser({ const updated = await foldersApi.rename(folder.id, name) setFolders(f => f.map(x => x.id === folder.id ? updated : x)) const alterName = folder.name - toast.undo(`Renamed to „${name}"`, async () => { + toast.undo(`Renamed to “${name}”`, async () => { try { const zurueck = await foldersApi.rename(folder.id, alterName) setFolders(f => f.map(x => x.id === folder.id ? zurueck : x)) @@ -506,9 +506,9 @@ export default function FileBrowser({ // anlegen und alles zurücksortieren. Ein Knopf, der etwas anderes tut // als er verspricht, wäre schlimmer als keiner; deshalb sagt die // Meldung stattdessen, wo die Dateien geblieben sind. - toast.info(`Deleted „${folder.name}" – its files are in the trash`) + toast.info(`Deleted “${folder.name}” – its files are in the trash`) } catch (err) { - toast.error(fehlertext(err, `Could not delete „${folder.name}"`)) + toast.error(fehlertext(err, `Could not delete “${folder.name}”`)) } } @@ -518,7 +518,7 @@ export default function FileBrowser({ const alterOrdner = file.folder_id ?? null await filesApi.move(file.id, targetFolderId) await reload() - toast.undo(`Moved „${file.name}"`, async () => { + toast.undo(`Moved “${file.name}”`, async () => { try { await filesApi.move(file.id, alterOrdner) await reload() @@ -527,7 +527,7 @@ export default function FileBrowser({ } }) } catch (err) { - toast.error(fehlertext(err, `Could not move „${file.name}"`)) + toast.error(fehlertext(err, `Could not move “${file.name}”`)) } } diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index da924e2..5a7bf48 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -5,6 +5,7 @@ import { useAppSettings } from '../hooks/useAppSettings' import AppPasswords from '../components/AppPasswords' import PageHeader from '../components/PageHeader' import Tabs from '../components/Tabs' +import { toast, fehlertext } from '../components/Toasts' interface Props { user: User @@ -201,7 +202,11 @@ export default function ProfilePage({ user, onUserChanged }: Props) { enabled ? primary : globalColors.primary, enabled ? accent : globalColors.accent, ) - await authApi.setUseCustomTheme(enabled).catch(() => {}) + try { + await authApi.setUseCustomTheme(enabled) + } catch (err) { + toast.error(fehlertext(err, 'Could not change the theme setting')) + } await onUserChanged?.() } @@ -217,11 +222,18 @@ export default function ProfilePage({ user, onUserChanged }: Props) { async function saveProfile() { setProfileLoading(true) - await authApi.updateProfile(displayName).catch(() => {}) + // Vorher wurde der Fehler verschluckt und danach **trotzdem** + // „gespeichert" gemeldet. Das ist die schlimmste Sorte stiller Fehler: + // Sie behauptet das Gegenteil dessen, was geschehen ist. + try { + await authApi.updateProfile(displayName) + setProfileSaved(true) + setTimeout(() => setProfileSaved(false), 2000) + } catch (err) { + toast.error(fehlertext(err, 'Could not save your profile')) + } await onUserChanged?.() setProfileLoading(false) - setProfileSaved(true) - setTimeout(() => setProfileSaved(false), 2000) } async function changePassword() { @@ -243,11 +255,15 @@ export default function ProfilePage({ user, onUserChanged }: Props) { async function saveTheme() { setThemeLoading(true) - const result = await authApi.updateColors(primary, accent).catch(() => null) + const result = await authApi.updateColors(primary, accent).catch(err => { + toast.error(fehlertext(err, 'Could not save your colors')) + return null + }) // Wer Farben speichert, will sie auch sehen. if (result && !useCustom) { setUseCustom(true) - await authApi.setUseCustomTheme(true).catch(() => {}) + await authApi.setUseCustomTheme(true).catch(err => + toast.error(fehlertext(err, 'Could not switch to your own colors'))) applyTheme(primary, accent) } setThemeLoading(false) diff --git a/frontend/src/pages/SharedPage.tsx b/frontend/src/pages/SharedPage.tsx index ff7ee46..1fd1486 100644 --- a/frontend/src/pages/SharedPage.tsx +++ b/frontend/src/pages/SharedPage.tsx @@ -4,6 +4,7 @@ import ConfirmDialog from '../components/ConfirmDialog' import PageHeader from '../components/PageHeader' import { SkeletonRows, useSkeleton } from '../components/Skeleton' import EmptyState from '../components/EmptyState' +import { toast, fehlertext } from '../components/Toasts' function shareURL(token: string): string { return `${window.location.origin}/s/${token}` @@ -27,20 +28,35 @@ export default function SharedPage() { useEffect(() => { sharesApi.list() .then(data => setLinks(data ?? [])) - .catch(() => {}) + .catch(err => toast.error(fehlertext(err, 'Could not load the share links'))) .finally(() => setLoading(false)) }, []) async function copy(token: string) { - await navigator.clipboard.writeText(shareURL(token)) - setCopied(token) - setTimeout(() => setCopied(null), 2000) + // Ohne sicheren Kontext (http:// im LAN) gibt es keine Zwischenablage. + // Vorher blieb der Aufruf unbehandelt liegen: Der Knopf tat schlicht + // nichts, und der Grund stand allenfalls in der Konsole. + try { + await navigator.clipboard.writeText(shareURL(token)) + setCopied(token) + setTimeout(() => setCopied(null), 2000) + } catch { + toast.error('Could not copy – the clipboard needs HTTPS.') + } } async function deleteLink(link: ShareLink) { - await sharesApi.delete(link.id) - setLinks(l => l.filter(x => x.id !== link.id)) setConfirmDelete(null) + // **Kein Rückgängig.** Ein neuer Link bekäme ein neues Token; der alte + // bliebe tot. Ein Knopf, der etwas anderes wiederherstellt als das, was + // weg ist, wäre eine Lüge. + try { + await sharesApi.delete(link.id) + setLinks(l => l.filter(x => x.id !== link.id)) + toast.info('Share link deleted') + } catch (err) { + toast.error(fehlertext(err, 'Could not delete the share link')) + } } const now = new Date() diff --git a/frontend/src/pages/SharedWithMePage.tsx b/frontend/src/pages/SharedWithMePage.tsx index f415e11..d653135 100644 --- a/frontend/src/pages/SharedWithMePage.tsx +++ b/frontend/src/pages/SharedWithMePage.tsx @@ -5,6 +5,7 @@ import PageHeader from '../components/PageHeader' import { FolderGlyph } from '../components/icons' import { SkeletonRows, useSkeleton } from '../components/Skeleton' import EmptyState from '../components/EmptyState' +import { toast, fehlertext } from '../components/Toasts' interface Props { /** Öffnet einen freigegebenen Ordner in der Dateiansicht. */ @@ -22,7 +23,7 @@ export default function SharedWithMePage({ onOpenFolder }: Props) { useEffect(() => { aclApi.sharedWithMe() .then(d => setItems(d ?? [])) - .catch(() => {}) + .catch(err => toast.error(fehlertext(err, 'Could not load what was shared with you'))) .finally(() => setLoading(false)) }, []) diff --git a/frontend/src/pages/TrashPage.tsx b/frontend/src/pages/TrashPage.tsx index c7b11ce..41873b1 100644 --- a/frontend/src/pages/TrashPage.tsx +++ b/frontend/src/pages/TrashPage.tsx @@ -5,6 +5,7 @@ import PageHeader from '../components/PageHeader' import ConfirmDialog from '../components/ConfirmDialog' import { SkeletonRows, useSkeleton } from '../components/Skeleton' import EmptyState from '../components/EmptyState' +import { toast, fehlertext } from '../components/Toasts' interface Props { /** Wiederherstellen verändert den belegten Speicher – Balken nachziehen. */ @@ -22,14 +23,21 @@ export default function TrashPage({ onUserChanged }: Props) { useEffect(() => { trashApi.list() .then(data => setItems(data ?? [])) - .catch(() => {}) + .catch(err => toast.error(fehlertext(err, 'Could not load the trash'))) .finally(() => setLoading(false)) }, []) async function restore(file: TrashItem) { - await trashApi.restore(file.id) - setItems(i => i.filter(x => x.id !== file.id)) - await onUserChanged?.() + // Vorher ohne jede Absicherung: Schlug das Wiederherstellen fehl, blieb + // die Zeile stehen und nichts sagte, warum. + try { + await trashApi.restore(file.id) + setItems(i => i.filter(x => x.id !== file.id)) + await onUserChanged?.() + toast.info(`Restored “${file.name}”`) + } catch (err) { + toast.error(fehlertext(err, `Could not restore “${file.name}”`)) + } } async function emptyAll() { @@ -38,7 +46,9 @@ export default function TrashPage({ onUserChanged }: Props) { await trashApi.emptyAll() setItems([]) await onUserChanged?.() - } catch { /* Liste bleibt stehen, damit der Fehlschlag sichtbar ist */ } + } catch (err) { + toast.error(fehlertext(err, 'Could not empty the trash')) + } setEmptying(false) setConfirmEmpty(false) } diff --git a/frontend/src/pages/admin/AuditTab.tsx b/frontend/src/pages/admin/AuditTab.tsx index 3329dca..3a64d6e 100644 --- a/frontend/src/pages/admin/AuditTab.tsx +++ b/frontend/src/pages/admin/AuditTab.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react' import RescanCard from './RescanCard' import { adminApi, type AuditEntry, type OcrStatus } from '../../api/client' +import { toast, fehlertext } from '../../components/Toasts' import { formatBytes } from './ui' // Audit-Log, Speicher-Bereinigung und der Stand der Texterkennung – @@ -154,7 +155,8 @@ function StorageCleanup() { const [derived, setDerived] = useState<{ prefix: string; files: number; bytes: number }[]>([]) useEffect(() => { - adminApi.derivedStorage().then(setDerived).catch(() => {}) + adminApi.derivedStorage().then(setDerived) + .catch(err => toast.error(fehlertext(err, 'Could not load the derived-storage figures'))) }, []) async function run() { @@ -254,7 +256,8 @@ export default function AuditTab() { const [loading, setLoading] = useState(true) useEffect(() => { - adminApi.listAuditActions().then(a => setActions(a ?? [])).catch(() => {}) + adminApi.listAuditActions().then(a => setActions(a ?? [])) + .catch(err => toast.error(fehlertext(err, 'Could not load the action list'))) }, []) useEffect(() => { diff --git a/frontend/src/pages/admin/ThemingTab.tsx b/frontend/src/pages/admin/ThemingTab.tsx index 5dc8677..d303871 100644 --- a/frontend/src/pages/admin/ThemingTab.tsx +++ b/frontend/src/pages/admin/ThemingTab.tsx @@ -3,6 +3,7 @@ import { adminApi, type AppSettings } from '../../api/client' import { applyTheme } from '../../theme' import { useAppSettings } from '../../hooks/useAppSettings' import ConfirmDialog from '../../components/ConfirmDialog' +import { toast, fehlertext } from '../../components/Toasts' import { Card, Btn, Input } from './ui' // Erscheinungsbild: Name, Farben, Logo und App-Symbol. @@ -178,7 +179,7 @@ export default function ThemingTab() { useEffect(() => { adminApi.getSettings() .then(data => setSettings(data ?? {})) - .catch(() => {}) + .catch(err => toast.error(fehlertext(err, 'Could not load the settings'))) .finally(() => setLoading(false)) }, []) @@ -199,7 +200,10 @@ export default function ThemingTab() { cloud_name: settings.cloud_name, primary_color: settings.primary_color, accent_color: settings.accent_color, - }).catch(() => null) + }).catch(err => { + toast.error(fehlertext(err, 'Could not save the settings')) + return null + }) setSaving(false) if (result) { setSettings(result)