Files
Aether/frontend/src/components/FileViews.tsx
T
marcandClaude Opus 5 9c5cda75d7 feat(office): Dokumente am Telefon als PDF ansehen statt bearbeiten
Am Finger öffnet ein Office-Dokument jetzt das gewandelte PDF im eigenen
Betrachter; "Edit" daneben führt trotzdem in den Editor. Mit Maus ändert
sich nichts.

Der Web-Editor ist eine vollständige Textverarbeitung – zwischen seinen
Leisten bleibt auf 400 Pixeln vom Dokument ein Rechteck. Collabora und
OnlyOffice verhalten sich gleich; deshalb kein besserer Bedienmodus,
sondern ein anderes Werkzeug. Gewandelt wird von dem Server, der auch
bearbeitet: Sonst sähe man unterwegs ein LibreOffice-Layout eines
Dokuments, für das man OnlyOffice gewählt hat.

Je Dokument gibt es GENAU EIN PDF, und das ist keine Frage des
Aufräumens, sondern des Schlüssels: previews/<id>.pdf trägt die Fassung
nicht im Namen, eine neue Fassung überschreibt. files.preview_version
sagt, ob das Abgelegte noch passt.

Sicherheit: Die Adresse, die OnlyOffice zurückgibt, wird von uns
abgerufen – der Wähler des Transports lässt nur OFFICE_ALLOWED_HOSTS zu,
zusätzlich eine ausdrückliche Prüfung mit Test. JWT-Signatur (HS256) für
die eigene API des Dokumentenservers. Fremde Kennung 404, ohne Anmeldung
401. Das Ergebnis muss mit %PDF- beginnen, sonst landete eine
Fehlerseite im Zwischenspeicher.

Gemessen gegen einen echten OnlyOffice-Server im Docker-Netz:
  erster Abruf 1,05 s -> zweiter 0,009 s
  erneut hochgeladen -> Fassung 2, preview_version 2, weiterhin 1 Datei
  GC: herrenloses PDF von vorgestern weg, echtes bleibt
  endgültig gelöscht -> PDF weg; Papierkorb -> PDF bleibt
  ohne Anmeldung 401, fremde Kennung 404, Nicht-Office 415

Dazu im Admin-Bereich neu: Platzbedarf der abgeleiteten Daten
(Vorschaubilder, PDFs, Profilbilder). Sie zählen gegen kein Kontingent –
ein Ordner, der wächst, ohne dass ihn jemand sieht, ist eine stille
Überraschung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 23:30:49 +02:00

