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.
Neu: components/Toasts.tsx. Kein Context, sondern Modulzustand, damit auch
api/client.ts melden kann, ohne Komponente zu sein. Löschen, Umbenennen und
Verschieben bekommen ein „Undo" – beim Löschen ist das der Papierkorb plus
einen Aufruf, kostet also fast nichts. Ordner löschen bekommt bewusst keines:
Der Ordner ist wirklich weg, nur seine Dateien liegen im Papierkorb, und ein
Knopf, der etwas anderes tut als er verspricht, wäre schlimmer als keiner.
Dazu die erste Animation im Projekt – und gleich die Regel dazu:
prefers-reduced-motion wird respektiert.
**jsdom ist jetzt in den devDependencies**, und der erste DOM-Test hat sofort
einen Fehler in der Behebung von vorgestern gefunden:
Der Zähler für selbst ausgelöste Verlaufsschritte setzt voraus, dass jedes
`back()` auch ein `popstate` nach sich zieht. Das gilt nicht – folgt im selben
Durchlauf ein `pushState`, verwirft der Browser den Sprung. Der Zähler blieb
dann 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 noch obenauf liegt. Nur dann kommt es zum Sprung – und dann folgt das
`popstate` garantiert. Der Zuhörer hängt am Modul statt an der Ansicht; vorher
entfernte ihn deren Aufräumer, bevor der eigene Sprung ausgeführt war.
Ehrlich dokumentiert: Den ursprünglichen Fehler bilden diese Tests **nicht**
ab. jsdom verwirft den Sprung, das verhängnisvolle popstate entsteht dort nie
– mit der alten, kaputten Fassung blieben alle Tests grün. Dafür bräuchte es
einen echten Browser. Steht so in TESTING.md und im Test selbst.
Geprüft: tsc sauber, 41 Vitest-Tests (4 neue unter Mutation nachgewiesen),
alle 234 Prüfungen der sieben Selbsttest-Suiten grün.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
165 lines
6.3 KiB
TypeScript
165 lines
6.3 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react'
|
||
import { filesApi } from '../api/client'
|
||
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'
|
||
import SharedPage from '../pages/SharedPage'
|
||
import SharedWithMePage from '../pages/SharedWithMePage'
|
||
import AdminPage from '../pages/AdminPage'
|
||
import ProfilePage from '../pages/ProfilePage'
|
||
import TagsPage from '../pages/TagsPage'
|
||
import CalendarPage from '../pages/CalendarPage'
|
||
import { UserMenuProvider } from './UserMenu'
|
||
import { wideEnough } from '../breakpoints'
|
||
|
||
export type { View }
|
||
|
||
interface Props {
|
||
user: User
|
||
onLogout: () => Promise<void>
|
||
/** Lädt den Nutzer neu – für Speicherverbrauch und Anzeigename. */
|
||
onUserChanged: () => Promise<void>
|
||
}
|
||
|
||
export default function Shell({ user, onLogout, onUserChanged }: Props) {
|
||
const nav = useNavHistory()
|
||
const { view, folderPath } = nav
|
||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||
|
||
// Am Finger: vom linken Rand nach rechts wischen holt die Leiste, nach
|
||
// links wischen schickt sie zurück. Mit Maus passiert nichts.
|
||
useEdgeSwipe({
|
||
open: sidebarOpen,
|
||
onOpen: useCallback(() => setSidebarOpen(true), []),
|
||
onClose: useCallback(() => setSidebarOpen(false), []),
|
||
})
|
||
// Ist die Leiste am Telefon aufgeschoben, holt die Zurück-Geste sie
|
||
// zurück – sie ist dann das Oberste auf dem Schirm, und genau darauf zielt
|
||
// die Bewegung.
|
||
useBackClose({
|
||
active: sidebarOpen,
|
||
onClose: useCallback(() => { setSidebarOpen(false) }, []),
|
||
})
|
||
|
||
// Wird das Gerät aufgeklappt, während die Leiste aufgeschoben war, steht sie
|
||
// ab da ohnehin fest im Bild. Bliebe „aufgeschoben" dabei stehen, schluckte
|
||
// die Zurück-Geste einmal ins Leere – sie zielte auf etwas, das nichts mehr
|
||
// verdeckt.
|
||
useEffect(() => {
|
||
if (typeof matchMedia !== 'function') return
|
||
const mq = matchMedia(wideEnough)
|
||
const auf = () => { if (mq.matches) setSidebarOpen(false) }
|
||
auf()
|
||
mq.addEventListener('change', auf)
|
||
return () => mq.removeEventListener('change', auf)
|
||
}, [])
|
||
|
||
// Hochgezählt, wenn etwas auf den Papierkorb gezogen wurde – die Dateiliste
|
||
// hängt daran und holt sich daraufhin neu.
|
||
const [trashBump, setTrashBump] = useState(0)
|
||
|
||
/**
|
||
* Auf den Papierkorb gezogene Dateien.
|
||
*
|
||
* Bewusst **ohne** Rückfrage: Der Weg dorthin ist eine bewusste Bewegung mit
|
||
* gedrückter Maustaste, kein Klick, der danebengehen kann – und der
|
||
* Papierkorb ist umkehrbar. Ein Dialog nach jedem Zug wäre die Art
|
||
* Sicherheitsabfrage, die man nach dem dritten Mal blind wegklickt.
|
||
*/
|
||
async function trashDrop(fileIds: string[]) {
|
||
await filesApi.deleteMany(fileIds)
|
||
setTrashBump(n => n + 1)
|
||
await onUserChanged()
|
||
}
|
||
|
||
// Aus der Seitenleiste kennen wir nur Kennung und Name – die Kette darueber
|
||
// holt die Liste nicht mit, also faengt der Pfad hier beim Ordner selbst an.
|
||
function navigateToFolder(folderId: string, folderName: string) {
|
||
setSidebarOpen(false)
|
||
const stub: FolderItem = {
|
||
id: folderId, owner_id: null, group_id: null, parent_id: null,
|
||
name: folderName, created_at: '',
|
||
}
|
||
nav.setFolderPath([stub])
|
||
}
|
||
|
||
return (
|
||
<UserMenuProvider value={{
|
||
user,
|
||
onNavigate: (v) => { nav.setView(v); setSidebarOpen(false) },
|
||
onLogout,
|
||
onOpenSidebar: () => setSidebarOpen(true),
|
||
}}>
|
||
<div className="aether-vh" style={{ display: 'flex', overflow: 'hidden' }}>
|
||
{/* 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. */}
|
||
<Toaster />
|
||
|
||
{/* 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
|
||
machte sie unanklickbar – sichtbar, aber taub. */}
|
||
{sidebarOpen && (
|
||
<div
|
||
className="aether-scrim"
|
||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 40 }}
|
||
onClick={() => setSidebarOpen(false)}
|
||
/>
|
||
)}
|
||
|
||
<Sidebar
|
||
user={user}
|
||
view={view}
|
||
activeTagId={nav.tagId}
|
||
onOpenTag={nav.setTag}
|
||
activeCorrespondentId={nav.correspondentId}
|
||
onOpenCorrespondent={nav.setCorrespondent}
|
||
onNavigate={(v) => { nav.setView(v); setSidebarOpen(false) }}
|
||
onNavigateToFolder={navigateToFolder}
|
||
onTrashDrop={trashDrop}
|
||
open={sidebarOpen}
|
||
/>
|
||
|
||
{/* Main content */}
|
||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, overflow: 'hidden' }}>
|
||
{view === 'files' && (
|
||
<FileBrowser
|
||
folderPath={folderPath}
|
||
onNavigate={nav.setFolderPath}
|
||
onUserChanged={onUserChanged}
|
||
reloadSignal={trashBump}
|
||
/>
|
||
)}
|
||
{view === 'calendar' && <CalendarPage />}
|
||
{view === 'trash' && <TrashPage onUserChanged={onUserChanged} />}
|
||
{view === 'incoming' && <SharedWithMePage onOpenFolder={navigateToFolder} />}
|
||
{view === 'shared' && <SharedPage />}
|
||
{view === 'admin' && <AdminPage />}
|
||
{view === 'profile' && <ProfilePage user={user} onUserChanged={onUserChanged} />}
|
||
{/* Etikett und Absender teilen sich die Seite – es ist dieselbe Frage
|
||
(„alles mit diesem Merkmal"), nur ein anderes Merkmal. */}
|
||
{(view === 'tags' || view === 'from') && (
|
||
<TagsPage
|
||
tagId={view === 'tags' ? nav.tagId : undefined}
|
||
correspondentId={view === 'from' ? nav.correspondentId : undefined}
|
||
onOpenFolder={(id, name) => {
|
||
// Klick auf einen Treffer springt zum Ordner – dieselbe Regel wie
|
||
// in der Suche: Geöffnet wird per Doppelklick auf die Datei.
|
||
if (id) navigateToFolder(id, name)
|
||
else nav.setFolderPath([])
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
</div>
|
||
</UserMenuProvider>
|
||
)
|
||
}
|