feat(files): Sammel-Loeschen und Ziehen auf den Papierkorb
Zwei Wege, weil sie verschiedene Momente treffen: Der Knopf in der Auswahlleiste raeumt eine geplante Auswahl weg, das Ziehen auf den Papierkorb in der Seitenleiste die eine Datei, die einem gerade auffaellt. Zieht man eine markierte Datei, meint man die Markierung - deshalb traegt der Ziehvorgang jetzt zusaetzlich aether-file-ids mit allen markierten IDs. Eine unmarkierte Datei zieht nur sich selbst. Der Knopf fragt nach, das Ziehen nicht: Ziehen ist eine bewusste Bewegung mit gedrueckter Taste, und der Papierkorb ist umkehrbar. Eine Rueckfrage nach jedem Zug klickt man nach dem dritten Mal blind weg. filesApi.deleteMany schickt vier Anfragen gleichzeitig und meldet, was nicht ging; fehlgeschlagene Dateien bleiben markiert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3b12d2e943
commit
1d933c5c73
6 files changed
+198
-13
No files matched your search
+20
@@ -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
|
||||
|
||||
@@ -598,6 +598,38 @@ export const filesApi = {
|
||||
|
||||
delete: (id: string) => request<void>('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<void>('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<FileItem>('PATCH', `/files/${id}/rename`, { name }),
|
||||
|
||||
|
||||
@@ -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<string>) {
|
||||
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
|
||||
<div
|
||||
key={file.id}
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
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
|
||||
<div
|
||||
key={file.id}
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
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,
|
||||
|
||||
@@ -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' && <TrashPage onUserChanged={onUserChanged} />}
|
||||
|
||||
@@ -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> | 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<FavoriteItem[]>([])
|
||||
const [dragOverId, setDragOverId] = useState<string | null>(null)
|
||||
const [trashOver, setTrashOver] = useState(false)
|
||||
const dragItem = useRef<FavoriteItem | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -405,19 +408,53 @@ export default function Sidebar({ user, view, activeTagId, onOpenTag,
|
||||
<nav style={{ padding: '4px 8px' }}>
|
||||
{nav.map(item => {
|
||||
const active = view === item.id
|
||||
// Der Papierkorb nimmt gezogene Dateien an. Er ist die einzige
|
||||
// Stelle der Leiste, an der eine Datei etwas zu suchen hat – und
|
||||
// die naheliegendste: Man zieht sie dorthin, wo sie hin soll.
|
||||
const isTrash = item.id === 'trash' && !!onTrashDrop
|
||||
const over = isTrash && trashOver
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
onDragOver={isTrash ? (e => {
|
||||
if (!e.dataTransfer.types.includes('aether-file-id')) return
|
||||
// Ohne preventDefault lehnt der Browser die Ablage ab – und
|
||||
// zwar stumm, der Zeiger zeigt dann nur ein Verbotsschild.
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
setTrashOver(true)
|
||||
}) : undefined}
|
||||
onDragLeave={isTrash ? (() => setTrashOver(false)) : undefined}
|
||||
onDrop={isTrash ? (e => {
|
||||
e.preventDefault()
|
||||
setTrashOver(false)
|
||||
const raw = e.dataTransfer.getData('aether-file-ids')
|
||||
const single = e.dataTransfer.getData('aether-file-id')
|
||||
let ids: string[] = single ? [single] : []
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Array.isArray(parsed) && parsed.length) ids = parsed
|
||||
} catch { /* dann bleibt es bei der einen Datei */ }
|
||||
}
|
||||
if (ids.length) void onTrashDrop?.(ids)
|
||||
}) : undefined}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '8px 10px', borderRadius: 8,
|
||||
border: 'none', cursor: 'pointer',
|
||||
background: active ? 'rgba(var(--color-primary-rgb), 0.15)' : 'transparent',
|
||||
background: over
|
||||
? 'rgba(var(--color-danger-rgb, 239 68 68), 0.18)'
|
||||
: active ? 'rgba(var(--color-primary-rgb), 0.15)' : 'transparent',
|
||||
// Streifen links in der Akzentfarbe – die zweite Farbe braucht
|
||||
// eine Stelle, an der man sie tatsächlich sieht.
|
||||
boxShadow: active ? 'inset 3px 0 0 var(--color-accent)' : 'none',
|
||||
color: active ? 'var(--color-primary)' : 'var(--color-text-muted)',
|
||||
boxShadow: over
|
||||
? 'inset 0 0 0 1px var(--color-danger)'
|
||||
: active ? 'inset 3px 0 0 var(--color-accent)' : 'none',
|
||||
color: over
|
||||
? 'var(--color-danger)'
|
||||
: active ? 'var(--color-primary)' : 'var(--color-text-muted)',
|
||||
fontWeight: active ? 600 : 400, fontSize: 14, textAlign: 'left',
|
||||
transition: 'background 0.15s, color 0.15s', marginBottom: 2,
|
||||
}}
|
||||
|
||||
@@ -31,6 +31,13 @@ interface Props {
|
||||
onNavigate: (path: FolderItem[]) => void
|
||||
/** Nach Änderungen am Bestand aufrufen, damit der Speicherbalken stimmt. */
|
||||
onUserChanged?: () => Promise<void>
|
||||
/**
|
||||
* Zählt hoch, wenn woanders etwas am Bestand geändert wurde – heute: eine
|
||||
* Datei, die auf den Papierkorb in der Seitenleiste gezogen wurde. Die
|
||||
* Leiste ist ein Geschwister dieser Seite, kommt an ihren Zustand also
|
||||
* nicht heran.
|
||||
*/
|
||||
reloadSignal?: number
|
||||
}
|
||||
|
||||
// Robust client-side ID for upload-queue keys. crypto.randomUUID() only exists
|
||||
@@ -52,6 +59,7 @@ type Dialog =
|
||||
| { type: 'share-with'; resource: { type: 'folder' | 'file'; id: string; name: string } }
|
||||
| { type: 'versions'; file: FileItem }
|
||||
| { type: 'confirm-delete'; file: FileItem }
|
||||
| { type: 'confirm-delete-many'; ids: string[] }
|
||||
| { type: 'move-file'; file: FileItem }
|
||||
| { type: 'move-folder'; folder: FolderItem }
|
||||
| { type: 'tags'; file: FileItem }
|
||||
@@ -127,7 +135,7 @@ export function formatBytes(n: number): string {
|
||||
|
||||
|
||||
export default function FileBrowser({
|
||||
user: _user, folderPath, onNavigate, onUserChanged,
|
||||
user: _user, folderPath, onNavigate, onUserChanged, reloadSignal,
|
||||
}: Props) {
|
||||
const [files, setFiles] = useState<FileItem[]>([])
|
||||
const [folders, setFolders] = useState<FolderItem[]>([])
|
||||
@@ -223,6 +231,18 @@ export default function FileBrowser({
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
// Neu laden, wenn von außen gelöscht wurde. Der Vergleich mit dem letzten
|
||||
// Wert statt eines schlichten Effekts auf `reloadSignal`: Sonst liefe er
|
||||
// auch bei jedem Ordnerwechsel mit, weil `load` sich dabei ändert.
|
||||
const lastSignal = useRef(reloadSignal)
|
||||
useEffect(() => {
|
||||
if (reloadSignal === lastSignal.current) return
|
||||
lastSignal.current = reloadSignal
|
||||
setSelected(new Set())
|
||||
lastPicked.current = null
|
||||
void load()
|
||||
}, [reloadSignal, load])
|
||||
|
||||
// Beim Ordnerwechsel die Auswahl verwerfen: Eine Auswahl, die man nicht mehr
|
||||
// sieht, aber beim Herunterladen mitkäme, wäre eine böse Überraschung.
|
||||
useEffect(() => { setSelected(new Set()); lastPicked.current = null }, [currentFolderId])
|
||||
@@ -347,6 +367,26 @@ export default function FileBrowser({
|
||||
lastPicked.current = id
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Auswahl in den Papierkorb.
|
||||
*
|
||||
* Ohne das blieb nur: Rechtsklick, Löschen, Bestätigen – je Datei. Bei
|
||||
* dreißig Belegen sind das neunzig Klicks für einen Vorgang, den man als
|
||||
* einen denkt.
|
||||
*/
|
||||
async function deleteSelection(ids: string[]) {
|
||||
setDialog(null)
|
||||
const { ok, failed } = await filesApi.deleteMany(ids)
|
||||
const gone = new Set(ok)
|
||||
setFiles(f => f.filter(x => !gone.has(x.id)))
|
||||
// Fehlgeschlagene bleiben markiert – so sieht man, was übrig ist, statt
|
||||
// eine Auswahl zu verlieren und raten zu müssen.
|
||||
setSelected(new Set(failed))
|
||||
lastPicked.current = null
|
||||
await onUserChanged?.()
|
||||
if (failed.length) await load()
|
||||
}
|
||||
|
||||
function downloadSelection() {
|
||||
const here = folderPath.at(-1)?.name
|
||||
downloadZip({
|
||||
@@ -770,6 +810,18 @@ export default function FileBrowser({
|
||||
>
|
||||
<DownloadIcon /> Download as ZIP
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDialog({ type: 'confirm-delete-many', ids: [...selected] })}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 7,
|
||||
padding: '6px 14px', borderRadius: 8, cursor: 'pointer',
|
||||
fontSize: 12, fontWeight: 600, flexShrink: 0,
|
||||
border: '1px solid var(--color-danger)', background: 'transparent',
|
||||
color: 'var(--color-danger)',
|
||||
}}
|
||||
>
|
||||
<TrashIcon /> Move to trash
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelected(new Set())}
|
||||
style={{
|
||||
@@ -1029,6 +1081,17 @@ export default function FileBrowser({
|
||||
/>
|
||||
)}
|
||||
|
||||
{dialog?.type === 'confirm-delete-many' && (
|
||||
<ConfirmDialog
|
||||
title="Move to trash"
|
||||
message={`Move ${dialog.ids.length} ${dialog.ids.length === 1 ? 'file' : 'files'} to the trash? You can restore them from there.`}
|
||||
confirmLabel={`Move ${dialog.ids.length} to trash`}
|
||||
danger
|
||||
onConfirm={() => deleteSelection(dialog.ids)}
|
||||
onCancel={() => setDialog(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{dialog?.type === 'info' && (
|
||||
<FileInfoDialog
|
||||
file={dialog.file}
|
||||
|
||||
Reference in new issue
Block a user