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:
1 parent
16258640ad
commit
8586664fae
10 files changed
+133
-35
No files matched your search
+37
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }[] = [
|
||||
|
||||
@@ -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}”`))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user