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

+198
View File
@@ -0,0 +1,198 @@
import { useState, useEffect } from 'react'
import { trashApi, filesApi, type TrashItem } from '../api/client'
import { formatBytes } from './FileBrowser'
import PageHeader from '../components/PageHeader'
import ConfirmDialog from '../components/ConfirmDialog'
interface Props {
/** Wiederherstellen verändert den belegten Speicher – Balken nachziehen. */
onUserChanged?: () => Promise<void>
}
export default function TrashPage({ onUserChanged }: Props) {
const [items, setItems] = useState<TrashItem[]>([])
const [loading, setLoading] = useState(true)
const [confirmDelete, setConfirmDelete] = useState<TrashItem | null>(null)
const [confirmEmpty, setConfirmEmpty] = useState(false)
const [emptying, setEmptying] = useState(false)
useEffect(() => {
trashApi.list()
.then(data => setItems(data ?? []))
.catch(() => {})
.finally(() => setLoading(false))
}, [])
async function restore(file: TrashItem) {
await trashApi.restore(file.id)
setItems(i => i.filter(x => x.id !== file.id))
await onUserChanged?.()
}
async function emptyAll() {
setEmptying(true)
try {
await trashApi.emptyAll()
setItems([])
await onUserChanged?.()
} catch { /* Liste bleibt stehen, damit der Fehlschlag sichtbar ist */ }
setEmptying(false)
setConfirmEmpty(false)
}
async function deletePermanently(file: TrashItem) {
await trashApi.deletePermanently(file.id)
setItems(i => i.filter(x => x.id !== file.id))
setConfirmDelete(null)
}
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<PageHeader
icon={
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14H6L5 6M10 11v6M14 11v6M9 6V4h6v2" />
</svg>
}
title="Trash"
meta={items.length > 0 ? `${items.length} item${items.length !== 1 ? 's' : ''}` : undefined}
>
{items.length > 0 && (
<button
onClick={() => setConfirmEmpty(true)}
disabled={emptying}
style={{
padding: '6px 14px', borderRadius: 8, fontSize: 12, fontWeight: 500,
border: '1px solid var(--color-danger)', background: 'transparent',
color: 'var(--color-danger)', cursor: emptying ? 'default' : 'pointer',
}}
>
{emptying ? 'Emptying…' : 'Empty trash'}
</button>
)}
</PageHeader>
<div style={{ flex: 1, overflowY: 'auto', padding: 20 }}>
{loading && (
<div style={{ display: 'flex', justifyContent: 'center', paddingTop: 80 }}>
<div style={{ width: 24, height: 24, border: '2px solid var(--color-primary)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
)}
{!loading && items.length === 0 && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', paddingTop: 100, gap: 12 }}>
<div style={{ fontSize: 56, opacity: 0.25 }}>🗑</div>
<p style={{ color: 'var(--color-text-muted)', fontSize: 14, margin: 0 }}>Trash is empty</p>
</div>
)}
{!loading && items.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map(file => (
<div key={file.id} style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '10px 14px',
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 10,
}}>
{/* Thumbnail or icon */}
<div style={{
width: 44, height: 44, borderRadius: 8, flexShrink: 0,
overflow: 'hidden', background: 'var(--color-surface-2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{file.has_thumbnail ? (
<img
src={filesApi.thumbnail(file.id)}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={e => { e.currentTarget.style.display = 'none' }}
/>
) : (
<span style={{ fontSize: 20 }}>{mimeEmoji(file.mime_type)}</span>
)}
</div>
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ margin: 0, fontSize: 13, fontWeight: 500, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{file.name}
</p>
<p style={{ margin: '3px 0 0', fontSize: 11, color: 'var(--color-text-muted)', display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<span>{formatBytes(file.size)}</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="var(--color-warning)" stroke="none">
<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
</svg>
{file.folder_path ? `Home / ${file.folder_path}` : 'Home'}
</span>
</p>
</div>
{/* Actions */}
<button
onClick={() => restore(file)}
style={{
padding: '5px 12px', borderRadius: 6,
border: '1px solid var(--color-border)',
background: 'transparent', color: 'var(--color-text)',
fontSize: 12, cursor: 'pointer', whiteSpace: 'nowrap', flexShrink: 0,
}}
>
Restore
</button>
<button
onClick={() => setConfirmDelete(file)}
style={{
padding: '5px 12px', borderRadius: 6,
border: '1px solid var(--color-danger)',
background: 'transparent', color: 'var(--color-danger)',
fontSize: 12, cursor: 'pointer', whiteSpace: 'nowrap', flexShrink: 0,
}}
>
Delete forever
</button>
</div>
))}
</div>
)}
</div>
{confirmDelete && (
<ConfirmDialog
title="Permanently delete"
message={`"${confirmDelete.name}" permanently delete? This cannot be undone.`}
confirmLabel="Delete forever"
danger
onConfirm={() => deletePermanently(confirmDelete)}
onCancel={() => setConfirmDelete(null)}
/>
)}
{confirmEmpty && (
<ConfirmDialog
title="Empty trash"
message={`${items.length} ${items.length === 1 ? 'file' : 'files'} will be deleted permanently, including all revisions. This cannot be undone.`}
confirmLabel="Delete forever"
danger
onConfirm={emptyAll}
onCancel={() => setConfirmEmpty(false)}
/>
)}
</div>
)
}
function mimeEmoji(mime: string): string {
if (mime.startsWith('image/')) return '🖼'
if (mime.startsWith('video/')) return '🎬'
if (mime.startsWith('audio/')) return '🎵'
if (mime.includes('pdf')) return '📄'
if (mime.includes('zip') || mime.includes('tar')) return '📦'
if (mime.includes('word') || mime.includes('document')) return '📝'
if (mime.includes('sheet') || mime.includes('excel')) return '📊'
return '📁'
}