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 } // 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 = { '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(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([]) const [folders, setFolders] = useState([]) const [loading, setLoading] = useState(true) const [uploads, setUploads] = useState([]) const [ctxMenu, setCtxMenu] = useState(null) const [dialog, setDialog] = useState(null) const [dragging, setDragging] = useState(false) const [dragOverFolder, setDragOverFolder] = useState(null) const [viewMode, setViewMode] = useState(() => loadPref('aether:view', 'grid')) const [sort, setSort] = useState(() => loadPref('aether:sort', 'name-asc')) const [preview, setPreview] = useState(null) /** Datei, die gerade im Office-Server bearbeitet wird. */ const [editing, setEditing] = useState(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>({}) /** Absender je Datei-ID – kommt mit derselben Abfrage wie die Etiketten. */ const [fileCorrespondents, setFileCorrespondents] = useState>({}) /** * 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>(() => new Set()) const lastPicked = useRef(null) const [highlightId, setHighlightId] = useState(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(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 { if (entry.isFile) { const fileEntry = entry as FileSystemFileEntry const file = await new Promise((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() // "a/b" → folderId async function ensureFolder(relPath: string): Promise { 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() 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: , 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: , onClick: () => setDialog({ type: 'info', file }) }, { separator: true }, { label: 'Share with people…', icon: , onClick: () => setDialog({ type: 'share-with', resource: { type: 'file', id: file.id, name: file.name } }), }, { label: 'Share link…', icon: , 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: , 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: , onClick: () => setEditing(file) }] : []), { separator: true }, { label: 'Version history…', icon: , onClick: () => setDialog({ type: 'versions', file }) }, { label: 'Keep versions', icon: , checked: file.versioning_enabled, onClick: () => toggleVersioning(file, !file.versioning_enabled), }, { separator: true }, { label: 'Rename…', icon: , onClick: () => setDialog({ type: 'rename', file }) }, { label: 'Move to…', icon: , onClick: () => setDialog({ type: 'move-file', file }) }, { separator: true }, { label: 'Delete', icon: , 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: , onClick: () => navigateTo(folder) }, { label: 'Favourite', icon: , 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: , 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: , onClick: () => downloadZip({ folderId: folder.id, name: folder.name }) }, { separator: true as const }, { label: 'Rename…', icon: , onClick: () => setDialog({ type: 'rename-folder', folder }) }, { label: 'Move to…', icon: , onClick: () => setDialog({ type: 'move-folder', folder }) }, { separator: true as const }, { label: 'Delete', icon: , danger: true, onClick: () => setDialog({ type: 'confirm-delete-folder', folder }) }, ]), ] }) } // ── Render ───────────────────────────────────────────────────────────────── const isEmpty = !loading && files.length === 0 && folders.length === 0 return (
{ 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 && (
☁️

Drop to upload

)} {/* Kopfleiste – dieselbe Höhe wie in allen anderen Ansichten */} }> {/* 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 ? (
{selected.size} selected
) : ( )} {/* Sortierung */} {/* View toggle */}
{/* Content */}
{loading && (
)} {!loading && isEmpty && (
☁️

Drag files here or click Upload

)} {!loading && !isEmpty && viewMode === 'grid' && ( )} {!loading && !isEmpty && viewMode === 'list' && ( )}
{editing && ( { setEditing(null) // Der Office-Server hat womoeglich gespeichert – Groesse, Datum // und Fassung stimmen dann nicht mehr. Einmal nachladen. void load() }} /> )} {preview && ( 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 */} { const list = Array.from(e.target.files ?? []) if (list.length) uploadFiles(list) e.target.value = '' }} /> setUploads(u => u.filter(x => x.id !== id))} /> {ctxMenu && setCtxMenu(null)} />} {dialog?.type === 'rename' && ( handleRename(dialog.file, name)} onCancel={() => setDialog(null)} /> )} {dialog?.type === 'rename-folder' && ( handleRenameFolder(dialog.folder, name)} onCancel={() => setDialog(null)} /> )} {dialog?.type === 'confirm-delete-folder' && ( handleDeleteFolder(dialog.folder)} onCancel={() => setDialog(null)} /> )} {dialog?.type === 'new-folder' && ( setDialog(null)} /> )} {dialog?.type === 'share-with' && ( setDialog(null)} /> )} {dialog?.type === 'share' && ( setDialog(null)} /> )} {dialog?.type === 'versions' && ( setDialog(null)} onRestored={updated => { setFiles(f => f.map(x => x.id === updated.id ? updated : x)) setDialog(null) }} /> )} {dialog?.type === 'confirm-delete' && ( handleDelete(dialog.file)} onCancel={() => setDialog(null)} /> )} {dialog?.type === 'info' && ( setDialog(null)} /> )} {dialog?.type === 'tags' && ( 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' && ( handleMoveFile(dialog.file, targetId)} onCancel={() => setDialog(null)} /> )} {dialog?.type === 'move-folder' && ( handleMoveFolder(dialog.folder, targetId)} onCancel={() => setDialog(null)} /> )}
) } // ── Grid View ────────────────────────────────────────────────────────────────