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>
186 lines
7.1 KiB
TypeScript
186 lines
7.1 KiB
TypeScript
import { useEffect, useState } from 'react'
|
||
import { filesApi, type Correspondent, type FileItem, type Tag } from '../api/client'
|
||
import { formatBytes } from '../pages/FileBrowser'
|
||
import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
|
||
import TagChip from './TagChip'
|
||
|
||
/**
|
||
* „Properties" einer Datei – das, was Dateimanager unter Windows und Linux
|
||
* im Rechtsklickmenü anbieten.
|
||
*
|
||
* Anlass war eine Lücke, die man erst beim Benutzen merkt: Bei einem Bild
|
||
* stand nirgends, **wie groß** es ist. Nicht in Byte – in Pixeln. Die Kachel
|
||
* zeigt eine Vorschau, die Liste zeigt Kilobyte, und ob das Foto 800 oder
|
||
* 4000 Punkte breit ist, war der Anwendung schlicht nicht zu entlocken.
|
||
*
|
||
* Die Maße kommen **hier im Browser** zustande, nicht vom Server: Er
|
||
* speichert sie nicht, und für eine Angabe in einem Dialog lohnt weder eine
|
||
* Spalte in der Datenbank noch ein Durchlauf über alle bestehenden Dateien.
|
||
* Der Browser kann jedes Bild, das er anzeigen kann, auch ausmessen.
|
||
*/
|
||
interface Props {
|
||
file: FileItem
|
||
tags: Tag[]
|
||
correspondent?: Correspondent
|
||
onClose: () => void
|
||
}
|
||
|
||
function formatDate(iso: string): string {
|
||
return new Date(iso).toLocaleString(undefined, {
|
||
day: '2-digit', month: 'short', year: 'numeric',
|
||
hour: '2-digit', minute: '2-digit',
|
||
})
|
||
}
|
||
|
||
/** Der Teil hinter dem letzten Punkt, in Großbuchstaben. Leer, wenn keiner. */
|
||
function extensionOf(name: string): string {
|
||
const at = name.lastIndexOf('.')
|
||
return at > 0 && at < name.length - 1 ? name.slice(at + 1).toUpperCase() : ''
|
||
}
|
||
|
||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||
return (
|
||
<div style={{ display: 'flex', gap: 12, padding: '5px 0', alignItems: 'baseline' }}>
|
||
<div style={{
|
||
width: 104, flexShrink: 0, fontSize: 12,
|
||
color: 'var(--color-text-muted)',
|
||
}}>{label}</div>
|
||
<div style={{ flex: 1, minWidth: 0, fontSize: 13, color: 'var(--color-text)' }}>
|
||
{children}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function FileInfoDialog({ file, tags, correspondent, onClose }: Props) {
|
||
const overlay = useOverlayDismiss(onClose)
|
||
const [dims, setDims] = useState<{ w: number; h: number } | null>(null)
|
||
const [copied, setCopied] = useState(false)
|
||
|
||
const isImage = file.mime_type.startsWith('image/')
|
||
|
||
useEffect(() => {
|
||
if (!isImage) return
|
||
let alive = true
|
||
const img = new Image()
|
||
// Dieselbe Adresse, die auch die Vorschau nimmt – das Bild liegt danach
|
||
// ohnehin im Zwischenspeicher des Browsers.
|
||
img.src = filesApi.inline(file.id)
|
||
img.onload = () => {
|
||
if (alive) setDims({ w: img.naturalWidth, h: img.naturalHeight })
|
||
}
|
||
// Kein onerror-Zweig: Kann der Browser das Bild nicht lesen (etwa ein
|
||
// Format, das er nicht kennt), bleibt die Zeile einfach weg. Eine
|
||
// Fehlermeldung über ein Maß, das niemand angefordert hat, wäre nur Lärm.
|
||
return () => { alive = false; img.src = '' }
|
||
}, [file.id, isImage])
|
||
|
||
async function copyChecksum() {
|
||
try {
|
||
await navigator.clipboard.writeText(file.checksum)
|
||
setCopied(true)
|
||
setTimeout(() => setCopied(false), 1500)
|
||
} catch {
|
||
/* Ohne sicheren Kontext gibt es keine Zwischenablage – dann bleibt der
|
||
Wert zum Markieren stehen, er ist ja vollständig zu sehen. */
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||
style={{ background: 'rgba(0,0,0,0.6)' }}
|
||
{...overlay}>
|
||
<div className="w-full max-w-md rounded-xl p-5 shadow-2xl border"
|
||
style={{ background: 'var(--color-surface)', borderColor: 'var(--color-border)' }}>
|
||
<h2 className="font-semibold mb-1" style={{ color: 'var(--color-text)' }}>Properties</h2>
|
||
<p className="mb-3" style={{
|
||
fontSize: 13, color: 'var(--color-text-muted)',
|
||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||
}} title={file.name}>{file.name}</p>
|
||
|
||
<div style={{ borderTop: '1px solid var(--color-border)', paddingTop: 6 }}>
|
||
<Row label="Type">
|
||
{extensionOf(file.name) && <strong style={{ fontWeight: 600 }}>{extensionOf(file.name)} </strong>}
|
||
<span style={{ color: 'var(--color-text-muted)' }}>{file.mime_type}</span>
|
||
</Row>
|
||
|
||
<Row label="Size">
|
||
{formatBytes(file.size)}
|
||
{/* Die genaue Zahl daneben: Beim Vergleich zweier Fassungen sind
|
||
„1,2 MB" und „1,2 MB" dasselbe, 1 261 «und» 1 264 Kilobyte
|
||
nicht. */}
|
||
<span style={{ color: 'var(--color-text-muted)' }}>
|
||
{' '}({file.size.toLocaleString()} bytes)
|
||
</span>
|
||
</Row>
|
||
|
||
{isImage && (
|
||
<Row label="Dimensions">
|
||
{dims
|
||
? <>{dims.w} × {dims.h} px
|
||
<span style={{ color: 'var(--color-text-muted)' }}>
|
||
{' '}({(dims.w * dims.h / 1e6).toFixed(1)} MP)
|
||
</span>
|
||
</>
|
||
: <span style={{ color: 'var(--color-text-muted)' }}>measuring…</span>}
|
||
</Row>
|
||
)}
|
||
|
||
{file.document_date && <Row label="Document date">{file.document_date}</Row>}
|
||
|
||
{correspondent && (
|
||
<Row label="From">
|
||
{correspondent.icon ? `${correspondent.icon} ${correspondent.name}` : correspondent.name}
|
||
</Row>
|
||
)}
|
||
|
||
{tags.length > 0 && (
|
||
<Row label="Tags">
|
||
<span style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||
{tags.map(t => <TagChip key={t.id} tag={t} size="sm" />)}
|
||
</span>
|
||
</Row>
|
||
)}
|
||
|
||
<Row label="Uploaded">{formatDate(file.created_at)}</Row>
|
||
<Row label="Modified">{formatDate(file.updated_at)}</Row>
|
||
|
||
<Row label="Version">
|
||
{file.version}
|
||
<span style={{ color: 'var(--color-text-muted)' }}>
|
||
{' '}({file.versioning_enabled ? 'history kept' : 'history off'})
|
||
</span>
|
||
</Row>
|
||
|
||
{/* Die Prüfsumme steht schon in der Datenbank und wird bei jedem
|
||
Download nachgerechnet. Sie hier zu zeigen kostet nichts und
|
||
beantwortet die eine Frage, für die es sonst keinen Weg gibt:
|
||
„Ist das dieselbe Datei wie die auf meiner Platte?" */}
|
||
<Row label="SHA-256">
|
||
<button
|
||
onClick={copyChecksum}
|
||
title="Copy"
|
||
style={{
|
||
fontFamily: 'ui-monospace, monospace', fontSize: 11, lineHeight: 1.4,
|
||
wordBreak: 'break-all', textAlign: 'left', cursor: 'pointer',
|
||
background: 'none', border: 'none', padding: 0,
|
||
color: copied ? 'var(--color-primary)' : 'var(--color-text-muted)',
|
||
}}
|
||
>
|
||
{copied ? 'copied' : file.checksum}
|
||
</button>
|
||
</Row>
|
||
</div>
|
||
|
||
<div className="flex justify-end mt-4">
|
||
<button onClick={onClose}
|
||
className="px-4 py-2 text-sm rounded-lg font-medium"
|
||
style={{ background: 'var(--color-primary)', color: 'var(--color-primary-fg)' }}>
|
||
Close
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|