feat(ui): Rückmeldung zu Ende verdrahtet – und zwei Lügen entfernt

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 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-19 20:59:46 +02:00
1 parent 16258640ad
commit 8586664fae
10 files changed
+133 -35

No files matched your search

+37
View File
@@ -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
+13 -4
View File
@@ -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)
}
}
+3 -1
View File
@@ -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<AdminUser[]>([])
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 }[] = [
+8 -8
View File
@@ -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}”`))
}
}
+22 -6
View File
@@ -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)
+22 -6
View File
@@ -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()
+2 -1
View File
@@ -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))
}, [])
+15 -5
View File
@@ -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)
}
+5 -2
View File
@@ -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(() => {
+6 -2
View File
@@ -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)