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,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 '📁'
|
||||
}
|
||||
Reference in new issue
Block a user