529 lines
26 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 { 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 <FolderGlyph size={size} color={group ? 'rgb(var(--color-primary-rgb))' : '#f59e0b'} />
}
/**
* 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<string>) {
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<string, Tag[]>
/** Absender je Datei-ID. Anders als die Etiketten höchstens einer. */
fileCorrespondents?: Record<string, Correspondent>
/** Ausgewählte Dateien für den Sammel-Download. */
selected?: Set<string>
/** 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<HTMLDivElement | null>(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.
<div className="aether-grid">
{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 (
<div
key={folder.id}
draggable={!isGroupFolder}
onDragStart={e => {
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)}
>
<div style={{ height: 100, position: 'relative', overflow: 'hidden' }}>
<FolderCover folder={folder} highlight={isOver} />
</div>
<div style={{ padding: '8px 10px 10px' }}>
<p style={{ margin: 0, fontSize: 12, fontWeight: 500, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{folder.name}
</p>
<p style={{
margin: '2px 0 0', fontSize: 11,
color: isGroupFolder ? 'var(--color-primary)' : 'var(--color-text-muted)',
display: 'flex', alignItems: 'center', gap: 4,
}}>
{isGroupFolder && (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
<circle cx="9" cy="8" r="3.5" />
<path d="M2 21v-1a7 7 0 0114 0v1" />
<path d="M17 11a3 3 0 100-6M19 21v-1a6 6 0 00-3-5.2" />
</svg>
)}
{isGroupFolder ? 'Group folder' : 'Folder'}
</p>
</div>
</div>
)
})}
{files.map(file => (
<div
key={file.id}
draggable
onDragStart={e => 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<HTMLElement>('.aether-pick')
if (pick) pick.style.opacity = '1'
}}
onMouseLeave={e => {
e.currentTarget.style.borderColor = 'var(--color-border)'
const pick = e.currentTarget.querySelector<HTMLElement>('.aether-pick')
if (pick && !selected?.has(file.id) && (selected?.size ?? 0) === 0) pick.style.opacity = '0'
}}
>
<div style={{ height: 100, position: 'relative', background: 'var(--color-surface-2)' }}>
<FileCover file={file} />
{/* 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 && (
<button
className="aether-pick"
onClick={e => { e.stopPropagation(); onToggleSelect(file.id, e.shiftKey) }}
title="Select"
style={{
position: 'absolute', top: 6, left: 6,
width: 20, height: 20, borderRadius: 6, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: selected?.has(file.id) ? 'var(--color-primary)' : 'rgba(15,23,42,0.65)',
border: `1px solid ${selected?.has(file.id) ? 'var(--color-primary)' : 'rgba(255,255,255,0.35)'}`,
opacity: selected?.has(file.id) || (selected?.size ?? 0) > 0 ? 1 : 0,
transition: 'opacity 0.12s',
}}
>
{selected?.has(file.id) && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#fff"
strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4 12 10 18 20 6" />
</svg>
)}
</button>
)}
</div>
<div style={{ padding: '8px 10px 10px' }}>
<p style={{ margin: 0, fontSize: 12, fontWeight: 500, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={file.name}>
{file.name}
</p>
{/* 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. */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
margin: '3px 0 0', minWidth: 0, height: 18,
}}>
{fileCorrespondents?.[file.id] ? (
<span
title={fileCorrespondents[file.id].name}
style={{
fontSize: 11, color: 'var(--color-text-muted)', minWidth: 0,
display: 'flex', alignItems: 'center', gap: 4,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}
>
{fileCorrespondents[file.id].icon && (
<span style={{ flexShrink: 0 }}>{fileCorrespondents[file.id].icon}</span>
)}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
{fileCorrespondents[file.id].name}
</span>
</span>
) : (
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', flexShrink: 0 }}>
{formatBytes(file.size)}
</span>
)}
{(fileTags?.[file.id]?.length ?? 0) > 0 && (
<span style={{
marginLeft: 'auto', minWidth: 0, display: 'flex',
justifyContent: 'flex-end', flexShrink: 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. */}
<TagRow tags={fileTags?.[file.id] ?? []} max={1} />
</span>
)}
</div>
</div>
</div>
))}
</div>
)
}
// ── 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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '4px 12px', marginBottom: 4 }}>
<div style={{ width: 32 }} />
<div style={{ flex: 1, fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Name</div>
<div style={{ width: 80, fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', textAlign: 'right' }}>Size</div>
<div style={{ width: 100, fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', textAlign: 'right' }}>Modified</div>
<div style={{ width: 32 }} />
</div>
{folders.map(folder => {
const isOver = dragOverFolder === folder.id
return (
<div
key={folder.id}
draggable
onDragStart={e => {
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)}
>
<div style={{ width: 32, display: 'flex', justifyContent: 'center' }}>
<FolderIcon size={24} group={folder.group_id !== null} />
</div>
<div style={{ flex: 1, fontSize: 13, fontWeight: 500, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{folder.name}
</div>
<div style={{ width: 80, fontSize: 12, color: 'var(--color-text-muted)', textAlign: 'right' }}>—</div>
<div style={{ width: 100, fontSize: 12, color: 'var(--color-text-muted)', textAlign: 'right' }}>
{new Date(folder.created_at).toLocaleDateString()}
</div>
<div style={{ width: 32 }} />
</div>
)
})}
{files.map(file => (
<div
key={file.id}
draggable
onDragStart={e => 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'}
>
<div
style={{ width: 32, display: 'flex', justifyContent: 'center', flexShrink: 0 }}
onClick={e => {
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) ? (
<span style={{
width: 18, height: 18, borderRadius: 5,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: selected?.has(file.id) ? 'var(--color-primary)' : 'transparent',
border: `1px solid ${selected?.has(file.id) ? 'var(--color-primary)' : 'var(--color-border)'}`,
cursor: 'pointer',
}}>
{selected?.has(file.id) && (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#fff"
strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4 12 10 18 20 6" />
</svg>
)}
</span>
) : (
<FileTypeIcon mime={file.mime_type} name={file.name} size={22} />
)}
</div>
{/* 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. */}
<div style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{
fontSize: 13, color: 'var(--color-text)', minWidth: 0,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}} title={file.name}>
{file.name}
</span>
{(fileTags?.[file.id]?.length ?? 0) > 0 && (
<span style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
{(fileTags?.[file.id] ?? []).slice(0, 2).map(t => (
<TagChip key={t.id} tag={t} size="sm" />
))}
</span>
)}
</div>
{/* Eigene Spalte statt einer zweiten Zeile: In einer Tabelle gibt
es die Spalte ohnehin schon, und eine leere Zelle ändert die
Zeilenhöhe nicht. */}
<div
className="aether-from-col"
title={fileCorrespondents?.[file.id]?.name}
style={{
width: 130, fontSize: 12, color: 'var(--color-text-muted)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}
>
{fileCorrespondents?.[file.id]
? `${fileCorrespondents[file.id].icon} ${fileCorrespondents[file.id].name}`.trim()
: ''}
</div>
<div style={{ width: 80, fontSize: 12, color: 'var(--color-text-muted)', textAlign: 'right' }}>
{formatBytes(file.size)}
</div>
<div style={{ width: 100, fontSize: 12, color: 'var(--color-text-muted)', textAlign: 'right' }}>
{new Date(file.updated_at).toLocaleDateString()}
</div>
<button
onClick={e => { e.stopPropagation(); onFileDownload(file) }}
style={{ width: 32, height: 28, border: 'none', background: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
title="Download"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
</div>
))}
</div>
)
}