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:
marcandClaude Opus 5 committed 2026-08-24 22:43:36 +02:00
1 parent 3b12d2e943
commit 1d933c5c73
6 files changed
+198 -13

No files matched your search

+21 -8
View File
@@ -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,
+20
View File
@@ -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} />}
+41 -4
View File
@@ -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,
}}