diff --git a/TESTING.md b/TESTING.md index 8422571..dc24df1 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1468,6 +1468,63 @@ das war die Asymmetrie, über die man stolperte. --- +## Meldungen mit Rückgängig (18.09.2026) + +Vorher bestätigte **nichts** irgendetwas, und Fehler verschwanden still: Die +Handler fingen sie mit `catch { /* bleibt stehen */ }` ab. Wer eine Datei +löschen wollte und nicht durfte, sah genau dasselbe wie jemand, bei dem es +klappte – nichts. + +- [ ] Datei löschen → Meldung mit „Undo", Datei ist zurück 🔎 +- [ ] Mehrere löschen → eine Meldung, „Undo" holt alle zurück +- [ ] Umbenennen → „Undo" stellt den alten Namen her +- [ ] Verschieben → „Undo" bringt die Datei an den alten Ort +- [ ] Ordner löschen → **kein** „Undo", stattdessen der Hinweis auf den + Papierkorb (der Ordner selbst ist wirklich weg) +- [ ] Ohne Schreibrecht löschen → rote Meldung mit dem Satz des Servers +- [ ] Netz trennen, dann löschen → „No connection to the server." +- [ ] Meldung verschwindet nach 4 s, mit Knopf erst nach 10 +- [ ] Fünfte Meldung verdrängt die erste (höchstens vier stehen) +- [ ] Betriebssystem auf „Bewegung reduzieren" → keine Einblendung + +## 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 +öffnen. Beim Wechsel räumt React erst die alte Ansicht ab; deren Aufräumer rief +`history.back()`, das verzögert eintraf und den Eintrag verbrauchte, den der +Editor gerade angelegt hatte. + +**Die erste Behebung war selbst fehlerhaft** und ist erst durch die neuen +jsdom-Tests aufgefallen: Sie zählte selbst ausgelöste Sprünge mit. Das setzt +voraus, dass jedes `back()` auch ein `popstate` nach sich zieht – und das gilt +nicht. Folgt im selben Durchlauf ein `pushState`, verwirft der Browser den +Sprung. Der Zähler blieb dann für immer stehen, und die **nächste** echte +Zurück-Geste wurde verschluckt: Statt die Ansicht zu schließen, hätte man die +Anwendung verlassen. + +Jetzt wird erst im nächsten Durchlauf entschieden **und** geprüft, ob der +eigene Eintrag überhaupt noch obenauf liegt. Nur dann kommt es zum Sprung – und +dann folgt das `popstate` garantiert. + +- [x] Vier jsdom-Tests in `useBackClose.test.tsx`, unter Mutation nachgewiesen: + Der Schalter bleibt stehen → der Übergangstest wird rot. + +> **Was diese Tests nicht leisten.** Den ursprünglichen Fehler bilden sie +> **nicht** ab. jsdom verwirft einen noch nicht ausgeführten Sprung, sobald +> `pushState` folgt – das verhängnisvolle `popstate` entsteht dort also nie. +> Nachgeprüft, indem die alte, kaputte Fassung wieder eingesetzt wurde: **alle +> Tests blieben grün.** Chrome auf dem Telefon verhält sich anders, und dort +> war der Fehler zu sehen. +> +> Wer diesen Fall absichern will, braucht einen echten Browser (Playwright). +> Bis dahin ist der Beleg ein Mensch mit einem Telefon. + +Seit dem 18.09.2026 ist **jsdom** in den devDependencies. Die Vitest-Umgebung +bleibt `node` (schnell); wer ein DOM braucht, schreibt +`// @vitest-environment jsdom` an den Anfang seiner Datei. + +--- + ## Absender / Korrespondent (23.08.2026) Das zweite Ordnungsmerkmal neben den Etiketten. Unterschied: genau **einer** diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 92e64ac..3cdf2d3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,6 +21,7 @@ "@testing-library/user-event": "^14.6.1", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", + "jsdom": "^26.1.0", "tailwindcss": "^4.3.1", "typescript": "^6.0.3", "vitest": "^4.1.9" @@ -41,6 +42,27 @@ "ajv": ">=8" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1401,6 +1423,121 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -2955,6 +3092,16 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -3308,12 +3455,77 @@ "node": ">=8" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "dev": true }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -3378,6 +3590,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -3488,6 +3707,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.24.2", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", @@ -4043,6 +4275,60 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/idb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", @@ -4276,6 +4562,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -4476,6 +4769,83 @@ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -4863,6 +5233,13 @@ "node": ">=18" } }, + "node_modules/nwsapi": { + "version": "2.2.27", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz", + "integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==", + "dev": true, + "license": "MIT" + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -4935,6 +5312,19 @@ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==" }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -5291,6 +5681,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-array-concat": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", @@ -5340,6 +5737,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -5689,6 +6106,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", @@ -5789,6 +6213,39 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", @@ -6201,11 +6658,48 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==" }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", @@ -6513,6 +7007,45 @@ "workbox-core": "7.4.1" } }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 76d44b0..ad228b3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ "@testing-library/user-event": "^14.6.1", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", + "jsdom": "^26.1.0", "tailwindcss": "^4.3.1", "typescript": "^6.0.3", "vitest": "^4.1.9" diff --git a/frontend/src/components/Shell.tsx b/frontend/src/components/Shell.tsx index 5ae4614..bbf10bc 100644 --- a/frontend/src/components/Shell.tsx +++ b/frontend/src/components/Shell.tsx @@ -4,6 +4,7 @@ import type { FolderItem, User } from '../api/client' import { useNavHistory, type View } from '../hooks/useNavHistory' import { useEdgeSwipe } from '../hooks/useEdgeSwipe' import { useBackClose } from '../hooks/useBackClose' +import { Toaster } from './Toasts' import Sidebar from './Sidebar' import FileBrowser from '../pages/FileBrowser' import TrashPage from '../pages/TrashPage' @@ -95,6 +96,11 @@ export default function Shell({ user, onLogout, onUserChanged }: Props) { onOpenSidebar: () => setSidebarOpen(true), }}>
+ {/* Die Meldungen liegen über allem und sind an genau einer Stelle + eingehängt – absetzen darf sie jedes Modul, anzeigen tut sie nur + diese eine Anzeige. */} + + {/* Der abdunkelnde Grund hinter der aufgeschobenen Leiste. Die Klasse blendet ihn aus, sobald die Leiste fest im Bild steht: Dort liegt sie im Fluss und **ohne** z-index, ein Grund darüber diff --git a/frontend/src/components/Toasts.tsx b/frontend/src/components/Toasts.tsx new file mode 100644 index 0000000..1a3d3c4 --- /dev/null +++ b/frontend/src/components/Toasts.tsx @@ -0,0 +1,206 @@ +import { useEffect, useState } from 'react' +import { APIError } from '../api/client' + +/** + * Kurze Rückmeldungen am unteren Rand – und, wo es geht, ein „Rückgängig". + * + * Vorher bestätigte **nichts** irgendetwas: Verschieben, Umbenennen, Teilen + * geschah, und die Oberfläche schwieg. Schlimmer noch das Scheitern – die + * Handler fingen ihre Fehler mit `catch { /* bleibt stehen *\/ }` ab. Wer eine + * Datei löschen wollte und keine Berechtigung hatte, sah exakt dasselbe wie + * jemand, bei dem es geklappt hat: nichts. + * + * **Kein Context, sondern Modulzustand.** Eine Meldung soll von überall + * abgesetzt werden können – auch aus `api/client.ts`, das keine Komponente + * ist und in keinem Anbieter steckt. Derselbe Weg wie bei `useBackClose`: + * ein Zustand im Modul, und die Anzeige hört zu. + */ + +export type ToastTone = 'info' | 'error' + +export interface ToastAction { + label: string + /** Läuft genau einmal; danach schließt die Meldung. */ + run: () => void | Promise +} + +interface Toast { + id: number + text: string + tone: ToastTone + action?: ToastAction +} + +let toasts: Toast[] = [] +let seq = 0 +const hoerer = new Set<(t: Toast[]) => void>() + +/** Höchstens so viele gleichzeitig. Darüber stapelt sich Lärm, kein Inhalt. */ +const maxSichtbar = 4 + +/** + * Wie lange eine Meldung steht. + * + * Mit Knopf deutlich länger: Man muss sie lesen, verstehen, *entscheiden* und + * dann treffen. Vier Sekunden reichen zum Lesen, nicht zum Entscheiden – ein + * „Rückgängig", das weg ist, bevor die Hand da war, ist schlimmer als keines, + * weil es die Erwartung geweckt hat. + */ +const dauerSchlicht = 4000 +const dauerMitKnopf = 10000 + +function melden() { + for (const h of hoerer) h(toasts) +} + +function schliessen(id: number) { + toasts = toasts.filter(t => t.id !== id) + melden() +} + +function zeigen(text: string, tone: ToastTone, action?: ToastAction): number { + const id = ++seq + toasts = [...toasts, { id, text, tone, action }].slice(-maxSichtbar) + melden() + // Bewusst `setTimeout` statt `window.setTimeout`: Dieselbe Funktion, aber + // ohne `window` läuft der Zustandsteil dieser Datei auch im Test, der ohne + // DOM startet. + setTimeout(() => schliessen(id), action ? dauerMitKnopf : dauerSchlicht) + return id +} + +export const toast = { + /** Es hat geklappt. */ + info: (text: string) => zeigen(text, 'info'), + /** Es hat nicht geklappt – bleibt genauso lange stehen, das ist Absicht: + * Eine Fehlermeldung, die man festhalten will, steht in der Konsole. */ + error: (text: string) => zeigen(text, 'error'), + /** + * Es hat geklappt, und man kann es zurücknehmen. + * + * Der eigentliche Grund, warum es diese Datei gibt. Löschen ist bei uns ein + * Papierkorb, Wiederherstellen ein einziger Aufruf – das Rückgängig kostet + * also fast nichts und nimmt dem Löschen den Schrecken. + */ + undo: (text: string, run: ToastAction['run'], label = 'Undo') => + zeigen(text, 'info', { label, run }), + schliessen, +} + +/** Einmal im Gerüst einhängen. */ +export function Toaster() { + const [liste, setListe] = useState(toasts) + const [laufend, setLaufend] = useState(null) + + useEffect(() => { + hoerer.add(setListe) + return () => { hoerer.delete(setListe) } + }, []) + + if (liste.length === 0) return null + + return ( +
+ {liste.map(t => ( +
+ {t.text} + + {t.action && ( + + )} + + +
+ ))} +
+ ) +} + +/** + * Aus einem Fehler einen Satz machen, den man lesen kann. + * + * Der Server schreibt seine Meldungen selbst und in ganzen Sätzen („You only + * have read access to this file") – die ist besser als alles, was wir hier + * erfinden könnten, und wird deshalb bevorzugt. Der Rückfalltext greift nur, + * wenn gar nichts da ist. + * + * Zwei Fälle bekommen einen eigenen Satz, weil die Rohfassung nichts sagt: + * `403` ohne Text, und der Abbruch ohne Antwort (Status 0) – „Unknown error" + * hilft niemandem, „keine Verbindung" schon. + */ +export function fehlertext(err: unknown, rückfall: string): string { + if (err instanceof APIError) { + if (err.status === 0) return 'No connection to the server.' + if (err.message && err.message !== 'Unknown error') return err.message + if (err.status === 403) return 'You do not have permission for that.' + return rückfall + } + return err instanceof Error && err.message ? err.message : rückfall +} diff --git a/frontend/src/hooks/useBackClose.test.tsx b/frontend/src/hooks/useBackClose.test.tsx new file mode 100644 index 0000000..3542c67 --- /dev/null +++ b/frontend/src/hooks/useBackClose.test.tsx @@ -0,0 +1,155 @@ +// @vitest-environment jsdom +import { useState } from 'react' +import { render, screen, act, cleanup } from '@testing-library/react' +import { describe, it, expect, afterEach } from 'vitest' +import { useBackClose } from './useBackClose' + +/** + * **Was diese Tests abdecken – und was ausdrücklich nicht.** + * + * Abgedeckt ist die Rangfolge: Eine Zurück-Geste schließt genau die oberste + * Ansicht, und ein selbst ausgelöster Sprung schließt gar nichts. Der zweite + * Punkt ist nicht theoretisch – er hat hier einen echten Fehler gefangen: Die + * erste Fassung der Behebung zählte selbst ausgelöste Sprünge mit, und der + * Zähler blieb stehen, wenn ein Sprung gar nicht ausgeführt wurde. Die + * **nächste** echte Geste wäre dann verschluckt worden, und statt die Ansicht + * zu schließen, hätte man die Anwendung verlassen. + * + * **Nicht abgedeckt ist der Fehler, der den Anlass gab** (16.09.2026): Aus der + * PDF-Ansicht führte „Edit" in den Editor, und statt ihn zu öffnen, schloss + * sich die Datei. Ursache war ein `history.back()` aus dem Aufräumer der alten + * Ansicht, das verzögert eintraf und den Eintrag der neuen verbrauchte. + * + * Nachgestellt bekommt man das hier **nicht**: jsdom verwirft einen noch nicht + * ausgeführten Sprung, sobald `pushState` folgt – das verhängnisvolle + * `popstate` entsteht dort also nie. Nachgeprüft, indem die alte, kaputte + * Fassung wieder eingesetzt wurde: Alle Tests blieben grün. Chrome auf dem + * Telefon verhält sich anders, und dort war der Fehler zu sehen. + * + * Wer diesen Fall absichern will, braucht einen echten Browser (Playwright). + * Bis dahin ist der Beleg ein Mensch mit einem Telefon – festgehalten in + * TESTING.md. + */ + +/** Wartet, bis der Browser sein `popstate` nachgereicht hat. */ +async function verlaufSetztSich() { + await act(async () => { + await new Promise(r => setTimeout(r, 60)) + }) +} + +function Ansicht({ name, onClose }: { name: string; onClose: () => void }) { + useBackClose({ active: true, onClose }) + return
{name}
+} + +describe('useBackClose', () => { + // Testing Library hängt die Ansichten nach jedem Test aus. Deren Aufräumer + // stößt einen Verlaufsschritt an, der erst danach ankommt – und im nächsten + // Test ein fremdes `popstate` wäre. Also selbst abräumen und abwarten: + // erst die Aushäng-Effekte laufen lassen, dann das `popstate` einsammeln. + afterEach(async () => { + cleanup() + await new Promise(r => setTimeout(r, 0)) + await new Promise(r => setTimeout(r, 80)) + }) + + it('schließt auf die Zurück-Geste des Nutzers', async () => { + function Eine() { + const [offen, setOffen] = useState(true) + return offen ? setOffen(false)} /> :
zu
+ } + + render() + expect(screen.getByText('Offen')).toBeTruthy() + + await act(async () => { window.history.back() }) + await verlaufSetztSich() + + expect(screen.getByText('zu')).toBeTruthy() + }) + + it('schließt immer nur die oberste von zwei Ansichten', async () => { + function Zwei() { + const [unten, setUnten] = useState(true) + const [oben, setOben] = useState(true) + return ( + <> + {unten && setUnten(false)} />} + {oben && setOben(false)} />} + + ) + } + + render() + + await act(async () => { window.history.back() }) + await verlaufSetztSich() + + // Eine Geste, eine Ansicht. Ohne die Rangfolge im Stapel fielen beide auf + // einmal weg – und der Nutzer stünde zwei Ebenen tiefer, als er gewischt + // hat. + expect(screen.queryByText('oben')).toBeNull() + expect(screen.queryByText('unten')).toBeTruthy() + + await act(async () => { window.history.back() }) + await verlaufSetztSich() + expect(screen.queryByText('unten')).toBeNull() + }) + + it('verschluckt die Geste nicht, nachdem eine Ansicht in eine andere überging', async () => { + // Der Übergang Vorschau → „Edit" → Editor: Die alte Ansicht geht, die neue + // kommt im selben Rendern. Ihr Aufräumer darf den Verlauf dann **nicht** + // anfassen – und vor allem nichts merken, was er später verschlucken + // würde. Genau daran ist die erste Fassung gescheitert: Der Schalter blieb + // stehen, und die nächste echte Geste verpuffte. + function Wechsel() { + const [wo, setWo] = useState<'vorschau' | 'editor' | 'nichts'>('vorschau') + return ( + <> + {wo === 'vorschau' && setWo('nichts')} />} + {wo === 'editor' && setWo('nichts')} />} + + + ) + } + + render() + await act(async () => { screen.getByText('edit').click() }) + await verlaufSetztSich() + expect(screen.queryByText('Editor')).toBeTruthy() + + // Und jetzt die Zurück-Geste. Sie muss ankommen. + await act(async () => { window.history.back() }) + await verlaufSetztSich() + expect(screen.queryByText('Editor')).toBeNull() + }) + + it('verschluckt die nächste Geste nicht, wenn eine Ansicht per Knopf ging', async () => { + // Genau der Fall, an dem die erste Fassung scheiterte: Die obere Ansicht + // geht über einen Knopf zu – und danach muss eine echte Geste wieder + // greifen, statt vom Aufräumer der oberen verschluckt zu werden. + function Zwei() { + const [unten, setUnten] = useState(true) + const [oben, setOben] = useState(true) + return ( + <> + {unten && setUnten(false)} />} + {oben && setOben(false)} />} + + + ) + } + + render() + await act(async () => { screen.getByText('schliessen').click() }) + await verlaufSetztSich() + + expect(screen.queryByText('oben')).toBeNull() + expect(screen.queryByText('unten')).toBeTruthy() + + await act(async () => { window.history.back() }) + await verlaufSetztSich() + expect(screen.queryByText('unten')).toBeNull() + }) +}) diff --git a/frontend/src/hooks/useBackClose.ts b/frontend/src/hooks/useBackClose.ts index 41a4dc3..ab3e7bd 100644 --- a/frontend/src/hooks/useBackClose.ts +++ b/frontend/src/hooks/useBackClose.ts @@ -22,26 +22,66 @@ import { useEffect, useRef } from 'react' * und nur der oberste reagiert. */ -/** Kennungen der offenen Ansichten, unterste zuerst. */ -let stack: number[] = [] +/** Die offenen Ansichten, unterste zuerst – mit ihrem Schließen-Aufruf. */ +let stack: { id: number; close: () => boolean | void }[] = [] let seq = 0 /** - * Wie viele `popstate` noch von **uns selbst** stammen und nicht vom Nutzer. + * Ein von **uns** ausgelöster Verlaufsschritt ist unterwegs. * - * Der Aufräumer unten ruft `history.back()`, wenn eine Ansicht über einen - * Knopf geschlossen wurde – ihr Eintrag muss ja weg. Nur wirkt `back()` nicht - * sofort, sondern erst im nächsten Durchlauf. Öffnet in derselben Runde eine - * *andere* Ansicht (Vorschau → „Edit" → Editor), liegt deren frischer Eintrag - * dann schon obenauf – und das verspätete `back()` verbraucht ihn. Der Editor - * bekam ein `popstate`, hielt es für die Zurück-Geste und schloss sich sofort - * wieder: Aus „Edit" wurde „Datei zu". + * Der Aufräumer unten geht zurück, wenn eine Ansicht über einen Knopf + * geschlossen wurde – ihr Eintrag muss ja weg. Das daraus folgende `popstate` + * ist keine Geste des Nutzers und darf nichts schließen. * - * Deshalb zählen wir die selbst ausgelösten Schritte mit und verschlucken - * genau so viele `popstate`. Die Zurück-Geste des Nutzers erkennt man daran, - * dass keiner offen ist. + * **Warum ein Schalter und kein Zähler.** Der erste Versuch zählte die selbst + * ausgelösten Schritte mit. Das setzt voraus, dass jedes `back()` auch ein + * `popstate` nach sich zieht – und genau das gilt nicht: Ruft im selben + * Durchlauf jemand `pushState` (die neue Ansicht legt ihren Eintrag an), wird + * der noch nicht ausgeführte Sprung verworfen. Der Zähler blieb dann für immer + * stehen, und die **nächste** echte Zurück-Geste wurde verschluckt – der + * Nutzer verließ die Anwendung, statt die Ansicht zu schließen. Nachgestellt + * in useBackClose.test.tsx. + * + * Deshalb wird unten erst im nächsten Durchlauf entschieden **und** geprüft, + * ob der eigene Eintrag überhaupt noch obenauf liegt. Nur dann kommt es zum + * Sprung – und dann folgt das `popstate` garantiert. */ -let eigeneSchritte = 0 +let unterwegs = false + +/** + * **Ein Zuhörer fürs ganze Modul, nicht einer je Ansicht.** + * + * Hängt er an der Ansicht, entfernt ihn deren Aufräumer – und zwar bevor der + * Sprung ausgeführt ist, den derselbe Aufräumer auslöst. War danach keine + * weitere Ansicht offen, hörte niemand mehr zu. Ein Zuhörer, der so lange + * lebt wie die Seite, hat dieses Loch nicht und kostet nichts: ohne offene + * Ansicht kehrt er sofort um. + */ +let lauscht = false + +function onPop() { + // Von uns selbst ausgelöst? Dann ist nichts zu schließen – der Schritt + // gehört zu einer Ansicht, die bereits weg ist. + if (unterwegs) { + unterwegs = false + return + } + // Nur die oberste reagiert. Eine tiefer liegende hat ihren eigenen + // Zurück-Schritt noch vor sich. + const oben = stack.at(-1) + if (!oben) return + stack = stack.slice(0, -1) + if (oben.close() === false) { + stack = [...stack, oben] + window.history.pushState({ ...window.history.state, [stateKey]: oben.id }, '') + } +} + +function lauschen() { + if (lauscht || typeof window === 'undefined') return + window.addEventListener('popstate', onPop) + lauscht = true +} /** Wir merken uns unseren Eintrag im State des Browsers, um ihn beim * Zurückkommen wiederzuerkennen. */ @@ -67,48 +107,37 @@ export function useBackClose({ active, onClose }: Options) { useEffect(() => { if (!active) return + lauschen() const id = ++seq - stack.push(id) + const eintrag = { id, close: () => close.current() } + stack = [...stack, eintrag] // Den bisherigen State mitnehmen: Darin steckt die Ansicht der Anwendung // (siehe useNavHistory). Ginge er verloren, stünde man nach dem // Schließen in der Wurzel statt im Ordner. window.history.pushState({ ...window.history.state, [stateKey]: id }, '') - function push() { - window.history.pushState({ ...window.history.state, [stateKey]: id }, '') - } - - function onPop() { - // Von uns selbst ausgelöst? Dann ist nichts zu schließen – der Schritt - // gehört zu einer Ansicht, die bereits weg ist. - if (eigeneSchritte > 0) { - eigeneSchritte-- - return - } - // Nur der oberste reagiert. Ein tiefer liegender Eintrag hat seinen - // eigenen Zurück-Schritt noch vor sich. - if (stack.at(-1) !== id) return - stack = stack.filter(x => x !== id) - if (close.current() === false) { - stack.push(id) - push() - } - } - - window.addEventListener('popstate', onPop) return () => { - window.removeEventListener('popstate', onPop) // Geschlossen wurde über einen Knopf oder die Escape-Taste: Unser // Eintrag steht noch im Verlauf und muss weg, sonst bräuchte die - // Zurück-Geste danach einen Schritt ins Leere. - if (stack.at(-1) === id) { - stack = stack.filter(x => x !== id) - eigeneSchritte++ + // Zurück-Geste danach einen Schritt ins Leere. Lagen andere Ansichten + // darüber, ist unser Eintrag nicht mehr der oberste – dann räumt deren + // Aufräumer auf, und wir gehen nicht zurück. + const warOben = stack.at(-1)?.id === id + stack = stack.filter(x => x.id !== id) + if (!warOben) return + + // **Erst im nächsten Durchlauf entscheiden.** Öffnet in derselben Runde + // eine andere Ansicht, hat die bis dahin ihren Eintrag angelegt und + // liegt obenauf – dann gehört der Verlauf ihr, und wir rühren ihn nicht + // an. Unser Eintrag bleibt begraben; das kostet einmal einen + // Zurück-Schritt, der nichts sichtbar tut, und ist der ruhigere Fehler. + queueMicrotask(() => { + if (window.history.state?.[stateKey] !== id) return + unterwegs = true window.history.back() - } else { - stack = stack.filter(x => x !== id) - } + }) } }, [active]) } + diff --git a/frontend/src/index.css b/frontend/src/index.css index d973bd3..1671b72 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -133,3 +133,25 @@ body { @media (min-width: 720px) { .aether-scrim { display: none; } } + +/* ── Meldungen ─────────────────────────────────────────────────────────────── + Die erste Bewegung im Projekt, deshalb hier gleich die Regel dazu: Jede + Animation braucht ihre Ausnahme für Menschen, die keine wollen. Das ist + keine Kür – für manche löst Bewegung Übelkeit oder Schwindel aus, und das + Betriebssystem fragt sie genau danach. + + Von unten und leicht kleiner: Die Meldung kommt aus der Richtung, in der + sie erscheint, statt aus dem Nichts. 140 ms sind schnell genug, um nicht + zu warten, und lang genug, um nicht zu blinken. */ +@keyframes aether-toast-auf { + from { opacity: 0; transform: translateY(8px) scale(0.98); } + to { opacity: 1; transform: none; } +} + +.aether-toast { + animation: aether-toast-auf 140ms ease-out; +} + +@media (prefers-reduced-motion: reduce) { + .aether-toast { animation: none; } +} diff --git a/frontend/src/pages/FileBrowser.tsx b/frontend/src/pages/FileBrowser.tsx index 0792696..50bbb2d 100644 --- a/frontend/src/pages/FileBrowser.tsx +++ b/frontend/src/pages/FileBrowser.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { - filesApi, foldersApi, versionsApi, downloadZip, tagsChangedEvent, + filesApi, foldersApi, versionsApi, trashApi, downloadZip, tagsChangedEvent, isOfficeFile, type Correspondent, type FileItem, type FolderItem, type SearchHit, type Tag, } from '../api/client' @@ -11,6 +11,7 @@ import FilePreview, { canPreview, kindOf } from '../components/FilePreview' import ContextMenu, { type MenuItem } from '../components/ContextMenu' import UploadQueue from '../components/UploadQueue' import OfficeEditor from '../components/OfficeEditor' +import { toast, fehlertext } from '../components/Toasts' import FileToolbar, { type Sort, type ViewMode } from '../components/files/FileToolbar' import FileDialogs, { type Dialog } from '../components/files/FileDialogs' import { fileMenuItems, folderMenuItems } from '../components/files/menus' @@ -389,7 +390,21 @@ export default function FileBrowser({ // Die Liste wird bewusst nur lokal angepasst (schneller), der belegte // Speicher muss aber vom Server kommen – sonst bleibt der Balken stehen. await onUserChanged?.() - } catch { /* die Datei bleibt stehen */ } + // 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 () => { + try { + await trashApi.restore(file.id) + await load() + await onUserChanged?.() + } catch (err) { + toast.error(fehlertext(err, 'Could not restore the file')) + } + }) + } catch (err) { + toast.error(fehlertext(err, `Could not delete „${file.name}"`)) + } } /** @@ -410,6 +425,21 @@ export default function FileBrowser({ lastPicked.current = null await onUserChanged?.() if (failed.length) await load() + + if (failed.length) { + toast.error(`${failed.length} of ${ids.length} could not be deleted`) + } + if (ok.length) { + toast.undo(`Deleted ${ok.length} ${ok.length === 1 ? 'file' : 'files'}`, async () => { + // Einzeln zurück: Einen Sammelaufruf gibt es nicht, und bei einer + // Handvoll Dateien lohnt er auch keinen neuen Endpunkt. + const wieder = await Promise.allSettled(ok.map(id => trashApi.restore(id))) + const hin = wieder.filter(r => r.status === 'rejected').length + await load() + await onUserChanged?.() + if (hin) toast.error(`${hin} could not be restored`) + }) + } } async function handleRename(file: FileItem, name: string) { @@ -417,7 +447,18 @@ export default function FileBrowser({ try { const updated = await filesApi.rename(file.id, name) setFiles(f => f.map(x => x.id === file.id ? updated : x)) - } catch { /* der alte Name bleibt stehen */ } + const alterName = file.name + 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)) + } catch (err) { + toast.error(fehlertext(err, 'Could not undo the rename')) + } + }) + } catch (err) { + toast.error(fehlertext(err, 'Could not rename the file')) + } } async function handleCreateFolder(name: string) { @@ -433,7 +474,18 @@ export default function FileBrowser({ try { const updated = await foldersApi.rename(folder.id, name) setFolders(f => f.map(x => x.id === folder.id ? updated : x)) - } catch { /* der alte Name bleibt stehen */ } + const alterName = folder.name + 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)) + } catch (err) { + toast.error(fehlertext(err, 'Could not undo the rename')) + } + }) + } catch (err) { + toast.error(fehlertext(err, 'Could not rename the folder')) + } } async function handleDeleteFolder(folder: FolderItem) { @@ -442,15 +494,34 @@ export default function FileBrowser({ await foldersApi.delete(folder.id) setFolders(f => f.filter(x => x.id !== folder.id)) await onUserChanged?.() - } catch { /* der Ordner bleibt stehen */ } + // **Kein Rückgängig.** Die Dateien darin wandern in den Papierkorb, der + // Ordner selbst wird wirklich gelöscht – ein „Undo" müsste ihn neu + // 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`) + } catch (err) { + toast.error(fehlertext(err, `Could not delete „${folder.name}"`)) + } } async function handleMoveFile(file: FileItem, targetFolderId: string | null) { setDialog(null) try { + const alterOrdner = file.folder_id ?? null await filesApi.move(file.id, targetFolderId) await reload() - } catch { /* silent */ } + toast.undo(`Moved „${file.name}"`, async () => { + try { + await filesApi.move(file.id, alterOrdner) + await reload() + } catch (err) { + toast.error(fehlertext(err, 'Could not move it back')) + } + }) + } catch (err) { + toast.error(fehlertext(err, `Could not move „${file.name}"`)) + } } async function handleMoveFolder(folder: FolderItem, targetParentId: string | null) {