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>
529 lines
26 KiB
TypeScript
529 lines
26 KiB
TypeScript
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>
|
||
)
|
||
}
|