Files
Aether/frontend/src/pages/FileBrowser.tsx
T
HoneyandClaude Opus 5 31a027c93a feat(aether): initial commit – self-hosted Cloud, Phasen 1–7
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>
2026-08-24 09:25:36 +02:00

1073 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ────────────────────────────────────────────────────────────────