diff --git a/TESTING.md b/TESTING.md index e1826a2..69fe564 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1402,6 +1402,26 @@ Zwei Riegel, weil einer allein zu leicht wieder aufgeht: - [ ] Auf dem Handy gegenprüfen: Ein Bild lässt sich mit dem Finger nicht mehr aus der Vorschau ziehen (dort war das Wischen ohnehin gemeint) +## Sammel-Löschen und Ziehen auf den Papierkorb (24.08.2026) + +Zwei Wege zum selben Ziel: der Knopf in der Auswahlleiste (mit Rückfrage) und +das Ziehen auf den Papierkorb in der Seitenleiste (ohne). + +Gemessen an einem Testordner mit fünf Dateien: + +| Handlung | Erwartet | Gemessen | +|---|---|---| +| drei markieren, eine davon auf den Papierkorb ziehen | alle drei | `aether-file-ids` trägt 3 IDs, 3 DELETE, Kacheln 5 → 2 | +| über dem Papierkorb schweben | rot umrandet | `rgb(239,68,68)` + Rahmen; danach wieder grau | +| zwei markieren, „Move to trash" | Rückfrage, dann weg | Dialog „Move 2 files…", 2 DELETE, Kacheln 0, Leiste weg | + +Der Sammel-Aufruf läuft mit vier gleichzeitigen Anfragen. Fehlgeschlagene +Dateien bleiben markiert, statt die Auswahl zu verlieren. + +- [ ] Am Finger gegenprüfen: Ziehen auf den Papierkorb setzt eine Maus voraus, + auf dem Handy bleibt der Knopf der Weg +- [ ] Mit vielen Dateien (50+) auf der produktiven Instanz gegenprüfen + ## Automatisiert geprüft (22.08.2026) Diese Punkte hat Claude per API/`curl` gegen Wegwerf-Konten durchgespielt; sie diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index a833450..e3950de 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -598,6 +598,38 @@ export const filesApi = { delete: (id: string) => request('DELETE', `/files/${id}`), + /** + * Mehrere Dateien in den Papierkorb legen. + * + * Vier gleichzeitig, nicht alle auf einmal: Bei achtzig markierten Dateien + * schickte der Browser sonst achtzig Anfragen in derselben Sekunde – die + * Verbindungsgrenze staut sie ohnehin, und der Server verschiebt für jede + * einen Blob. Nacheinander wäre umgekehrt spürbar zäh. + * + * Der Rückgabewert nennt, was **nicht** ging. Eine einzelne fehlgeschlagene + * Datei darf die übrigen nicht aufhalten – sie soll nur nicht verschwiegen + * werden. + */ + deleteMany: async (ids: string[]): Promise<{ ok: string[]; failed: string[] }> => { + const ok: string[] = [] + const failed: string[] = [] + const queue = [...ids] + const worker = async () => { + for (;;) { + const id = queue.shift() + if (!id) return + try { + await request('DELETE', `/files/${id}`) + ok.push(id) + } catch { + failed.push(id) + } + } + } + await Promise.all(Array.from({ length: Math.min(4, ids.length) }, worker)) + return { ok, failed } + }, + rename: (id: string, name: string) => request('PATCH', `/files/${id}/rename`, { name }), diff --git a/frontend/src/components/FileViews.tsx b/frontend/src/components/FileViews.tsx index 312ddd8..7a71101 100644 --- a/frontend/src/components/FileViews.tsx +++ b/frontend/src/components/FileViews.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react' +import type React from 'react' import { type Correspondent, type FileItem, type FolderItem, type Tag } from '../api/client' import { TagRow } from './TagDialog' import TagChip from './TagChip' @@ -58,6 +59,24 @@ function useCoarsePointer(): boolean { * Handler, deshalb genügt hier das Umschalten der Eigenschaft. Dateien * schreiben ihren Handler selbst aus – warum, steht dort. */ +/** + * Was genau wird gezogen – diese eine Datei oder die ganze Auswahl? + * + * Zieht man eine Datei, die markiert ist, meint man die Markierung. Zieht man + * eine unmarkierte, meint man genau sie – die Markierung bleibt unberührt. + * Dieselbe Regel wie in jedem Dateimanager. + * + * `aether-file-id` bleibt daneben stehen, weil das Verschieben in einen Ordner + * damit arbeitet; `aether-file-ids` ist die Liste für alles, was mehrere + * verträgt. + */ +export function startFileDrag(e: React.DragEvent, fileId: string, selected?: Set) { + const ids = selected?.has(fileId) ? [...selected] : [fileId] + e.dataTransfer.setData('aether-file-id', fileId) + e.dataTransfer.setData('aether-file-ids', JSON.stringify(ids)) + e.dataTransfer.effectAllowed = 'move' +} + function openGesture(coarse: boolean, open: () => void) { return coarse ? { onClick: open } : { onDoubleClick: open } } @@ -180,10 +199,7 @@ export default function GridView({ folders, files, onFolderOpen, onFolderMenu, o
{ - e.dataTransfer.setData('aether-file-id', file.id) - e.dataTransfer.effectAllowed = 'move' - }} + onDragStart={e => startFileDrag(e, file.id, selected)} ref={file.id === highlightId ? highlightRef : undefined} onDoubleClick={() => { if (!coarse) onFileOpen(file) }} onClick={e => { @@ -391,10 +407,7 @@ export function ListView({ folders, files, onFolderOpen, onFolderMenu, onFileMen
{ - e.dataTransfer.setData('aether-file-id', file.id) - e.dataTransfer.effectAllowed = 'move' - }} + onDragStart={e => startFileDrag(e, file.id, selected)} ref={file.id === highlightId ? highlightRef : undefined} style={{ display: 'flex', alignItems: 'center', gap: 12, diff --git a/frontend/src/components/Shell.tsx b/frontend/src/components/Shell.tsx index 37810b1..08f146b 100644 --- a/frontend/src/components/Shell.tsx +++ b/frontend/src/components/Shell.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { filesApi } from '../api/client' import type { FolderItem, User } from '../api/client' import { useNavHistory, type View } from '../hooks/useNavHistory' import Sidebar from './Sidebar' @@ -24,6 +25,23 @@ export default function Shell({ user, onLogout, onUserChanged }: Props) { const nav = useNavHistory() const { view, folderPath } = nav const [sidebarOpen, setSidebarOpen] = useState(false) + // 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. @@ -61,6 +79,7 @@ export default function Shell({ user, onLogout, onUserChanged }: Props) { onOpenCorrespondent={nav.setCorrespondent} onNavigate={(v) => { nav.setView(v); setSidebarOpen(false) }} onNavigateToFolder={navigateToFolder} + onTrashDrop={trashDrop} open={sidebarOpen} /> @@ -72,6 +91,7 @@ export default function Shell({ user, onLogout, onUserChanged }: Props) { folderPath={folderPath} onNavigate={nav.setFolderPath} onUserChanged={onUserChanged} + reloadSignal={trashBump} /> )} {view === 'trash' && } diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 29cd424..ef59e83 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -19,6 +19,8 @@ interface Props { view: View onNavigate: (v: View) => void onNavigateToFolder: (folderId: string, folderName: string) => void + /** Auf den Papierkorb gezogene Dateien. Fehlt sie, ist er keine Ablagefläche. */ + onTrashDrop?: (fileIds: string[]) => Promise | void open: boolean } @@ -253,7 +255,7 @@ function FavoriteRow({ fav, onNavigate, onRemove, onToggleExpand, onDragStart, o ) } -export default function Sidebar({ user, view, activeTagId, onOpenTag, +export default function Sidebar({ user, view, activeTagId, onOpenTag, onTrashDrop, activeCorrespondentId, onOpenCorrespondent, onNavigate, onNavigateToFolder, open }: Props) { const { cloudName, logoUrl } = useAppSettings() @@ -310,6 +312,7 @@ export default function Sidebar({ user, view, activeTagId, onOpenTag, const [favorites, setFavorites] = useState([]) const [dragOverId, setDragOverId] = useState(null) + const [trashOver, setTrashOver] = useState(false) const dragItem = useRef(null) useEffect(() => { @@ -405,19 +408,53 @@ export default function Sidebar({ user, view, activeTagId, onOpenTag,