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>
This commit is contained in:
commit
31a027c93a
225 files changed
+38042
No files matched your search
@@ -0,0 +1,511 @@
|
||||
import { useEffect, useRef, useState } 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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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 (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))',
|
||||
gap: 12,
|
||||
}}>
|
||||
{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',
|
||||
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)'; e.currentTarget.style.transform = 'translateY(-1px)' } }}
|
||||
onMouseLeave={e => { if (!isOver) { e.currentTarget.style.borderColor = 'var(--color-border)'; e.currentTarget.style.transform = 'none' } }}
|
||||
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 => {
|
||||
e.dataTransfer.setData('aether-file-id', file.id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
}}
|
||||
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, transform 0.1s, box-shadow 0.3s',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
}}
|
||||
onContextMenu={e => onFileMenu(e, file)}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.borderColor = 'var(--color-primary)'
|
||||
e.currentTarget.style.transform = 'translateY(-1px)'
|
||||
const pick = e.currentTarget.querySelector<HTMLElement>('.aether-pick')
|
||||
if (pick) pick.style.opacity = '1'
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.borderColor = 'var(--color-border)'
|
||||
e.currentTarget.style.transform = 'none'
|
||||
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 => {
|
||||
e.dataTransfer.setData('aether-file-id', file.id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
}}
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user