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:
HoneyandClaude Opus 5 committed 2026-08-24 09:25:36 +02:00
commit 31a027c93a
225 files changed
+38042

No files matched your search

+185
View File
@@ -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>
)
}