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,185 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user