Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA. Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb, Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming, Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office (Collabora/OnlyOffice). Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort) sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1073 lines
43 KiB
TypeScript
1073 lines
43 KiB
TypeScript
import React, { useState, useEffect, useCallback, useRef, type DragEvent } from 'react'
|
||
import {
|
||
filesApi, foldersApi, versionsApi, uploadWithProgress, downloadZip,
|
||
tagsChangedEvent,
|
||
isOfficeFile, type Correspondent, type FileItem, type FolderItem, type SearchHit, type Tag, type User, APIError
|
||
} from '../api/client'
|
||
import Breadcrumb from '../components/Breadcrumb'
|
||
import SearchBox from '../components/SearchBox'
|
||
import PageHeader from '../components/PageHeader'
|
||
import GridView, { ListView } from '../components/FileViews'
|
||
import FilePreview, { canPreview } from '../components/FilePreview'
|
||
import ContextMenu, { type MenuItem } from '../components/ContextMenu'
|
||
import UploadQueue, { type UploadItem } from '../components/UploadQueue'
|
||
import InputDialog from '../components/Dialog'
|
||
import ShareDialog from '../components/ShareDialog'
|
||
import ShareWithDialog from '../components/ShareWithDialog'
|
||
import ConfirmDialog from '../components/ConfirmDialog'
|
||
import VersionsDialog from '../components/VersionsDialog'
|
||
import FileInfoDialog from '../components/FileInfoDialog'
|
||
import MoveTo from '../components/MoveTo'
|
||
import TagDialog from '../components/TagDialog'
|
||
import OfficeEditor from '../components/OfficeEditor'
|
||
import { useAppSettings } from '../hooks/useAppSettings'
|
||
import { DownloadIcon, ShareIcon, HistoryIcon, RenameIcon, MoveIcon, TrashIcon, FolderOpenIcon, StarIcon, CheckIcon, PeopleIcon, TagIcon, OfficeIcon, InfoIcon } from '../components/icons'
|
||
|
||
interface Props {
|
||
user: User
|
||
/** Ordnerkette – liegt im Verlauf des Browsers, nicht hier. */
|
||
folderPath: FolderItem[]
|
||
onNavigate: (path: FolderItem[]) => void
|
||
/** Nach Änderungen am Bestand aufrufen, damit der Speicherbalken stimmt. */
|
||
onUserChanged?: () => Promise<void>
|
||
}
|
||
|
||
// Robust client-side ID for upload-queue keys. crypto.randomUUID() only exists
|
||
// in secure contexts (https/localhost); fall back so uploads work everywhere
|
||
// (e.g. behind VS Code port forwarding).
|
||
function uid(): string {
|
||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||
return crypto.randomUUID()
|
||
}
|
||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
||
}
|
||
|
||
type Dialog =
|
||
| { type: 'rename'; file: FileItem }
|
||
| { type: 'rename-folder'; folder: FolderItem }
|
||
| { type: 'confirm-delete-folder'; folder: FolderItem }
|
||
| { type: 'new-folder' }
|
||
| { type: 'share'; file: FileItem }
|
||
| { type: 'share-with'; resource: { type: 'folder' | 'file'; id: string; name: string } }
|
||
| { type: 'versions'; file: FileItem }
|
||
| { type: 'confirm-delete'; file: FileItem }
|
||
| { type: 'move-file'; file: FileItem }
|
||
| { type: 'move-folder'; folder: FolderItem }
|
||
| { type: 'tags'; file: FileItem }
|
||
| { type: 'info'; file: FileItem }
|
||
|
||
interface CtxMenu { x: number; y: number; items: MenuItem[] }
|
||
|
||
/** Löst den Download aus, ohne einen Tab zu öffnen.
|
||
*
|
||
* `window.open` erzeugte ein leeres Fenster, das je nach Browser stehen blieb
|
||
* oder kommentarlos zuklappte. Ein Klick auf ein Anker-Element mit
|
||
* `download`-Attribut ist keine Navigation – der Browser behandelt es direkt
|
||
* als Download und der Dateiname aus `Content-Disposition` bleibt erhalten. */
|
||
function downloadFile(file: FileItem) {
|
||
const a = document.createElement('a')
|
||
a.href = filesApi.download(file.id)
|
||
a.download = file.name
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
a.remove()
|
||
}
|
||
|
||
type ViewMode = 'grid' | 'list'
|
||
type Sort = 'name-asc' | 'name-desc' | 'size-asc' | 'size-desc' | 'date-asc' | 'date-desc'
|
||
|
||
const sortLabels: Record<Sort, string> = {
|
||
'name-asc': 'Name A–Z',
|
||
'name-desc': 'Name Z–A',
|
||
'size-asc': 'Smallest first',
|
||
'size-desc': 'Largest first',
|
||
'date-asc': 'Oldest first',
|
||
'date-desc': 'Newest first',
|
||
}
|
||
|
||
/** Einstellungen im Browser des Nutzers. Ein privater Zwischenspeicher, der
|
||
* fehlen darf – deshalb überall abgesichert statt geprüft. */
|
||
function loadPref<T extends string>(key: string, fallback: T): T {
|
||
try {
|
||
const v = localStorage.getItem(key)
|
||
return (v as T) || fallback
|
||
} catch { return fallback }
|
||
}
|
||
function savePref(key: string, value: string) {
|
||
try { localStorage.setItem(key, value) } catch { /* privates Fenster */ }
|
||
}
|
||
|
||
/** Ordner haben keine Größe und kein sinnvolles Änderungsdatum in der Liste –
|
||
* sie werden deshalb immer nach Namen sortiert, nur die Richtung zählt. */
|
||
function sortFolders(folders: FolderItem[], sort: Sort): FolderItem[] {
|
||
const desc = sort.endsWith('-desc') && sort.startsWith('name')
|
||
return [...folders].sort((a, b) =>
|
||
desc ? b.name.localeCompare(a.name) : a.name.localeCompare(b.name))
|
||
}
|
||
|
||
function sortFiles(files: FileItem[], sort: Sort): FileItem[] {
|
||
const [field, dir] = sort.split('-')
|
||
const factor = dir === 'desc' ? -1 : 1
|
||
return [...files].sort((a, b) => {
|
||
switch (field) {
|
||
case 'size': return (a.size - b.size) * factor
|
||
case 'date': return (new Date(a.updated_at).getTime() - new Date(b.updated_at).getTime()) * factor
|
||
default: return a.name.localeCompare(b.name) * factor
|
||
}
|
||
})
|
||
}
|
||
|
||
export function formatBytes(n: number): string {
|
||
if (n < 1024) return `${n} B`
|
||
if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`
|
||
if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`
|
||
return `${(n / 1024 ** 3).toFixed(1)} GB`
|
||
}
|
||
|
||
|
||
export default function FileBrowser({
|
||
user: _user, folderPath, onNavigate, onUserChanged,
|
||
}: Props) {
|
||
const [files, setFiles] = useState<FileItem[]>([])
|
||
const [folders, setFolders] = useState<FolderItem[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [uploads, setUploads] = useState<UploadItem[]>([])
|
||
const [ctxMenu, setCtxMenu] = useState<CtxMenu | null>(null)
|
||
const [dialog, setDialog] = useState<Dialog | null>(null)
|
||
const [dragging, setDragging] = useState(false)
|
||
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null)
|
||
const [viewMode, setViewMode] = useState<ViewMode>(() => loadPref('aether:view', 'grid'))
|
||
const [sort, setSort] = useState<Sort>(() => loadPref('aether:sort', 'name-asc'))
|
||
const [preview, setPreview] = useState<FileItem | null>(null)
|
||
/** Datei, die gerade im Office-Server bearbeitet wird. */
|
||
const [editing, setEditing] = useState<FileItem | null>(null)
|
||
const { officeEnabled } = useAppSettings()
|
||
|
||
/** Datei, zu der gerade aus der Suche gesprungen wurde – siehe openHitFile.
|
||
* Die Markierung verschwindet von selbst; ein Rahmen, der bleibt, sähe
|
||
* irgendwann wie ein Zustand aus statt wie ein Hinweis. */
|
||
/** Etiketten je Datei-ID – kommen mit der Ordnerliste in einer Abfrage mit,
|
||
* nicht einzeln je Kachel. */
|
||
const [fileTags, setFileTags] = useState<Record<string, Tag[]>>({})
|
||
/** Absender je Datei-ID – kommt mit derselben Abfrage wie die Etiketten. */
|
||
const [fileCorrespondents, setFileCorrespondents] =
|
||
useState<Record<string, Correspondent>>({})
|
||
|
||
/**
|
||
* Ausgewählte Dateien für den Sammel-Download.
|
||
*
|
||
* Der zuletzt angeklickte Eintrag wird gemerkt, damit die Umschalttaste
|
||
* einen Bereich auswählen kann – wie in jedem Dateimanager.
|
||
*/
|
||
const [selected, setSelected] = useState<Set<string>>(() => new Set())
|
||
const lastPicked = useRef<string | null>(null)
|
||
|
||
const [highlightId, setHighlightId] = useState<string | null>(null)
|
||
/**
|
||
* Das Wort, mit dem die zuletzt angesprungene Datei gefunden wurde.
|
||
*
|
||
* Es wandert bis in den PDF-Betrachter durch: Findet die Volltextsuche
|
||
* „Garantie" auf Seite 7 eines Scans, soll das Öffnen auch dort landen und
|
||
* nicht auf Seite 1. Gilt nur für die *eine* Datei aus dem Treffer – wer
|
||
* danach weiterblättert, sucht nicht mehr.
|
||
*/
|
||
const [hitTerm, setHitTerm] = useState<{ id: string; term: string } | null>(null)
|
||
useEffect(() => {
|
||
if (!highlightId) return
|
||
const t = setTimeout(() => setHighlightId(null), 2600)
|
||
return () => clearTimeout(t)
|
||
}, [highlightId])
|
||
|
||
// Ansicht und Sortierung überleben das Neuladen – eine Einstellung, die man
|
||
// bei jedem Besuch neu treffen muss, ist keine.
|
||
useEffect(() => { savePref('aether:view', viewMode) }, [viewMode])
|
||
useEffect(() => { savePref('aether:sort', sort) }, [sort])
|
||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||
|
||
const currentFolderId = folderPath.at(-1)?.id
|
||
|
||
/** Nur die Absender-Zuordnung neu holen, ohne die ganze Dateiliste. */
|
||
const reloadCorrespondents = useCallback(async () => {
|
||
try {
|
||
const data = await filesApi.list(currentFolderId)
|
||
setFileCorrespondents(data.correspondents ?? {})
|
||
} catch {
|
||
/* die Kachel zeigt dann den alten Stand – kein Grund für eine Meldung */
|
||
}
|
||
}, [currentFolderId])
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true)
|
||
try {
|
||
const data = await filesApi.list(currentFolderId)
|
||
setFiles(data.files ?? [])
|
||
setFolders(data.folders ?? [])
|
||
setFileTags(data.tags ?? {})
|
||
setFileCorrespondents(data.correspondents ?? {})
|
||
} catch {
|
||
// silent
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [currentFolderId])
|
||
|
||
// Nach Änderungen am Bestand: Liste neu holen und den Speicherverbrauch
|
||
// nachziehen. Der Wert steckt im Nutzer-Objekt und wurde bisher nur beim
|
||
// Anmelden geholt – der Balken blieb deshalb bis zum nächsten Neuladen stehen.
|
||
const reload = useCallback(async () => {
|
||
await load()
|
||
await onUserChanged?.()
|
||
}, [load, onUserChanged])
|
||
|
||
useEffect(() => { load() }, [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])
|
||
|
||
/**
|
||
* Escape hebt die Auswahl auf.
|
||
*
|
||
* Nur, wenn sonst nichts offen ist: Vorschau und Dialoge haben dieselbe
|
||
* Taste, und die soll zuerst *die* schließen. Sonst stünde man nach dem
|
||
* Schließen eines Dialogs plötzlich ohne Auswahl da, ohne zu wissen, warum.
|
||
*
|
||
* Ebenso nicht, während man in einem Feld tippt – dort bedeutet Escape
|
||
* „Eingabe verwerfen", nicht „Auswahl aufheben".
|
||
*/
|
||
useEffect(() => {
|
||
if (selected.size === 0) return
|
||
function onKey(e: KeyboardEvent) {
|
||
if (e.key !== 'Escape') return
|
||
if (preview || dialog || ctxMenu) return
|
||
const el = document.activeElement
|
||
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) return
|
||
setSelected(new Set())
|
||
lastPicked.current = null
|
||
}
|
||
window.addEventListener('keydown', onKey)
|
||
return () => window.removeEventListener('keydown', onKey)
|
||
}, [selected.size, preview, dialog, ctxMenu])
|
||
|
||
/**
|
||
* Nach einer Änderung an den Etiketten die Liste neu holen.
|
||
*
|
||
* Beim Setzen an *einer* Datei genügt es, deren Eintrag nachzuziehen – das
|
||
* macht der Dialog selbst. Beim Umbenennen oder Löschen betrifft es aber
|
||
* jede Datei, die das Etikett trägt: Ohne das stünde an den Kacheln noch
|
||
* der alte Name, und nach dem Löschen ein Aufkleber, den es nicht mehr gibt.
|
||
*/
|
||
useEffect(() => {
|
||
window.addEventListener(tagsChangedEvent, load)
|
||
return () => window.removeEventListener(tagsChangedEvent, load)
|
||
}, [load])
|
||
|
||
// ── Navigation ─────────────────────────────────────────────────────────────
|
||
|
||
function navigateTo(folder: FolderItem) {
|
||
const idx = folderPath.findIndex(f => f.id === folder.id)
|
||
onNavigate(idx >= 0 ? folderPath.slice(0, idx + 1) : [...folderPath, folder])
|
||
}
|
||
|
||
function navigateById(folderId: string | undefined) {
|
||
if (!folderId) { onNavigate([]); return }
|
||
const idx = folderPath.findIndex(f => f.id === folderId)
|
||
if (idx >= 0) onNavigate(folderPath.slice(0, idx + 1))
|
||
}
|
||
|
||
/**
|
||
* Doppelklick auf eine Datei: anschauen, wenn der Browser das kann – sonst
|
||
* herunterladen. Vorher lud ein Doppelklick immer herunter, was bei einem
|
||
* Bild ein Umweg über den Download-Ordner war.
|
||
*/
|
||
/**
|
||
* Doppelklick auf eine Datei.
|
||
*
|
||
* Bei einem Textdokument oder einer Tabelle heisst „oeffnen" **bearbeiten** –
|
||
* dafuer gibt es die Datei. Die Vorschau bleibt fuer alles, was man nur
|
||
* ansieht; heruntergeladen wird, was der Browser nicht zeigen kann.
|
||
*/
|
||
function openFile(file: FileItem) {
|
||
if (officeEnabled && isOfficeFile(file.name)) setEditing(file)
|
||
else if (canPreview(file)) setPreview(file)
|
||
else downloadFile(file)
|
||
}
|
||
|
||
const sortedFolders = sortFolders(folders, sort)
|
||
const sortedFiles = sortFiles(files, sort)
|
||
|
||
// Blättern in der Vorschau geht nur über das, was sich auch anzeigen lässt –
|
||
// sonst landet man mit dem Pfeil auf einer ZIP-Datei und sieht nichts.
|
||
const previewable = sortedFiles.filter(canPreview)
|
||
function stepPreview(delta: number) {
|
||
if (!preview) return
|
||
const i = previewable.findIndex(f => f.id === preview.id)
|
||
if (i < 0) return
|
||
const next = previewable[(i + delta + previewable.length) % previewable.length]
|
||
setPreview(next)
|
||
}
|
||
|
||
/**
|
||
* Datei-Treffer: in den Ordner springen und die Datei gleich zeigen. Nur
|
||
* hinzuspringen und den Nutzer selbst suchen zu lassen wäre die halbe Sache –
|
||
* er hat sie ja gerade benannt.
|
||
*/
|
||
/**
|
||
* Treffer aus der Suche: **nur** zum Ordner springen, nicht öffnen.
|
||
*
|
||
* Bis zum 23.08.2026 ging zugleich die Vorschau auf. Das war verwirrend –
|
||
* ein Klick in einer Trefferliste ließ unangekündigt einen Betrachter über
|
||
* den Bildschirm klappen, und wer nur nachsehen wollte, *wo* die Datei liegt,
|
||
* musste ihn erst wieder schließen. Geöffnet wird jetzt dort, wo man es
|
||
* erwartet: mit einem Doppelklick auf die Datei selbst.
|
||
*
|
||
* Damit der Sprung trotzdem etwas taugt, wird die Datei kurz umrandet und in
|
||
* den sichtbaren Bereich geholt – in einem Ordner mit fünfzig Einträgen
|
||
* suchte man sonst von vorn.
|
||
*/
|
||
function toggleSelect(id: string, range: boolean) {
|
||
setSelected(prev => {
|
||
const next = new Set(prev)
|
||
const list = sortedFiles
|
||
if (range && lastPicked.current) {
|
||
// Bereich zwischen dem zuletzt angeklickten und diesem Eintrag – in
|
||
// der *angezeigten* Reihenfolge, nicht in der Reihenfolge der Daten.
|
||
const a = list.findIndex(f => f.id === lastPicked.current)
|
||
const b = list.findIndex(f => f.id === id)
|
||
if (a >= 0 && b >= 0) {
|
||
for (let i = Math.min(a, b); i <= Math.max(a, b); i++) next.add(list[i].id)
|
||
return next
|
||
}
|
||
}
|
||
next.has(id) ? next.delete(id) : next.add(id)
|
||
return next
|
||
})
|
||
lastPicked.current = id
|
||
}
|
||
|
||
function downloadSelection() {
|
||
const here = folderPath.at(-1)?.name
|
||
downloadZip({
|
||
fileIds: [...selected],
|
||
name: here ? `${here} (selection)` : 'selection',
|
||
})
|
||
}
|
||
|
||
function openHitFile(hit: SearchHit, term: string) {
|
||
openFromSearch(hit.folder_id, hit.folder_name)
|
||
setHighlightId(hit.id)
|
||
// Nur bei einem Inhaltstreffer: Wurde bloß der Dateiname getroffen, steht
|
||
// das Wort im Dokument womöglich nirgends, und die Suche im Betrachter
|
||
// meldete „keine Treffer" – ein Hinweis auf einen Fehler, wo keiner ist.
|
||
setHitTerm(hit.matched_content ? { id: hit.id, term } : null)
|
||
}
|
||
|
||
/**
|
||
* Sprung aus der Trefferliste. Die Kette darüber kennen wir nicht – es gibt
|
||
* keine Abfrage, die zu einer Ordner-ID die Vorfahren liefert –, deshalb
|
||
* fängt der Pfad beim Ziel selbst an. Dasselbe tut die Seitenleiste bei
|
||
* einem Favoriten.
|
||
*/
|
||
function openFromSearch(folderId: string | null, folderName: string) {
|
||
if (!folderId) { onNavigate([]); return }
|
||
onNavigate([{
|
||
id: folderId, owner_id: null, group_id: null, parent_id: null,
|
||
name: folderName, created_at: '',
|
||
}])
|
||
}
|
||
|
||
// ── Upload ──────────────────────────────────────────────────────────────────
|
||
|
||
async function uploadFiles(fileList: File[]) {
|
||
const folderId = currentFolderId
|
||
|
||
// Register all items in the queue immediately
|
||
const items = fileList.map(file => ({ id: uid(), file }))
|
||
setUploads(u => [...u, ...items.map(({ id, file }) => ({
|
||
id, name: file.name, progress: 0, status: 'uploading' as const,
|
||
}))])
|
||
|
||
// Upload up to 3 files in parallel
|
||
const concurrency = 3
|
||
const queue = [...items]
|
||
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
|
||
while (queue.length > 0) {
|
||
const item = queue.shift()!
|
||
try {
|
||
await uploadWithProgress(item.file, folderId, ({ percent }) => {
|
||
setUploads(u => u.map(x => x.id === item.id ? { ...x, progress: percent } : x))
|
||
})
|
||
setUploads(u => u.map(x => x.id === item.id ? { ...x, progress: 100, status: 'done' } : x))
|
||
} catch (err) {
|
||
const msg = err instanceof APIError ? err.message : 'Upload failed'
|
||
setUploads(u => u.map(x => x.id === item.id ? { ...x, status: 'error', error: msg } : x))
|
||
}
|
||
}
|
||
})
|
||
|
||
await Promise.all(workers)
|
||
await reload()
|
||
}
|
||
|
||
async function onDrop(e: DragEvent) {
|
||
e.preventDefault()
|
||
setDragging(false)
|
||
const t = e.dataTransfer.types
|
||
if (t.includes('aether-file-id') || t.includes('aether-folder-id')) return // internal move, not upload
|
||
|
||
// Capture FileSystemEntries synchronously – the DataTransferItemList is only
|
||
// valid during the drop event itself.
|
||
const entries: FileSystemEntry[] = []
|
||
const items = e.dataTransfer.items
|
||
if (items && items.length) {
|
||
for (let i = 0; i < items.length; i++) {
|
||
const entry = items[i].webkitGetAsEntry?.()
|
||
if (entry) entries.push(entry)
|
||
}
|
||
}
|
||
const fileList = Array.from(e.dataTransfer.files)
|
||
|
||
// If any dropped item is a directory, traverse it and upload as a folder tree.
|
||
if (entries.some(en => en.isDirectory)) {
|
||
const gathered = await gatherFromEntries(entries)
|
||
if (gathered.length) await uploadFolder(gathered)
|
||
} else if (fileList.length) {
|
||
uploadFiles(fileList)
|
||
}
|
||
}
|
||
|
||
// Recursively walk dropped FileSystemEntries into {file, relativePath} pairs.
|
||
async function gatherFromEntries(entries: FileSystemEntry[]): Promise<{ file: File; relativePath: string }[]> {
|
||
const out: { file: File; relativePath: string }[] = []
|
||
|
||
async function walk(entry: FileSystemEntry, prefix: string): Promise<void> {
|
||
if (entry.isFile) {
|
||
const fileEntry = entry as FileSystemFileEntry
|
||
const file = await new Promise<File>((res, rej) => fileEntry.file(res, rej))
|
||
out.push({ file, relativePath: prefix + entry.name })
|
||
} else if (entry.isDirectory) {
|
||
const reader = (entry as FileSystemDirectoryEntry).createReader()
|
||
const children: FileSystemEntry[] = []
|
||
// readEntries returns in batches – loop until it yields nothing.
|
||
for (;;) {
|
||
const batch: FileSystemEntry[] = await new Promise((res, rej) => reader.readEntries(res, rej))
|
||
if (batch.length === 0) break
|
||
children.push(...batch)
|
||
}
|
||
for (const child of children) await walk(child, prefix + entry.name + '/')
|
||
}
|
||
}
|
||
|
||
for (const entry of entries) await walk(entry, '')
|
||
return out
|
||
}
|
||
|
||
async function handleDropOnFolder(e: React.DragEvent, folder: FolderItem) {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
setDragOverFolder(null)
|
||
|
||
const fileId = e.dataTransfer.getData('aether-file-id')
|
||
if (fileId) {
|
||
try {
|
||
await filesApi.move(fileId, folder.id)
|
||
await reload()
|
||
} catch { /* silent */ }
|
||
return
|
||
}
|
||
|
||
const srcFolderId = e.dataTransfer.getData('aether-folder-id')
|
||
if (srcFolderId && srcFolderId !== folder.id) {
|
||
try {
|
||
await foldersApi.move(srcFolderId, folder.id)
|
||
await reload()
|
||
} catch { /* silent */ }
|
||
}
|
||
}
|
||
|
||
async function handleMoveFile(file: FileItem, targetFolderId: string | null) {
|
||
setDialog(null)
|
||
try {
|
||
await filesApi.move(file.id, targetFolderId)
|
||
await reload()
|
||
} catch { /* silent */ }
|
||
}
|
||
|
||
async function handleMoveFolder(folder: FolderItem, targetParentId: string | null) {
|
||
setDialog(null)
|
||
try {
|
||
await foldersApi.move(folder.id, targetParentId)
|
||
await reload()
|
||
} catch { /* silent */ }
|
||
}
|
||
|
||
async function uploadFolder(entries: { file: File; relativePath: string }[]) {
|
||
if (!entries.length) return
|
||
const folderId = currentFolderId
|
||
|
||
// relativePath looks like "TopFolder/sub/file.txt" — the full tree (including the
|
||
// dropped folder itself) is recreated under the current folder.
|
||
const folderCache = new Map<string, string>() // "a/b" → folderId
|
||
|
||
async function ensureFolder(relPath: string): Promise<string | undefined> {
|
||
if (!relPath) return folderId
|
||
if (folderCache.has(relPath)) return folderCache.get(relPath)!
|
||
const parts = relPath.split('/')
|
||
let currentParent = folderId
|
||
let accumulated = ''
|
||
for (const part of parts) {
|
||
accumulated = accumulated ? `${accumulated}/${part}` : part
|
||
if (folderCache.has(accumulated)) {
|
||
currentParent = folderCache.get(accumulated)!
|
||
} else {
|
||
const f = await foldersApi.create(part, currentParent)
|
||
folderCache.set(accumulated, f.id)
|
||
currentParent = f.id
|
||
}
|
||
}
|
||
return currentParent
|
||
}
|
||
|
||
// Group files by their folder path (everything except the filename).
|
||
const grouped = new Map<string, { file: File; relativePath: string }[]>()
|
||
for (const entry of entries) {
|
||
const parts = entry.relativePath.split('/')
|
||
parts.pop() // remove filename
|
||
const folderPart = parts.join('/')
|
||
if (!grouped.has(folderPart)) grouped.set(folderPart, [])
|
||
grouped.get(folderPart)!.push(entry)
|
||
}
|
||
|
||
// Register upload queue items
|
||
const queued = entries.map(e => ({ id: uid(), entry: e }))
|
||
setUploads(u => [...u, ...queued.map(({ id, entry }) => ({
|
||
id, name: entry.relativePath, progress: 0, status: 'uploading' as const,
|
||
}))])
|
||
|
||
let itemIdx = 0
|
||
for (const [relFolderPath, files] of grouped) {
|
||
const targetId = await ensureFolder(relFolderPath).catch(() => folderId)
|
||
|
||
for (const { file } of files) {
|
||
const uploadId = queued[itemIdx++]?.id
|
||
try {
|
||
await uploadWithProgress(file, targetId, ({ percent }) => {
|
||
if (uploadId) setUploads(u => u.map(x => x.id === uploadId ? { ...x, progress: percent } : x))
|
||
})
|
||
if (uploadId) setUploads(u => u.map(x => x.id === uploadId ? { ...x, progress: 100, status: 'done' } : x))
|
||
} catch (err) {
|
||
const msg = err instanceof APIError ? err.message : 'Upload failed'
|
||
if (uploadId) setUploads(u => u.map(x => x.id === uploadId ? { ...x, status: 'error', error: msg } : x))
|
||
}
|
||
}
|
||
}
|
||
await reload()
|
||
}
|
||
|
||
// ── Actions ────────────────────────────────────────────────────────────────
|
||
|
||
async function handleDelete(file: FileItem) {
|
||
try {
|
||
await filesApi.delete(file.id)
|
||
setFiles(f => f.filter(x => x.id !== file.id))
|
||
// Die Liste wird bewusst nur lokal angepasst (schneller), der belegte
|
||
// Speicher muss aber vom Server kommen – sonst bleibt der Balken stehen.
|
||
await onUserChanged?.()
|
||
} catch { /* todo: toast */ }
|
||
setDialog(null)
|
||
}
|
||
|
||
async function handleRename(file: FileItem, name: string) {
|
||
try {
|
||
const updated = await filesApi.rename(file.id, name)
|
||
setFiles(f => f.map(x => x.id === file.id ? updated : x))
|
||
} catch { /* todo: toast */ }
|
||
setDialog(null)
|
||
}
|
||
|
||
async function handleCreateFolder(name: string) {
|
||
try {
|
||
const folder = await foldersApi.create(name, currentFolderId)
|
||
setFolders(f => [...f, folder].sort((a, b) => a.name.localeCompare(b.name)))
|
||
} catch { /* todo: toast */ }
|
||
setDialog(null)
|
||
}
|
||
|
||
async function handleRenameFolder(folder: FolderItem, name: string) {
|
||
try {
|
||
const updated = await foldersApi.rename(folder.id, name)
|
||
setFolders(f => f.map(x => x.id === folder.id ? updated : x).sort((a, b) => a.name.localeCompare(b.name)))
|
||
} catch { /* todo: toast */ }
|
||
setDialog(null)
|
||
}
|
||
|
||
async function handleDeleteFolder(folder: FolderItem) {
|
||
try {
|
||
await foldersApi.delete(folder.id)
|
||
setFolders(f => f.filter(x => x.id !== folder.id))
|
||
await onUserChanged?.()
|
||
} catch { /* todo: toast */ }
|
||
setDialog(null)
|
||
}
|
||
|
||
async function toggleVersioning(file: FileItem, enabled: boolean) {
|
||
try {
|
||
const updated = await versionsApi.setEnabled(file.id, enabled)
|
||
setFiles(fs => fs.map(f => f.id === file.id ? updated : f))
|
||
} catch { /* Zustand bleibt, wie er war */ }
|
||
}
|
||
|
||
function openFileMenu(e: React.MouseEvent, file: FileItem) {
|
||
e.preventDefault()
|
||
setCtxMenu({
|
||
x: e.clientX, y: e.clientY,
|
||
// Nach Zweck gruppiert: erst was man am häufigsten will, dann Teilen,
|
||
// dann Verwalten, und ganz unten abgesetzt das Löschen.
|
||
items: [
|
||
{ label: 'Download', icon: <DownloadIcon />, onClick: () => downloadFile(file) },
|
||
// Nachschlagen statt anfassen – deshalb direkt beim Herunterladen und
|
||
// nicht unten bei Umbenennen/Verschieben. Beantwortet Fragen, die die
|
||
// Kachel nicht beantwortet: Bildmaße, genaue Bytezahl, Prüfsumme.
|
||
{ label: 'Properties…', icon: <InfoIcon />, onClick: () => setDialog({ type: 'info', file }) },
|
||
{ separator: true },
|
||
{
|
||
label: 'Share with people…',
|
||
icon: <PeopleIcon />,
|
||
onClick: () => setDialog({ type: 'share-with', resource: { type: 'file', id: file.id, name: file.name } }),
|
||
},
|
||
{ label: 'Share link…', icon: <ShareIcon />, onClick: () => setDialog({ type: 'share', file }) },
|
||
{ separator: true },
|
||
// Zwischen Teilen und Verwalten: Etikettieren ist beides nicht ganz –
|
||
// es ordnet ein, ohne die Datei anzufassen.
|
||
// Hiess bis zum 23.08.2026 „Tags…". Der Dialog setzt inzwischen drei
|
||
// Dinge – Etiketten, Absender und Belegdatum –, und ein Menuepunkt,
|
||
// der nur eines davon nennt, laesst die anderen beiden unauffindbar.
|
||
{ label: 'Organise…', icon: <TagIcon />, onClick: () => setDialog({ type: 'tags', file }) },
|
||
// Nur bei passenden Dateien und nur, wenn es einen Office-Server gibt.
|
||
// Ein Punkt, der immer scheitert, ist schlimmer als keiner.
|
||
...(officeEnabled && isOfficeFile(file.name)
|
||
? [{ label: 'Edit in Office…', icon: <OfficeIcon />, onClick: () => setEditing(file) }]
|
||
: []),
|
||
{ separator: true },
|
||
{ label: 'Version history…', icon: <HistoryIcon />, onClick: () => setDialog({ type: 'versions', file }) },
|
||
{
|
||
label: 'Keep versions',
|
||
icon: <CheckIcon />,
|
||
checked: file.versioning_enabled,
|
||
onClick: () => toggleVersioning(file, !file.versioning_enabled),
|
||
},
|
||
{ separator: true },
|
||
{ label: 'Rename…', icon: <RenameIcon />, onClick: () => setDialog({ type: 'rename', file }) },
|
||
{ label: 'Move to…', icon: <MoveIcon />, onClick: () => setDialog({ type: 'move-file', file }) },
|
||
{ separator: true },
|
||
{ label: 'Delete', icon: <TrashIcon />, danger: true, onClick: () => setDialog({ type: 'confirm-delete', file }) },
|
||
]
|
||
})
|
||
}
|
||
|
||
function openFolderMenu(e: React.MouseEvent, folder: FolderItem) {
|
||
e.preventDefault()
|
||
const isFav = (window as any).__aetherIsFavorite?.(folder.id) ?? false
|
||
|
||
// Ein Gruppenordner gehört der Gruppe, nicht dem, der ihn gerade offen hat.
|
||
// Umbenennen, Verschieben und Löschen fehlen deshalb ganz statt als
|
||
// Einträge, die nur eine Fehlermeldung liefern – der Server lehnt sie
|
||
// ohnehin ab. Alles *innerhalb* des Ordners bleibt normal bedienbar.
|
||
const isGroupFolder = folder.group_id !== null
|
||
|
||
setCtxMenu({
|
||
x: e.clientX, y: e.clientY,
|
||
items: [
|
||
{ label: 'Open', icon: <FolderOpenIcon />, onClick: () => navigateTo(folder) },
|
||
{
|
||
label: 'Favourite',
|
||
icon: <StarIcon filled={isFav} />,
|
||
checked: isFav,
|
||
onClick: () => isFav
|
||
? (window as any).__aetherRemoveFavorite?.(folder.id)
|
||
: (window as any).__aetherAddFavorite?.(folder),
|
||
},
|
||
...(isGroupFolder ? [] : [
|
||
{ separator: true as const },
|
||
{
|
||
label: 'Share with people…',
|
||
icon: <PeopleIcon />,
|
||
onClick: () => setDialog({ type: 'share-with', resource: { type: 'folder' as const, id: folder.id, name: folder.name } }),
|
||
},
|
||
{ separator: true as const },
|
||
// Ganzer Ordnerinhalt als Archiv – mit Unterordnern, ihre Struktur
|
||
// bleibt im Archiv erhalten.
|
||
{ label: 'Download as ZIP', icon: <DownloadIcon />, onClick: () => downloadZip({ folderId: folder.id, name: folder.name }) },
|
||
{ separator: true as const },
|
||
{ label: 'Rename…', icon: <RenameIcon />, onClick: () => setDialog({ type: 'rename-folder', folder }) },
|
||
{ label: 'Move to…', icon: <MoveIcon />, onClick: () => setDialog({ type: 'move-folder', folder }) },
|
||
{ separator: true as const },
|
||
{ label: 'Delete', icon: <TrashIcon />, danger: true, onClick: () => setDialog({ type: 'confirm-delete-folder', folder }) },
|
||
]),
|
||
]
|
||
})
|
||
}
|
||
|
||
// ── Render ─────────────────────────────────────────────────────────────────
|
||
|
||
const isEmpty = !loading && files.length === 0 && folders.length === 0
|
||
|
||
return (
|
||
<div
|
||
style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
|
||
onDragOver={e => {
|
||
e.preventDefault()
|
||
const t = e.dataTransfer.types
|
||
if (t.includes('Files') && !t.includes('aether-file-id') && !t.includes('aether-folder-id')) setDragging(true)
|
||
}}
|
||
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragging(false) }}
|
||
onDrop={onDrop}
|
||
>
|
||
{/* Drag overlay */}
|
||
{dragging && (
|
||
<div style={{
|
||
position: 'fixed', inset: 0, zIndex: 100,
|
||
background: 'rgba(var(--color-primary-rgb), 0.12)',
|
||
border: '3px dashed var(--color-primary)',
|
||
borderRadius: 16,
|
||
margin: 8,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
pointerEvents: 'none',
|
||
}}>
|
||
<div style={{ textAlign: 'center' }}>
|
||
<div style={{ fontSize: 48, marginBottom: 8 }}>☁️</div>
|
||
<p style={{ color: 'var(--color-primary)', fontWeight: 600, fontSize: 18 }}>Drop to upload</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Kopfleiste – dieselbe Höhe wie in allen anderen Ansichten */}
|
||
<PageHeader left={<Breadcrumb path={folderPath} onNavigate={navigateById} />}>
|
||
{/* Auswahlleiste: ersetzt Suche und Sortierung, solange etwas markiert
|
||
ist. Sie *neben* die anderen Elemente zu setzen wäre auf schmalen
|
||
Schirmen eine dritte Zeile – und solange man auswählt, will man
|
||
ohnehin nichts anderes. */}
|
||
{selected.size > 0 ? (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flex: 1, minWidth: 0 }}>
|
||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)', flexShrink: 0 }}>
|
||
{selected.size} selected
|
||
</span>
|
||
<button
|
||
onClick={downloadSelection}
|
||
style={{
|
||
display: 'flex', alignItems: 'center', gap: 7,
|
||
padding: '6px 14px', borderRadius: 8, border: 'none', cursor: 'pointer',
|
||
fontSize: 12, fontWeight: 600, color: '#fff', background: 'var(--color-primary)',
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<DownloadIcon /> Download as ZIP
|
||
</button>
|
||
<button
|
||
onClick={() => setSelected(new Set())}
|
||
style={{
|
||
padding: '6px 10px', borderRadius: 8, cursor: 'pointer', fontSize: 12,
|
||
border: '1px solid var(--color-border)', background: 'transparent',
|
||
color: 'var(--color-text-muted)', flexShrink: 0,
|
||
}}
|
||
>
|
||
Clear
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<SearchBox onOpenFolder={openFromSearch} onOpenFile={openHitFile} />
|
||
)}
|
||
|
||
{/* Sortierung */}
|
||
<select
|
||
value={sort}
|
||
onChange={e => setSort(e.target.value as Sort)}
|
||
title="Sort by"
|
||
style={{
|
||
padding: '6px 8px', borderRadius: 8, fontSize: 12,
|
||
border: '1px solid var(--color-border)',
|
||
background: 'var(--color-surface-2)',
|
||
color: 'var(--color-text-muted)', cursor: 'pointer',
|
||
}}
|
||
>
|
||
{(Object.keys(sortLabels) as Sort[]).map(key => (
|
||
<option key={key} value={key}>{sortLabels[key]}</option>
|
||
))}
|
||
</select>
|
||
|
||
{/* View toggle */}
|
||
<div style={{ display: 'flex', borderRadius: 8, overflow: 'hidden', border: '1px solid var(--color-border)' }}>
|
||
<button
|
||
onClick={() => setViewMode('grid')}
|
||
style={{
|
||
padding: '6px 10px', border: 'none', cursor: 'pointer',
|
||
background: viewMode === 'grid' ? 'var(--color-surface-2)' : 'transparent',
|
||
color: viewMode === 'grid' ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||
}}
|
||
title="Grid view"
|
||
>
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||
<rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" />
|
||
<rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
onClick={() => setViewMode('list')}
|
||
style={{
|
||
padding: '6px 10px', border: 'none', cursor: 'pointer',
|
||
background: viewMode === 'list' ? 'var(--color-surface-2)' : 'transparent',
|
||
color: viewMode === 'list' ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||
}}
|
||
title="List view"
|
||
>
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" /><line x1="8" y1="18" x2="21" y2="18" />
|
||
<circle cx="3" cy="6" r="1" fill="currentColor" /><circle cx="3" cy="12" r="1" fill="currentColor" /><circle cx="3" cy="18" r="1" fill="currentColor" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => setDialog({ type: 'new-folder' })}
|
||
style={{
|
||
padding: '6px 14px', borderRadius: 8, border: '1px solid var(--color-border)',
|
||
background: 'transparent', color: 'var(--color-text-muted)', fontSize: 13, cursor: 'pointer',
|
||
}}
|
||
title="New folder"
|
||
>
|
||
+ Folder
|
||
</button>
|
||
<button
|
||
onClick={() => fileInputRef.current?.click()}
|
||
style={{
|
||
padding: '6px 14px', borderRadius: 8, border: 'none',
|
||
background: 'var(--color-primary)', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||
}}
|
||
title="Upload files (drag & drop for folders)"
|
||
>
|
||
Upload
|
||
</button>
|
||
</PageHeader>
|
||
|
||
{/* Content */}
|
||
<div style={{ flex: 1, overflowY: 'auto', padding: 20 }}>
|
||
{loading && (
|
||
<div style={{ display: 'flex', justifyContent: 'center', paddingTop: 80 }}>
|
||
<div style={{
|
||
width: 24, height: 24,
|
||
border: '2px solid var(--color-primary)',
|
||
borderTopColor: 'transparent',
|
||
borderRadius: '50%',
|
||
animation: 'spin 0.7s linear infinite',
|
||
}} />
|
||
</div>
|
||
)}
|
||
|
||
{!loading && isEmpty && (
|
||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', paddingTop: 100, gap: 12 }}>
|
||
<div style={{ fontSize: 56, opacity: 0.25 }}>☁️</div>
|
||
<p style={{ color: 'var(--color-text-muted)', fontSize: 14, margin: 0 }}>
|
||
Drag files here or click <strong>Upload</strong>
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{!loading && !isEmpty && viewMode === 'grid' && (
|
||
<GridView
|
||
highlightId={highlightId}
|
||
fileTags={fileTags}
|
||
fileCorrespondents={fileCorrespondents}
|
||
selected={selected}
|
||
onToggleSelect={toggleSelect}
|
||
folders={sortedFolders}
|
||
files={sortedFiles}
|
||
onFolderOpen={navigateTo}
|
||
onFolderMenu={openFolderMenu}
|
||
onFileMenu={openFileMenu}
|
||
onFileOpen={openFile}
|
||
onFileDownload={downloadFile}
|
||
onFolderDrop={handleDropOnFolder}
|
||
dragOverFolder={dragOverFolder}
|
||
onFolderDragOver={setDragOverFolder}
|
||
/>
|
||
)}
|
||
|
||
{!loading && !isEmpty && viewMode === 'list' && (
|
||
<ListView
|
||
highlightId={highlightId}
|
||
fileTags={fileTags}
|
||
fileCorrespondents={fileCorrespondents}
|
||
selected={selected}
|
||
onToggleSelect={toggleSelect}
|
||
folders={sortedFolders}
|
||
files={sortedFiles}
|
||
onFolderOpen={navigateTo}
|
||
onFolderMenu={openFolderMenu}
|
||
onFileMenu={openFileMenu}
|
||
onFileOpen={openFile}
|
||
onFileDownload={downloadFile}
|
||
onFolderDrop={handleDropOnFolder}
|
||
dragOverFolder={dragOverFolder}
|
||
onFolderDragOver={setDragOverFolder}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{editing && (
|
||
<OfficeEditor
|
||
file={editing}
|
||
onClose={() => {
|
||
setEditing(null)
|
||
// Der Office-Server hat womoeglich gespeichert – Groesse, Datum
|
||
// und Fassung stimmen dann nicht mehr. Einmal nachladen.
|
||
void load()
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{preview && (
|
||
<FilePreview
|
||
file={preview}
|
||
onPrev={previewable.length > 1 ? () => stepPreview(-1) : undefined}
|
||
onNext={previewable.length > 1 ? () => stepPreview(1) : undefined}
|
||
onClose={() => setPreview(null)}
|
||
onSaved={reload}
|
||
searchTerm={hitTerm?.id === preview.id ? hitTerm.term : undefined}
|
||
/>
|
||
)}
|
||
|
||
{/* Hidden file input */}
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
multiple
|
||
style={{ display: 'none' }}
|
||
onChange={e => {
|
||
const list = Array.from(e.target.files ?? [])
|
||
if (list.length) uploadFiles(list)
|
||
e.target.value = ''
|
||
}}
|
||
/>
|
||
|
||
<UploadQueue items={uploads} onDismiss={id => setUploads(u => u.filter(x => x.id !== id))} />
|
||
{ctxMenu && <ContextMenu {...ctxMenu} onClose={() => setCtxMenu(null)} />}
|
||
|
||
{dialog?.type === 'rename' && (
|
||
<InputDialog
|
||
title="Rename"
|
||
defaultValue={dialog.file.name}
|
||
confirmLabel="Rename"
|
||
onConfirm={name => handleRename(dialog.file, name)}
|
||
onCancel={() => setDialog(null)}
|
||
/>
|
||
)}
|
||
{dialog?.type === 'rename-folder' && (
|
||
<InputDialog
|
||
title="Rename folder"
|
||
defaultValue={dialog.folder.name}
|
||
confirmLabel="Rename"
|
||
onConfirm={name => handleRenameFolder(dialog.folder, name)}
|
||
onCancel={() => setDialog(null)}
|
||
/>
|
||
)}
|
||
{dialog?.type === 'confirm-delete-folder' && (
|
||
<ConfirmDialog
|
||
title="Delete folder"
|
||
message={`Move "${dialog.folder.name}" and all its contents to the trash?`}
|
||
confirmLabel="Delete"
|
||
danger
|
||
onConfirm={() => handleDeleteFolder(dialog.folder)}
|
||
onCancel={() => setDialog(null)}
|
||
/>
|
||
)}
|
||
{dialog?.type === 'new-folder' && (
|
||
<InputDialog
|
||
title="New folder"
|
||
placeholder="Folder name"
|
||
confirmLabel="Create"
|
||
onConfirm={handleCreateFolder}
|
||
onCancel={() => setDialog(null)}
|
||
/>
|
||
)}
|
||
{dialog?.type === 'share-with' && (
|
||
<ShareWithDialog
|
||
resource={dialog.resource}
|
||
onClose={() => setDialog(null)}
|
||
/>
|
||
)}
|
||
{dialog?.type === 'share' && (
|
||
<ShareDialog
|
||
file={dialog.file}
|
||
onClose={() => setDialog(null)}
|
||
/>
|
||
)}
|
||
{dialog?.type === 'versions' && (
|
||
<VersionsDialog
|
||
file={dialog.file}
|
||
onClose={() => setDialog(null)}
|
||
onRestored={updated => {
|
||
setFiles(f => f.map(x => x.id === updated.id ? updated : x))
|
||
setDialog(null)
|
||
}}
|
||
/>
|
||
)}
|
||
{dialog?.type === 'confirm-delete' && (
|
||
<ConfirmDialog
|
||
title="Move to trash"
|
||
message={`Move "${dialog.file.name}" to the trash?`}
|
||
confirmLabel="Move to trash"
|
||
danger
|
||
onConfirm={() => handleDelete(dialog.file)}
|
||
onCancel={() => setDialog(null)}
|
||
/>
|
||
)}
|
||
|
||
{dialog?.type === 'info' && (
|
||
<FileInfoDialog
|
||
file={dialog.file}
|
||
tags={fileTags[dialog.file.id] ?? []}
|
||
correspondent={fileCorrespondents[dialog.file.id]}
|
||
onClose={() => setDialog(null)}
|
||
/>
|
||
)}
|
||
|
||
{dialog?.type === 'tags' && (
|
||
<TagDialog
|
||
file={dialog.file}
|
||
current={fileTags[dialog.file.id] ?? []}
|
||
currentCorrespondent={fileCorrespondents[dialog.file.id]}
|
||
onClose={() => setDialog(null)}
|
||
onSaved={tags => {
|
||
// Nur die eine Datei nachziehen statt die ganze Liste neu zu holen.
|
||
setFileTags(prev => ({ ...prev, [dialog.file.id]: tags }))
|
||
// Der Absender kann dabei neu entstanden sein und hat dann eine ID,
|
||
// die hier niemand kennt. Deshalb einmal nachladen – sonst zeigte
|
||
// die Kachel den alten Absender, bis man den Ordner wechselt.
|
||
void reloadCorrespondents()
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{dialog?.type === 'move-file' && (
|
||
<MoveTo
|
||
title={`Move "${dialog.file.name}"`}
|
||
onConfirm={targetId => handleMoveFile(dialog.file, targetId)}
|
||
onCancel={() => setDialog(null)}
|
||
/>
|
||
)}
|
||
{dialog?.type === 'move-folder' && (
|
||
<MoveTo
|
||
title={`Move "${dialog.folder.name}"`}
|
||
excludeId={dialog.folder.id}
|
||
onConfirm={targetId => handleMoveFolder(dialog.folder, targetId)}
|
||
onCancel={() => setDialog(null)}
|
||
/>
|
||
)}
|
||
|
||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Grid View ────────────────────────────────────────────────────────────────
|