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
@@ -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