import { useEffect, useRef, useState } from 'react' import type React from 'react' import { type Correspondent, type FileItem, type FolderItem, type Tag } from '../api/client' import { TagRow } from './TagDialog' import TagChip from './TagChip' import { FileTypeIcon, FolderGlyph } from './icons' import FileCover, { FolderCover } from './FileCover' import { formatBytes } from '../pages/FileBrowser' /** * Die beiden Darstellungen einer Ordnerliste: Kacheln und Zeilen. * * Lagen bis zum 23.08.2026 unten in FileBrowser.tsx und machten die Seite über * 1100 Zeilen lang. Sie hängen nur an ihren Eigenschaften, nicht am Zustand der * Seite – hier stehen sie für sich. */ /** Klein in der Liste – dieselbe Form wie auf der Kachel. 24 statt 22 Pixel, * weil 48/24 glatt aufgeht und die Kanten damit scharf bleiben. */ function FolderIcon({ size = 24, group = false }: { size?: number; group?: boolean }) { return } /** * Zeigt der Nutzer mit dem Finger oder mit einer Maus? * * `pointer: coarse` ist die Frage nach der Genauigkeit des Zeigegeräts, nicht * nach der Bildschirmbreite – ein schmales Fenster auf dem Schreibtisch bleibt * damit richtig eine Maus, ein Tablet im Querformat richtig ein Finger. * * Der Anlass: **Auf dem Handy öffnete gar nichts.** Ordner und Dateien hingen * an `onDoubleClick`, und den doppelten Klick gibt es auf einem Touchscreen * praktisch nicht – der Browser meldet zwei einzelne Berührungen, weil man * nicht zweimal auf denselben Pixel tippt. Der Doppelklick ist eine * Mausgeste; am Finger ist er nie angekommen. */ export function useCoarsePointer(): boolean { const [coarse, setCoarse] = useState( () => typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches, ) useEffect(() => { if (typeof matchMedia !== 'function') return const mq = matchMedia('(pointer: coarse)') const on = () => setCoarse(mq.matches) mq.addEventListener('change', on) return () => mq.removeEventListener('change', on) }, []) return coarse } /** * Die Öffnen-Geste, je nach Zeigegerät. * * Maus: Doppelklick – der einfache Klick bleibt zum Auswählen frei. * Finger: **einfacher Tipp**. So macht es jede Dateiverwaltung am Telefon, * und etwas anderes wäre dort nicht zu erraten. * * Nur für **Ordner**: Die haben keine Auswahl und keinen zweiten Klick- * Handler, deshalb genügt hier das Umschalten der Eigenschaft. Dateien * schreiben ihren Handler selbst aus – warum, steht dort. */ /** * Was genau wird gezogen – diese eine Datei oder die ganze Auswahl? * * Zieht man eine Datei, die markiert ist, meint man die Markierung. Zieht man * eine unmarkierte, meint man genau sie – die Markierung bleibt unberührt. * Dieselbe Regel wie in jedem Dateimanager. * * `aether-file-id` bleibt daneben stehen, weil das Verschieben in einen Ordner * damit arbeitet; `aether-file-ids` ist die Liste für alles, was mehrere * verträgt. */ export function startFileDrag(e: React.DragEvent, fileId: string, selected?: Set) { const ids = selected?.has(fileId) ? [...selected] : [fileId] e.dataTransfer.setData('aether-file-id', fileId) e.dataTransfer.setData('aether-file-ids', JSON.stringify(ids)) e.dataTransfer.effectAllowed = 'move' } function openGesture(coarse: boolean, open: () => void) { return coarse ? { onClick: open } : { onDoubleClick: open } } interface ViewProps { folders: FolderItem[] files: FileItem[] onFolderOpen: (f: FolderItem) => void onFolderMenu: (e: React.MouseEvent, f: FolderItem) => void onFileMenu: (e: React.MouseEvent, f: FileItem) => void /** Öffnen (Doppelklick bzw. Tipp): Vorschau, wenn möglich – sonst Download. */ onFileOpen: (file: FileItem) => void onFileDownload: (file: FileItem) => void onFolderDrop: (e: React.DragEvent, folder: FolderItem) => void dragOverFolder: string | null onFolderDragOver: (id: string | null) => void /** * Datei, zu der gerade aus der Suche gesprungen wurde. Sie wird kurz * umrandet und in den sichtbaren Bereich geholt. * * Ohne das wäre der Sprung in einen Ordner mit fünfzig Dateien wertlos: Man * landet am richtigen Ort und sucht dann von vorn. */ highlightId?: string | null /** Etiketten je Datei-ID – kommen mit der Ordnerliste in einer Abfrage mit. */ fileTags?: Record /** Absender je Datei-ID. Anders als die Etiketten höchstens einer. */ fileCorrespondents?: Record /** Ausgewählte Dateien für den Sammel-Download. */ selected?: Set /** Umschalten; `range` bei gedrückter Umschalttaste. */ onToggleSelect?: (id: string, range: boolean) => void } /** Umrandung der eben angesprungenen Datei. */ const highlightRing = '0 0 0 2px var(--color-primary)' /** * Holt die markierte Datei in den sichtbaren Bereich. * * In einem eigenen Haken, weil Kachel- und Listenansicht ihn beide brauchen – * und weil es in einem Callback-Ref bei jedem Neuzeichnen erneut scrollen * würde, auch wenn sich gar nichts geändert hat. */ function useScrollToHighlight(highlightId?: string | null) { const ref = useRef(null) useEffect(() => { if (!highlightId || !ref.current) return ref.current.scrollIntoView({ block: 'center', behavior: 'smooth' }) }, [highlightId]) return ref } export default function GridView({ folders, files, onFolderOpen, onFolderMenu, onFileMenu, onFileOpen, onFolderDrop, dragOverFolder, onFolderDragOver, highlightId, fileTags, fileCorrespondents, selected, onToggleSelect }: ViewProps) { const highlightRef = useScrollToHighlight(highlightId) const coarse = useCoarsePointer() return ( // Raster und Abstände stehen in index.css (.aether-grid). Hier inline // gesetzt wären sie auf dem Handy nicht zu ändern: Ein Inline-Stil schlägt // jede Media-Query – daran ist die Kopfleiste schon zweimal gescheitert.
{folders.map(folder => { const isOver = dragOverFolder === folder.id // Ein Gruppenordner lässt sich nicht verschieben – ihn ziehbar zu // machen, würde eine Bewegung anbieten, die der Server ablehnt. const isGroupFolder = folder.group_id !== null return (
{ if (isGroupFolder) { e.preventDefault(); return } e.dataTransfer.setData('aether-folder-id', folder.id) e.dataTransfer.effectAllowed = 'move' }} style={{ background: 'var(--color-surface)', border: `1px solid ${isOver ? 'var(--color-primary)' : 'var(--color-border)'}`, borderRadius: 12, overflow: 'hidden', cursor: 'grab', transition: 'border-color 0.15s, transform 0.1s, background 0.1s', // transform: nur noch beim Ziehen (isOver) display: 'flex', flexDirection: 'column', transform: isOver ? 'scale(1.03)' : 'none', boxShadow: isOver ? '0 0 0 3px rgba(var(--color-primary-rgb), 0.2)' : 'none', }} {...openGesture(coarse, () => onFolderOpen(folder))} onContextMenu={e => onFolderMenu(e, folder)} onMouseEnter={e => { if (!isOver) e.currentTarget.style.borderColor = 'var(--color-primary)' }} onMouseLeave={e => { if (!isOver) e.currentTarget.style.borderColor = 'var(--color-border)' }} onDragOver={e => { e.preventDefault(); e.stopPropagation(); onFolderDragOver(folder.id) }} onDragLeave={e => { e.stopPropagation(); onFolderDragOver(null) }} onDrop={e => onFolderDrop(e, folder)} >

{folder.name}

{isGroupFolder && ( )} {isGroupFolder ? 'Group folder' : 'Folder'}

) })} {files.map(file => (
startFileDrag(e, file.id, selected)} ref={file.id === highlightId ? highlightRef : undefined} onDoubleClick={() => { if (!coarse) onFileOpen(file) }} onClick={e => { // Am Finger öffnet der Tipp – bzw. schaltet die Auswahl um, // solange eine läuft. Ohne Strg- und Umschalttaste wäre eine // Mehrfachauswahl am Telefon sonst gar nicht zu bedienen. // // Das steht **hier** und nicht als eigener Handler daneben: In // JSX gewinnt das zuletzt notierte `onClick`. Ein zweites, per // Spread eingesetztes wurde von diesem hier stillschweigend // überschrieben – gemessen: der Tipp auf ein PDF tat nichts, // während der auf einen Ordner funktionierte. if (coarse) { if ((selected?.size ?? 0) > 0) { onToggleSelect?.(file.id, false); return } onFileOpen(file) return } // **Nur** mit Zusatztaste – der einfache Klick bleibt frei. // // Der erste Entwurf wählte auch einfach aus, sobald schon etwas // markiert war. Das kollidiert mit dem Doppelklick: Dessen erster // Klick löst dann eine Auswahl aus, und statt die Vorschau zu // öffnen ändert sich die Markierung. Beim Testen gemessen – aus // drei Ausgewählten wurden vier, und die Vorschau blieb zu. // // Zum Auswählen bleiben das Kästchen und Strg/Umschalt. Das ist // eindeutig, und der Doppelklick tut wieder genau eine Sache. if (!onToggleSelect) return if (e.metaKey || e.ctrlKey || e.shiftKey) { e.preventDefault() onToggleSelect(file.id, e.shiftKey) } }} style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', borderRadius: 12, overflow: 'hidden', cursor: 'grab', // boxShadow statt border: Ein dickerer Rahmen würde die Kachel um // einen Pixel wachsen lassen und die ganze Reihe verschieben. boxShadow: selected?.has(file.id) ? '0 0 0 2px var(--color-primary)' : file.id === highlightId ? highlightRing : undefined, transition: 'border-color 0.15s, box-shadow 0.3s', display: 'flex', flexDirection: 'column', }} onContextMenu={e => onFileMenu(e, file)} /* **Kein Anheben beim Überfahren.** Ein `translateY(-1px)` verschiebt die Kachel *unter* dem Zeiger weg: Steht er genau auf der unteren Kante, rutscht sie hoch, der Zeiger liegt daneben, es feuert `mouseleave`, sie fällt zurück – und das im Kreis, viele Male je Sekunde. Sichtbar als Flackern von Kachel und Mauszeiger. Der Rahmen in der Primärfarbe zeigt dasselbe an, ohne die Kachel zu bewegen. */ onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--color-primary)' const pick = e.currentTarget.querySelector('.aether-pick') if (pick) pick.style.opacity = '1' }} onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--color-border)' const pick = e.currentTarget.querySelector('.aether-pick') if (pick && !selected?.has(file.id) && (selected?.size ?? 0) === 0) pick.style.opacity = '0' }} >
{/* Kästchen oben links. Erscheint beim Überfahren oder sobald irgendetwas ausgewählt ist – dauerhaft sichtbar wäre es an fünfzig Kacheln nur Unruhe, und die meisten Besuche wählen nichts aus. */} {onToggleSelect && ( )}

{file.name}

{/* Eine Zeile unter dem Namen, zwei Zonen: links **wer**, rechts **was**. Vorher standen Absender, Größe und Aufkleber nebeneinander in derselben Reihe – drei Angaben in drei Schriftgraden auf 130 Pixeln, und jede Kachel brach an einer anderen Stelle um. Genau das machte das Raster unruhig. Die Größe weicht dabei nach hinten: Sie steht nur noch da, wo kein Absender steht (Bilder, Downloads), und sonst in der Listenansicht und unter „Properties". Beim Überfliegen eines Rasters sucht man den Absender, nicht die Kilobyte. */}
{fileCorrespondents?.[file.id] ? ( {fileCorrespondents[file.id].icon && ( {fileCorrespondents[file.id].icon} )} {fileCorrespondents[file.id].name} ) : ( {formatBytes(file.size)} )} {(fileTags?.[file.id]?.length ?? 0) > 0 && ( {/* Nur eins, der Rest als „+n": Bei zweien schrumpfen beide auf „gar…" und „steue…" – zwei unlesbare Aufkleber sagen weniger als einer lesbarer. Gemessen im Bildschirmfoto. */} )}
))}
) } // ── List View ──────────────────────────────────────────────────────────────── export function ListView({ folders, files, onFolderOpen, onFolderMenu, onFileMenu, onFileOpen, onFileDownload, onFolderDrop, dragOverFolder, onFolderDragOver, highlightId, fileTags, fileCorrespondents, selected, onToggleSelect }: ViewProps) { const highlightRef = useScrollToHighlight(highlightId) const coarse = useCoarsePointer() return (
{/* Header */}
Name
Size
Modified
{folders.map(folder => { const isOver = dragOverFolder === folder.id return (
{ e.dataTransfer.setData('aether-folder-id', folder.id) e.dataTransfer.effectAllowed = 'move' }} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 12px', borderRadius: 8, cursor: 'grab', background: isOver ? 'rgba(var(--color-primary-rgb), 0.1)' : 'transparent', outline: isOver ? '2px solid var(--color-primary)' : 'none', transition: 'background 0.1s', }} {...openGesture(coarse, () => onFolderOpen(folder))} onContextMenu={e => onFolderMenu(e, folder)} onMouseEnter={e => { if (!isOver) e.currentTarget.style.background = 'var(--color-surface)' }} onMouseLeave={e => { if (!isOver) e.currentTarget.style.background = 'transparent' }} onDragOver={e => { e.preventDefault(); e.stopPropagation(); onFolderDragOver(folder.id) }} onDragLeave={e => { e.stopPropagation(); onFolderDragOver(null) }} onDrop={e => onFolderDrop(e, folder)} >
{folder.name}
—
{new Date(folder.created_at).toLocaleDateString()}
) })} {files.map(file => (
startFileDrag(e, file.id, selected)} ref={file.id === highlightId ? highlightRef : undefined} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 12px', borderRadius: 8, cursor: 'grab', transition: 'background 0.1s, box-shadow 0.3s', boxShadow: selected?.has(file.id) ? '0 0 0 2px var(--color-primary)' : file.id === highlightId ? highlightRing : undefined, }} onDoubleClick={() => { if (!coarse) onFileOpen(file) }} onClick={e => { // Siehe Kachelansicht – ein einziger Handler für beide Geräte. if (coarse) { if ((selected?.size ?? 0) > 0) { onToggleSelect?.(file.id, false); return } onFileOpen(file) return } if (!onToggleSelect) return if (e.metaKey || e.ctrlKey || e.shiftKey) { e.preventDefault() onToggleSelect(file.id, e.shiftKey) } }} onContextMenu={e => onFileMenu(e, file)} onMouseEnter={e => e.currentTarget.style.background = 'var(--color-surface)'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'} >
{ if (!onToggleSelect) return e.stopPropagation() onToggleSelect(file.id, e.shiftKey) }} title={onToggleSelect ? 'Select' : undefined} > {/* In der Liste ersetzt das Kästchen das Symbol, sobald etwas ausgewählt ist – in einer Zeile ist kein Platz für beides, und zwei Spalten nur für den seltenen Fall wären Verschwendung. */} {onToggleSelect && (selected?.has(file.id) || (selected?.size ?? 0) > 0) ? ( {selected?.has(file.id) && ( )} ) : ( )}
{/* Name und Aufkleber teilen sich die Spalte: In einer Zeile ist kein Platz für eine zweite Zeile darunter, und eine eigene Spalte stünde bei den meisten Dateien leer. */}
{file.name} {(fileTags?.[file.id]?.length ?? 0) > 0 && ( {(fileTags?.[file.id] ?? []).slice(0, 2).map(t => ( ))} )}
{/* Eigene Spalte statt einer zweiten Zeile: In einer Tabelle gibt es die Spalte ohnehin schon, und eine leere Zelle ändert die Zeilenhöhe nicht. */}
{fileCorrespondents?.[file.id] ? `${fileCorrespondents[file.id].icon} ${fileCorrespondents[file.id].name}`.trim() : ''}
{formatBytes(file.size)}
{new Date(file.updated_at).toLocaleDateString()}
))}
) }