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

+329
View File
@@ -0,0 +1,329 @@
import { useState, useEffect } from 'react'
import { adminApi, type AuditEntry, type OcrStatus } from '../../api/client'
import { formatBytes } from './ui'
// Audit-Log, Speicher-Bereinigung und der Stand der Texterkennung –
// alles, was im Hintergrund läuft und sonst unbeobachtbar wäre.
// ── Audit Tab ─────────────────────────────────────────────────────────────────
const PAGE_SIZE = 50
/** Aktionsnamen sind maschinenlesbar (`admin.user_delete`). Hier stehen die
* lesbaren Entsprechungen – was nicht drinsteht, wird aus dem Schlüssel
* hergeleitet, damit neue Aktionen nicht als leere Zeile erscheinen. */
const ACTION_LABELS: Record<string, string> = {
'auth.login': 'Signed in',
'auth.login_failed': 'Sign-in failed',
'auth.logout': 'Signed out',
'auth.password_change': 'Changed password',
'app_password.create': 'Created app password',
'app_password.revoke': 'Revoked app password',
'admin.user_create': 'Created user',
'admin.user_update': 'Updated user',
'admin.user_delete': 'Deleted user',
'admin.user_password_reset': 'Reset a password',
'admin.group_create': 'Created group',
'admin.group_update': 'Updated group',
'admin.group_delete': 'Deleted group',
'admin.group_member_add': 'Added group member',
'admin.group_member_remove': 'Removed group member',
'admin.settings_update': 'Changed settings',
'share.create': 'Created share link',
'share.delete': 'Deleted share link',
'file.delete': 'Moved file to trash',
'file.restore': 'Restored file',
'file.trash_empty': 'Emptied trash',
'file.hard_delete': 'Permanently deleted file',
'system.gc': 'Cleaned up orphaned data',
}
function actionLabel(action: string): string {
return ACTION_LABELS[action] ?? action.split('.').pop()?.replace(/_/g, ' ') ?? action
}
/** Fehlgeschlagene Anmeldungen und endgültige Löschungen sind das, wonach man
* im Protokoll sucht – die bekommen Farbe, der Rest bleibt ruhig. */
function actionColor(action: string): string {
if (action === 'auth.login_failed') return 'var(--color-danger)'
if (action.includes('delete')) return 'var(--color-warning)'
return 'var(--color-text-muted)'
}
/**
* Stand der Texterkennung.
*
* Ohne diese Anzeige ist nicht zu unterscheiden, ob die Volltextsuche nichts
* findet oder den Bestand noch gar nicht gelesen hat. Nach dem ersten Start
* läuft die Erkennung je nach Menge stundenlang – bei einem Scan rund eine
* Minute je Seite –, und eine Suche ohne Treffer sieht in dieser Zeit aus wie
* ein Fehler.
*/
function IndexingStatus() {
const [status, setStatus] = useState<OcrStatus | null>(null)
const [failed, setFailed] = useState(false)
useEffect(() => {
let alive = true
let timer: ReturnType<typeof setTimeout>
async function tick() {
try {
const s = await adminApi.ocrStatus()
if (!alive) return
setStatus(s)
setFailed(false)
// Nur nachfragen, solange sich etwas bewegt. Steht die Warteschlange
// still, ändert sich die Zahl erst beim nächsten Upload – dann wäre
// jede weitere Anfrage verschenkt.
const busy = (s.pending ?? 0) + (s.running ?? 0) > 0
if (busy) timer = setTimeout(tick, 5000)
} catch {
if (alive) setFailed(true)
}
}
tick()
return () => { alive = false; clearTimeout(timer) }
}, [])
if (failed || !status) return null
const pending = status.pending ?? 0
const running = status.running ?? 0
const indexed = status.indexed ?? 0
const bad = status.failed ?? 0
const busy = pending + running > 0
let text: string
if (!status.enabled) {
text = 'Off — file contents are not searchable. Set OCR_ENABLED and make sure ocrmypdf and tesseract are in the image.'
} else if (busy) {
text = `${indexed} indexed · ${pending + running} to go — a scanned page takes about a minute.`
} else if (indexed === 0) {
text = 'No documents indexed yet. PDFs and images are read after upload.'
} else {
text = `${indexed} ${indexed === 1 ? 'document' : 'documents'} indexed — their contents are searchable.`
}
if (bad > 0) {
text += ` ${bad} could not be read.`
}
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16,
padding: '12px 14px', borderRadius: 10,
border: '1px solid var(--color-border)', background: 'var(--color-surface-2)',
}}>
{/* Der Punkt sagt den Zustand auch ohne Lesen: grün fertig, gelb läuft,
gedämpft aus. */}
<span style={{
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
background: !status.enabled
? 'var(--color-text-muted)'
: busy ? 'var(--color-warning)' : 'var(--color-success)',
}} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
Full-text indexing
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2 }}>
{text}
</div>
</div>
{busy && (
<div style={{
width: 12, height: 12, flexShrink: 0,
border: '2px solid var(--color-primary)', borderTopColor: 'transparent',
borderRadius: '50%', animation: 'spin 0.7s linear infinite',
}} />
)}
</div>
)
}
/** Der GC läuft von selbst alle 12 Stunden. Der Knopf ist da, damit man sieht,
* was er tut – ein Aufräumjob, der unbeobachtbar Dateien löscht, ist schwer zu
* vertrauen. */
function StorageCleanup() {
const [running, setRunning] = useState(false)
const [result, setResult] = useState<string | null>(null)
async function run() {
setRunning(true)
setResult(null)
try {
const r = await adminApi.runCleanup()
setResult(
r.deleted > 0
? `${r.deleted} orphaned ${r.deleted === 1 ? 'file' : 'files'} removed, ${formatBytes(r.freed_bytes)} freed (${r.scanned} scanned)`
: `Nothing to clean up — all ${r.scanned} stored files are accounted for`,
)
} catch {
setResult('Cleanup failed')
} finally {
setRunning(false)
}
}
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16,
padding: '12px 14px', borderRadius: 10,
border: '1px solid var(--color-border)', background: 'var(--color-surface-2)',
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
Storage cleanup
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2 }}>
{result ?? 'Removes stored files that no longer belong to any record. Runs automatically every 12 hours.'}
</div>
</div>
<button
onClick={run}
disabled={running}
style={{
padding: '7px 16px', borderRadius: 8, flexShrink: 0,
border: '1px solid var(--color-border)', background: 'var(--color-surface)',
color: 'var(--color-text)', fontSize: 12, fontWeight: 600,
cursor: running ? 'default' : 'pointer', opacity: running ? 0.6 : 1,
}}
>
{running ? 'Running…' : 'Run now'}
</button>
</div>
)
}
export default function AuditTab() {
const [entries, setEntries] = useState<AuditEntry[]>([])
const [total, setTotal] = useState(0)
const [actions, setActions] = useState<string[]>([])
const [filter, setFilter] = useState('')
const [page, setPage] = useState(0)
const [loading, setLoading] = useState(true)
useEffect(() => {
adminApi.listAuditActions().then(a => setActions(a ?? [])).catch(() => {})
}, [])
useEffect(() => {
setLoading(true)
adminApi.listAudit({ action: filter || undefined, limit: PAGE_SIZE, offset: page * PAGE_SIZE })
.then(res => { setEntries(res.entries ?? []); setTotal(res.total) })
.catch(() => {})
.finally(() => setLoading(false))
}, [filter, page])
const pages = Math.ceil(total / PAGE_SIZE)
return (
<div>
<IndexingStatus />
<StorageCleanup />
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<select
value={filter}
onChange={e => { setFilter(e.target.value); setPage(0) }}
style={{
padding: '7px 10px', borderRadius: 8, border: '1px solid var(--color-border)',
background: 'var(--color-surface-2)', color: 'var(--color-text)', fontSize: 13,
}}
>
<option value="">All actions</option>
{actions.map(a => <option key={a} value={a}>{actionLabel(a)}</option>)}
</select>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{total} {total === 1 ? 'entry' : 'entries'}
</span>
</div>
{loading ? (
<p style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>Loading…</p>
) : entries.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>Nothing recorded yet.</p>
) : (
<div style={{
border: '1px solid var(--color-border)', borderRadius: 12, overflow: 'hidden',
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: 'var(--color-surface-2)' }}>
{['When', 'Who', 'Action', 'Target', 'From'].map(h => (
<th key={h} style={{
textAlign: 'left', padding: '9px 12px', fontSize: 11,
fontWeight: 700, color: 'var(--color-text-muted)',
textTransform: 'uppercase', letterSpacing: 0.4,
}}>{h}</th>
))}
</tr>
</thead>
<tbody>
{entries.map(e => (
<tr key={e.id} style={{ borderTop: '1px solid var(--color-border)' }}>
<td style={{ padding: '9px 12px', fontSize: 12, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>
{new Date(e.created_at).toLocaleString()}
</td>
<td style={{ padding: '9px 12px', fontSize: 13, color: 'var(--color-text)' }}>
{e.actor_name}
{/* Fehlt die actor_id, ist das Konto gelöscht – der Name bleibt
stehen, damit der Eintrag lesbar bleibt. Bei einem Fehlversuch
auf einen unbekannten Namen gab es dagegen nie ein Konto,
„gelöscht" wäre dort schlicht falsch. */}
{e.actor_id === null && e.actor_name !== 'system'
&& e.action !== 'auth.login_failed' && (
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}> (deleted account)</span>
)}
</td>
<td style={{ padding: '9px 12px', fontSize: 13, color: actionColor(e.action), fontWeight: 500 }}>
{actionLabel(e.action)}
</td>
<td style={{ padding: '9px 12px', fontSize: 12, color: 'var(--color-text-muted)', maxWidth: 280 }}>
{e.target_name || e.detail || '—'}
{e.target_name && e.detail && (
<div style={{ fontSize: 11, opacity: 0.8 }}>{e.detail}</div>
)}
</td>
<td style={{ padding: '9px 12px', fontSize: 12, color: 'var(--color-text-muted)', fontFamily: 'monospace' }}>
{e.ip ?? '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{pages > 1 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 14 }}>
<button
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0}
style={{
padding: '6px 14px', borderRadius: 8, border: '1px solid var(--color-border)',
background: 'transparent', color: 'var(--color-text)', fontSize: 12,
cursor: page === 0 ? 'default' : 'pointer', opacity: page === 0 ? 0.5 : 1,
}}
>
Previous
</button>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
Page {page + 1} of {pages}
</span>
<button
onClick={() => setPage(p => Math.min(pages - 1, p + 1))}
disabled={page >= pages - 1}
style={{
padding: '6px 14px', borderRadius: 8, border: '1px solid var(--color-border)',
background: 'transparent', color: 'var(--color-text)', fontSize: 12,
cursor: page >= pages - 1 ? 'default' : 'pointer', opacity: page >= pages - 1 ? 0.5 : 1,
}}
>
Next
</button>
</div>
)}
</div>
)
}
+357
View File
@@ -0,0 +1,357 @@
import { useState, useEffect, useCallback } from 'react'
import { adminApi, type AdminUser, type AdminGroup, type GroupMember, type GroupFolder } from '../../api/client'
import ConfirmDialog from '../../components/ConfirmDialog'
import { FolderGlyph } from '../../components/icons'
import { Card, Btn, Input, Modal, formatBytes } from './ui'
// Gruppen und Gruppenordner.
// ── Groups Tab ────────────────────────────────────────────────────────────────
/** Gruppenordner: ein Ordner, der der Gruppe gehört statt einer Person. Jedes
* Mitglied sieht ihn in seiner Wurzel; entfernen kann ihn nur der Admin – und
* zwar hier, nicht in der Dateiansicht. Deshalb steht der Löschen-Knopf
* ausschließlich an dieser Stelle. */
function GroupFolders({ groups }: { groups: AdminGroup[] }) {
const [folders, setFolders] = useState<GroupFolder[]>([])
const [loading, setLoading] = useState(true)
const [groupId, setGroupId] = useState('')
const [name, setName] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [confirming, setConfirming] = useState<GroupFolder | null>(null)
useEffect(() => { load() }, [])
async function load() {
try {
setFolders(await adminApi.listGroupFolders() ?? [])
} catch {
setError('Could not load group folders')
} finally {
setLoading(false)
}
}
async function create() {
if (!groupId || !name.trim()) return
setBusy(true); setError('')
try {
await adminApi.createGroupFolder(groupId, name.trim())
setName('')
await load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not create group folder')
} finally {
setBusy(false)
}
}
async function remove(folder: GroupFolder) {
setConfirming(null)
try {
await adminApi.deleteGroupFolder(folder.id)
await load()
} catch {
setError('Could not delete group folder')
}
}
return (
<div style={{ marginBottom: 28 }}>
<h3 style={{ margin: '0 0 4px', fontSize: 15, fontWeight: 700, color: 'var(--color-text)' }}>
Group folders
</h3>
<p style={{ margin: '0 0 14px', fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
A folder that belongs to the group instead of a person. Every member sees it
next to their own folders and can work in it. Members cannot delete or rename
the folder itself — only you can, here.
</p>
<div style={{ display: 'flex', gap: 8, marginBottom: 14 }}>
<select
value={groupId}
onChange={e => setGroupId(e.target.value)}
style={{
padding: '8px 10px', borderRadius: 8, border: '1px solid var(--color-border)',
background: 'var(--color-surface-2)', color: 'var(--color-text)', fontSize: 13,
}}
>
<option value="">Choose a group…</option>
{groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
<input
value={name}
onChange={e => setName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') create() }}
placeholder="Folder name"
style={{
flex: 1, minWidth: 0, padding: '8px 10px', borderRadius: 8,
border: '1px solid var(--color-border)',
background: 'var(--color-surface-2)', color: 'var(--color-text)',
fontSize: 13, outline: 'none',
}}
/>
<button
onClick={create}
disabled={!groupId || !name.trim() || busy}
style={{
padding: '8px 18px', borderRadius: 8, border: 'none', flexShrink: 0,
background: 'var(--color-primary)', color: '#fff', fontSize: 13, fontWeight: 600,
cursor: !groupId || !name.trim() || busy ? 'default' : 'pointer',
opacity: !groupId || !name.trim() || busy ? 0.5 : 1,
}}
>
{busy ? 'Creating…' : 'Create'}
</button>
</div>
{error && <p style={{ margin: '0 0 10px', fontSize: 12, color: 'var(--color-danger)' }}>{error}</p>}
{loading ? (
<p style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>Loading…</p>
) : folders.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>No group folders yet.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{folders.map(f => (
<div key={f.id} style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 12px', borderRadius: 9, border: '1px solid var(--color-border)',
}}>
<span style={{ flexShrink: 0, display: 'flex' }}>
<FolderGlyph size={20} color="rgb(var(--color-primary-rgb))" />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>{f.name}</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2 }}>
{f.group_name} · {f.members} {f.members === 1 ? 'member' : 'members'} · {formatBytes(f.used_bytes)} used
</div>
</div>
<button
onClick={() => setConfirming(f)}
style={{
padding: '5px 12px', borderRadius: 7, flexShrink: 0,
border: '1px solid var(--color-danger)', background: 'transparent',
color: 'var(--color-danger)', fontSize: 12, cursor: 'pointer',
}}
>
Delete
</button>
</div>
))}
</div>
)}
{confirming && (
<ConfirmDialog
title="Delete group folder"
message={`"${confirming.name}" and everything in it will be permanently deleted for all ${confirming.members} members. This cannot be undone.`}
confirmLabel="Delete permanently"
danger
onConfirm={() => remove(confirming)}
onCancel={() => setConfirming(null)}
/>
)}
</div>
)
}
export default function GroupsTab({ allUsers }: { allUsers: AdminUser[] }) {
const [groups, setGroups] = useState<AdminGroup[]>([])
const [members, setMembers] = useState<Record<string, GroupMember[]>>({})
const [expanded, setExpanded] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [newName, setNewName] = useState('')
const [newDesc, setNewDesc] = useState('')
const [showCreate, setShowCreate] = useState(false)
const [editing, setEditing] = useState<AdminGroup | null>(null)
const [confirmGroup, setConfirmGroup] = useState<AdminGroup | null>(null)
const [groupError, setGroupError] = useState('')
const [editName, setEditName] = useState('')
const [editDesc, setEditDesc] = useState('')
const load = useCallback(() => {
adminApi.listGroups()
.then(data => setGroups(data ?? []))
.catch(() => {})
.finally(() => setLoading(false))
}, [])
useEffect(() => { load() }, [load])
async function toggleExpand(groupId: string) {
if (expanded === groupId) { setExpanded(null); return }
setExpanded(groupId)
if (!members[groupId]) {
const data = await adminApi.listMembers(groupId).catch(() => [])
setMembers(m => ({ ...m, [groupId]: data ?? [] }))
}
}
async function handleCreate() {
if (!newName) return
await adminApi.createGroup(newName, newDesc)
setNewName(''); setNewDesc(''); setShowCreate(false); load()
}
async function handleDelete(g: AdminGroup) {
setConfirmGroup(null)
try {
await adminApi.deleteGroup(g.id)
load()
} catch (e) {
setGroupError(`Could not delete group: ${e instanceof Error ? e.message : e}`)
}
}
function openEdit(g: AdminGroup) {
setEditing(g); setEditName(g.name); setEditDesc(g.description ?? '')
}
async function handleUpdate() {
if (!editing || !editName.trim()) return
try {
await adminApi.updateGroup(editing.id, editName.trim(), editDesc)
setEditing(null); load()
} catch (e) {
setGroupError(`Could not save group: ${e instanceof Error ? e.message : e}`)
}
}
async function handleAddMember(groupId: string, userId: string) {
await adminApi.addMember(groupId, userId)
const data = await adminApi.listMembers(groupId).catch(() => [])
setMembers(m => ({ ...m, [groupId]: data ?? [] }))
load()
}
async function handleRemoveMember(groupId: string, userId: string) {
await adminApi.removeMember(groupId, userId)
setMembers(m => ({ ...m, [groupId]: (m[groupId] ?? []).filter(x => x.user_id !== userId) }))
load()
}
return (
<div>
{/* Gruppenordner zuerst: Mitgliedschaft ohne gemeinsamen Ordner tut nichts,
der Ordner ist der eigentliche Zweck einer Gruppe. */}
<GroupFolders groups={groups} />
<div style={{
height: 1, background: 'var(--color-border)', margin: '0 0 24px',
}} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--color-text)' }}>Groups</h2>
<Btn variant="primary" onClick={() => setShowCreate(v => !v)}>+ Create group</Btn>
</div>
{showCreate && (
<Card style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end' }}>
<div style={{ flex: 1 }}><Input label="Name" value={newName} onChange={setNewName} placeholder="e.g. Editors" /></div>
<div style={{ flex: 2 }}><Input label="Description" value={newDesc} onChange={setNewDesc} placeholder="Optional" /></div>
<Btn variant="primary" onClick={handleCreate} disabled={!newName}>Create</Btn>
<Btn onClick={() => setShowCreate(false)}>Cancel</Btn>
</div>
</Card>
)}
{groupError && (
<Card style={{
marginBottom: 12, borderColor: 'var(--color-danger)',
background: 'rgba(239,68,68,0.08)', padding: '10px 14px',
display: 'flex', alignItems: 'center', gap: 10,
}}>
<span style={{ flex: 1, fontSize: 13, color: 'var(--color-danger)' }}>{groupError}</span>
<button onClick={() => setGroupError('')} style={{
background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--color-danger)', fontSize: 16, lineHeight: 1,
}}>×</button>
</Card>
)}
{confirmGroup && (
<ConfirmDialog
title="Delete group"
message={`Delete group "${confirmGroup.name}"? Members keep their accounts and files – only the group is removed.`}
confirmLabel="Delete group"
danger
onConfirm={() => handleDelete(confirmGroup)}
onCancel={() => setConfirmGroup(null)}
/>
)}
{editing && (
<Modal title={`Edit group "${editing.name}"`} onClose={() => setEditing(null)}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<Input label="Name" value={editName} onChange={setEditName} />
<Input label="Description" value={editDesc} onChange={setEditDesc} placeholder="Optional" />
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Btn onClick={() => setEditing(null)}>Cancel</Btn>
<Btn variant="primary" onClick={handleUpdate} disabled={!editName.trim()}>Save</Btn>
</div>
</div>
</Modal>
)}
<Card>
{loading && <p style={{ color: 'var(--color-text-muted)', fontSize: 13 }}>Loading…</p>}
{!loading && groups.length === 0 && (
<p style={{ color: 'var(--color-text-muted)', fontSize: 13, textAlign: 'center', padding: '20px 0' }}>No groups yet</p>
)}
{groups.map((g, i) => {
const isOpen = expanded === g.id
const groupMembers = members[g.id] ?? []
const nonMembers = allUsers.filter(u => !groupMembers.some(m => m.user_id === u.id))
return (
<div key={g.id} style={{ borderBottom: i < groups.length - 1 ? '1px solid var(--color-border)' : 'none' }}>
<div style={{ display: 'flex', alignItems: 'center', padding: '12px 4px', gap: 10 }}>
<button onClick={() => toggleExpand(g.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', display: 'flex' }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
style={{ transform: isOpen ? 'rotate(90deg)' : 'none', transition: 'transform 0.15s' }}>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)' }}>{g.name}</div>
{g.description && <div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{g.description}</div>}
</div>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{g.member_count} {g.member_count === 1 ? 'member' : 'members'}</span>
<Btn size="sm" onClick={() => openEdit(g)}>Edit</Btn>
<Btn size="sm" variant="danger" onClick={() => handleDelete(g)}>Delete</Btn>
</div>
{isOpen && (
<div style={{ paddingLeft: 28, paddingBottom: 12 }}>
{groupMembers.length === 0 && <p style={{ fontSize: 12, color: 'var(--color-text-muted)', margin: '4px 0 8px' }}>No members</p>}
{groupMembers.map(m => (
<div key={m.user_id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0' }}>
<span style={{ fontSize: 13, color: 'var(--color-text)', flex: 1 }}>{m.username}{m.display_name ? ` (${m.display_name})` : ''}</span>
<Btn size="sm" variant="danger" onClick={() => handleRemoveMember(g.id, m.user_id)}>Remove</Btn>
</div>
))}
{nonMembers.length > 0 && (
<div style={{ display: 'flex', gap: 8, marginTop: 8, alignItems: 'center' }}>
<select
style={{ padding: '5px 8px', borderRadius: 7, border: '1px solid var(--color-border)', background: 'var(--color-surface-2)', color: 'var(--color-text)', fontSize: 12 }}
defaultValue=""
onChange={e => { if (e.target.value) { handleAddMember(g.id, e.target.value); e.target.value = '' } }}
>
<option value="" disabled>Add member…</option>
{nonMembers.map(u => (
<option key={u.id} value={u.id}>{u.username}</option>
))}
</select>
</div>
)}
</div>
)}
</div>
)
})}
</Card>
</div>
)
}
+295
View File
@@ -0,0 +1,295 @@
import { useState, useEffect } from 'react'
import { adminApi, type AppSettings } from '../../api/client'
import { applyTheme } from '../../theme'
import { useAppSettings } from '../../hooks/useAppSettings'
import ConfirmDialog from '../../components/ConfirmDialog'
import { Card, Btn, Input } from './ui'
// Erscheinungsbild: Name, Farben, Logo und App-Symbol.
// ── Theming Tab ───────────────────────────────────────────────────────────────
/** Bild-Verwaltung für Logo und Favicon. Getrennt vom übrigen
* Theming-Formular, weil ein Upload sofort wirkt statt erst beim Speichern –
* ein Bild in einem Formular mit Speichern-Knopf würde die Frage aufwerfen,
* ob es schon hochgeladen ist. */
interface ImageSettingProps {
label: string
url: string
/** Quadratischer Rahmen fürs Favicon, breiter fürs Logo. */
preview: { width: number; height: number }
accept: string
hint: string
removeTitle: string
removeMessage: string
onUpload: (file: File) => Promise<unknown>
onRemove: () => Promise<unknown>
onDone: () => Promise<void>
}
function ImageSetting({
label, url, preview, accept, hint, removeTitle, removeMessage,
onUpload, onRemove, onDone,
}: ImageSettingProps) {
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [confirming, setConfirming] = useState(false)
const what = label.toLowerCase()
async function pick(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
// Zurücksetzen, damit dieselbe Datei erneut gewählt werden kann.
e.target.value = ''
if (!file) return
setBusy(true); setError('')
try {
await onUpload(file)
await onDone()
} catch (err) {
setError(err instanceof Error ? err.message : `Could not upload the ${what}`)
} finally {
setBusy(false)
}
}
async function remove() {
setConfirming(false)
setBusy(true); setError('')
try {
await onRemove()
await onDone()
} catch {
setError(`Could not remove the ${what}`)
} finally {
setBusy(false)
}
}
return (
<Card style={{ maxWidth: 480, marginBottom: 16 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 10 }}>
{label}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{
width: preview.width, height: preview.height, borderRadius: 10, flexShrink: 0,
border: '1px solid var(--color-border)',
background: 'var(--color-surface-2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
overflow: 'hidden',
}}>
{url ? (
<img src={url} alt="" style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} />
) : (
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>none</span>
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', gap: 8, marginBottom: 6 }}>
<label style={{
padding: '7px 14px', borderRadius: 8,
background: 'var(--color-primary)', color: '#fff',
fontSize: 12, fontWeight: 600,
cursor: busy ? 'default' : 'pointer', opacity: busy ? 0.6 : 1,
}}>
{busy ? 'Uploading…' : url ? 'Replace' : 'Upload'}
<input type="file" accept={accept}
onChange={pick} disabled={busy} style={{ display: 'none' }} />
</label>
{url && (
<button
onClick={() => setConfirming(true)}
disabled={busy}
style={{
padding: '7px 14px', borderRadius: 8,
border: '1px solid var(--color-border)', background: 'transparent',
color: 'var(--color-text-muted)', fontSize: 12, cursor: 'pointer',
}}
>
Remove
</button>
)}
</div>
<p style={{ margin: 0, fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
{hint}
</p>
</div>
</div>
{error && <p style={{ margin: '10px 0 0', fontSize: 12, color: 'var(--color-danger)' }}>{error}</p>}
{confirming && (
<ConfirmDialog
title={removeTitle}
message={removeMessage}
confirmLabel="Remove"
danger
onConfirm={remove}
onCancel={() => setConfirming(false)}
/>
)}
</Card>
)
}
/** Die beiden Bilder des Erscheinungsbilds. */
function BrandingSettings() {
const { logoUrl, appIconUrl, refresh } = useAppSettings()
return (
<>
<ImageSetting
label="Logo"
url={logoUrl}
preview={{ width: 84, height: 60 }}
accept="image/png,image/jpeg,image/webp,image/gif"
hint="Shown on the sign-in page and in the sidebar. PNG, JPEG, WebP or GIF, up to 2 MB. Takes effect immediately."
removeTitle="Remove logo"
removeMessage="The sign-in page and sidebar will show the default icon again."
onUpload={adminApi.uploadLogo}
onRemove={adminApi.deleteLogo}
onDone={refresh}
/>
<ImageSetting
label="App icon"
url={appIconUrl}
preview={{ width: 60, height: 60 }}
accept="image/png,image/webp,image/gif,image/jpeg"
hint="Used for the browser tab and for the installed app. One square PNG, at least 192×192 (512×512 is better), up to 2 MB – all sizes are generated from it. The tab changes immediately; an already installed app follows within a day or two."
removeTitle="Remove app icon"
removeMessage="Tabs and the installed app will show the default Aether icon again."
onUpload={adminApi.uploadAppIcon}
onRemove={adminApi.deleteAppIcon}
onDone={refresh}
/>
</>
)
}
export default function ThemingTab() {
const [settings, setSettings] = useState<Partial<AppSettings>>({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const { refresh } = useAppSettings()
useEffect(() => {
adminApi.getSettings()
.then(data => setSettings(data ?? {}))
.catch(() => {})
.finally(() => setLoading(false))
}, [])
// Farbwahl wirkt sofort auf die ganze Oberfläche – man sieht das Ergebnis,
// bevor man sich festlegt. Beim Verlassen ohne Speichern wird der
// gespeicherte Stand wiederhergestellt, damit keine Vorschau hängen bleibt.
function previewColors(next: Partial<AppSettings>) {
applyTheme(next.primary_color, next.accent_color)
}
useEffect(() => {
return () => { void refresh() }
}, [refresh])
async function handleSave() {
setSaving(true)
const result = await adminApi.updateSettings({
cloud_name: settings.cloud_name,
primary_color: settings.primary_color,
accent_color: settings.accent_color,
}).catch(() => null)
setSaving(false)
if (result) {
setSettings(result)
setSaved(true)
// Neu laden statt nur die Variablen zu setzen: so übernimmt auch der
// Cloud-Name in der Seitenleiste und im Fenstertitel sofort.
await refresh()
setTimeout(() => setSaved(false), 2000)
}
}
if (loading) return <p style={{ color: 'var(--color-text-muted)', fontSize: 13 }}>Loading…</p>
return (
<div>
<h2 style={{ margin: '0 0 16px', fontSize: 18, fontWeight: 700, color: 'var(--color-text)' }}>Theming</h2>
<BrandingSettings />
<Card style={{ maxWidth: 480 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
<Input
label="Cloud name"
value={settings.cloud_name ?? ''}
onChange={v => setSettings(s => ({ ...s, cloud_name: v }))}
placeholder="Aether"
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)' }}>Primary colour</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<input
type="color"
value={settings.primary_color ?? '#6366f1'}
onChange={e => setSettings(s => {
const next = { ...s, primary_color: e.target.value }
previewColors(next)
return next
})}
style={{ width: 40, height: 36, borderRadius: 8, border: '1px solid var(--color-border)', cursor: 'pointer', padding: 2 }}
/>
<span style={{ fontSize: 13, fontFamily: 'monospace', color: 'var(--color-text)' }}>
{settings.primary_color ?? '#6366f1'}
</span>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)' }}>Accent colour</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<input
type="color"
value={settings.accent_color ?? '#8b5cf6'}
onChange={e => setSettings(s => {
const next = { ...s, accent_color: e.target.value }
previewColors(next)
return next
})}
style={{ width: 40, height: 36, borderRadius: 8, border: '1px solid var(--color-border)', cursor: 'pointer', padding: 2 }}
/>
<span style={{ fontSize: 13, fontFamily: 'monospace', color: 'var(--color-text)' }}>
{settings.accent_color ?? '#8b5cf6'}
</span>
</div>
</div>
{/* Preview */}
<div style={{ padding: 14, borderRadius: 10, border: '1px solid var(--color-border)', background: 'var(--color-surface-2)' }}>
<p style={{ margin: '0 0 8px', fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>Preview</p>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<div style={{ width: 28, height: 28, borderRadius: 7, background: settings.primary_color ?? '#6366f1', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z" />
</svg>
</div>
<span style={{ fontWeight: 700, fontSize: 15, color: 'var(--color-text)' }}>{settings.cloud_name || 'Aether'}</span>
<div style={{ marginLeft: 'auto', padding: '4px 12px', borderRadius: 99, background: settings.primary_color ?? '#6366f1', color: '#fff', fontSize: 12, fontWeight: 600 }}>
Upload
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Btn variant="primary" onClick={handleSave} disabled={saving}>
{saving ? 'Saving…' : 'Save settings'}
</Btn>
{saved && <span style={{ fontSize: 12, color: '#22c55e', fontWeight: 600 }}>✓ Saved</span>}
</div>
</div>
</Card>
</div>
)
}
+264
View File
@@ -0,0 +1,264 @@
import { useState, useEffect, useCallback } from 'react'
import { adminApi, type AdminUser } from '../../api/client'
import ConfirmDialog from '../../components/ConfirmDialog'
import { Card, Btn, Input, Modal, formatBytes, bytesFromGB, gbFromBytes } from './ui'
// Benutzerverwaltung: anlegen, bearbeiten, Passwort zurücksetzen, löschen.
// ── Users Tab ─────────────────────────────────────────────────────────────────
export default function UsersTab() {
const [users, setUsers] = useState<AdminUser[]>([])
const [loading, setLoading] = useState(true)
const [modal, setModal] = useState<'create' | 'edit' | 'reset' | null>(null)
const [selected, setSelected] = useState<AdminUser | null>(null)
const [resetResult, setResetResult] = useState('')
const [error, setError] = useState('')
const [confirmUser, setConfirmUser] = useState<AdminUser | null>(null)
// Create form
const [newUsername, setNewUsername] = useState('')
const [newDisplay, setNewDisplay] = useState('')
const [newPassword, setNewPassword] = useState('')
const [newIsAdmin, setNewIsAdmin] = useState(false)
const [newQuotaGB, setNewQuotaGB] = useState('10')
// Edit form
const [editDisplay, setEditDisplay] = useState('')
const [editIsAdmin, setEditIsAdmin] = useState(false)
const [editQuotaGB, setEditQuotaGB] = useState('10')
const load = useCallback(() => {
adminApi.listUsers()
.then(data => setUsers(data ?? []))
.catch(() => {})
.finally(() => setLoading(false))
}, [])
useEffect(() => { load() }, [load])
async function handleCreate() {
if (!newUsername || !newPassword) return
try {
await adminApi.createUser({
username: newUsername, display_name: newDisplay,
password: newPassword, is_admin: newIsAdmin,
quota_bytes: bytesFromGB(parseFloat(newQuotaGB) || 10),
})
setModal(null); setNewUsername(''); setNewDisplay(''); setNewPassword(''); setNewIsAdmin(false); setNewQuotaGB('10')
load()
} catch (e) {
setError(`Could not create user: ${e instanceof Error ? e.message : e}`)
}
}
async function handleEdit() {
if (!selected) return
try {
await adminApi.updateUser(selected.id, {
display_name: editDisplay,
is_admin: editIsAdmin,
quota_bytes: bytesFromGB(parseFloat(editQuotaGB) || 10),
})
setModal(null); setSelected(null); load()
} catch (e) {
setError(`Could not save: ${e instanceof Error ? e.message : e}`)
}
}
async function handleReset() {
if (!selected) return
try {
const res = await adminApi.resetPassword(selected.id)
setResetResult(res.new_password)
} catch (e) {
setError(`Could not reset password: ${e instanceof Error ? e.message : e}`)
}
}
async function handleDelete(user: AdminUser) {
setConfirmUser(null)
try {
await adminApi.deleteUser(user.id)
load()
} catch (e) {
setError(`Could not delete: ${e instanceof Error ? e.message : e}`)
}
}
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--color-text)' }}>Users</h2>
<Btn variant="primary" onClick={() => setModal('create')}>+ Create user</Btn>
</div>
{error && (
<Card style={{
marginBottom: 12, borderColor: 'var(--color-danger)',
background: 'rgba(239,68,68,0.08)', padding: '10px 14px',
display: 'flex', alignItems: 'center', gap: 10,
}}>
<span style={{ flex: 1, fontSize: 13, color: 'var(--color-danger)' }}>{error}</span>
<button onClick={() => setError('')} style={{
background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--color-danger)', fontSize: 16, lineHeight: 1,
}}>×</button>
</Card>
)}
<Card>
{loading && <p style={{ color: 'var(--color-text-muted)', fontSize: 13 }}>Loading…</p>}
{!loading && (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--color-border)' }}>
{['User', 'Storage', 'Quota', 'Groups', 'Role', ''].map(h => (
<th key={h} style={{ padding: '6px 10px', textAlign: 'left', fontSize: 11, fontWeight: 700, color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id} style={{ borderBottom: '1px solid var(--color-border)' }}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--color-surface-2)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
<td style={{ padding: '10px 10px' }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>{u.username}</div>
{u.display_name && <div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>{u.display_name}</div>}
</td>
<td style={{ padding: '10px 10px', fontSize: 12, color: 'var(--color-text-muted)' }}>
{formatBytes(u.used_bytes)}
</td>
<td style={{ padding: '10px 10px', fontSize: 12, color: 'var(--color-text-muted)' }}>
{formatBytes(u.quota_bytes)}
</td>
<td style={{ padding: '10px 10px' }}>
{u.groups && u.groups.length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{u.groups.map(g => (
<span key={g} style={{
fontSize: 11, padding: '2px 8px', borderRadius: 99,
background: 'var(--color-surface-2)', color: 'var(--color-text-muted)',
}}>{g}</span>
))}
</div>
) : (
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>–</span>
)}
</td>
<td style={{ padding: '10px 10px' }}>
<span style={{
fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 99,
background: u.is_admin ? 'rgba(var(--color-primary-rgb), 0.15)' : 'var(--color-surface-2)',
color: u.is_admin ? 'var(--color-primary)' : 'var(--color-text-muted)',
}}>
{u.is_admin ? 'Admin' : 'User'}
</span>
</td>
<td style={{ padding: '10px 10px' }}>
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
<Btn size="sm" onClick={() => {
setSelected(u); setEditDisplay(u.display_name)
setEditIsAdmin(u.is_admin); setEditQuotaGB(String(gbFromBytes(u.quota_bytes)))
setModal('edit')
}}>Edit</Btn>
<Btn size="sm" onClick={() => { setSelected(u); setResetResult(''); setModal('reset') }}>Reset password</Btn>
<Btn size="sm" variant="danger" onClick={() => handleDelete(u)}>Delete</Btn>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
{/* Create modal */}
{modal === 'create' && (
<Modal title="Create user" onClose={() => setModal(null)}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<Input label="Username *" value={newUsername} onChange={setNewUsername} placeholder="e.g. alice" />
<Input label="Display name" value={newDisplay} onChange={setNewDisplay} placeholder="Alice Smith" />
<Input label="Password *" type="password" value={newPassword} onChange={setNewPassword} />
<Input label="Quota (GB)" value={newQuotaGB} onChange={setNewQuotaGB} placeholder="10" />
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={newIsAdmin} onChange={e => setNewIsAdmin(e.target.checked)} />
Administrator
</label>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 4 }}>
<Btn onClick={() => setModal(null)}>Cancel</Btn>
<Btn variant="primary" onClick={handleCreate} disabled={!newUsername || !newPassword}>Create</Btn>
</div>
</div>
</Modal>
)}
{/* Edit modal */}
{modal === 'edit' && selected && (
<Modal title={`Edit ${selected.username}`} onClose={() => { setModal(null); setSelected(null) }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<Input label="Display name" value={editDisplay} onChange={setEditDisplay} />
<Input label="Quota (GB)" value={editQuotaGB} onChange={setEditQuotaGB} />
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={editIsAdmin} onChange={e => setEditIsAdmin(e.target.checked)} />
Administrator
</label>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 4 }}>
<Btn onClick={() => { setModal(null); setSelected(null) }}>Cancel</Btn>
<Btn variant="primary" onClick={handleEdit}>Save</Btn>
</div>
</div>
</Modal>
)}
{/* Reset password modal */}
{modal === 'reset' && selected && (
<Modal title={`Reset password – ${selected.username}`} onClose={() => { setModal(null); setSelected(null); setResetResult('') }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{!resetResult ? (
<>
<p style={{ margin: 0, fontSize: 13, color: 'var(--color-text-muted)' }}>
Generate a random password and pass it on to the user.
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Btn onClick={() => { setModal(null); setSelected(null) }}>Cancel</Btn>
<Btn variant="primary" onClick={handleReset}>Generate password</Btn>
</div>
</>
) : (
<>
<p style={{ margin: 0, fontSize: 13, color: 'var(--color-text-muted)' }}>
New password (shown only once):
</p>
<div style={{
padding: '10px 14px', borderRadius: 8,
background: 'var(--color-surface-2)', border: '1px solid var(--color-border)',
fontFamily: 'monospace', fontSize: 14, color: 'var(--color-text)',
userSelect: 'all',
}}>
{resetResult}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Btn variant="primary" onClick={() => { setModal(null); setSelected(null); setResetResult('') }}>Done</Btn>
</div>
</>
)}
</div>
</Modal>
)}
{confirmUser && (
<ConfirmDialog
title="Delete user"
message={`Delete "${confirmUser.username}"? All of their files will be deleted as well. This cannot be undone.`}
confirmLabel="Delete user"
danger
onConfirm={() => handleDelete(confirmUser)}
onCancel={() => setConfirmUser(null)}
/>
)}
</div>
)
}
+119
View File
@@ -0,0 +1,119 @@
import type React from 'react'
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss'
/**
* Bausteine, die alle Reiter des Admin-Bereichs teilen: Karte, Knopf, Feld,
* Dialog – dazu die Umrechnung zwischen Bytes und Gigabyte.
*
* Lagen bis zum 23.08.2026 zusammen mit allen vier Reitern in einer Datei mit
* 1300 Zeilen.
*/
export function formatBytes(n: number): string {
if (n < 1024) return `${n} B`
if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`
if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`
return `${(n / 1024 ** 3).toFixed(1)} GB`
}
export function bytesFromGB(gb: number): number {
return Math.round(gb * 1024 ** 3)
}
export function gbFromBytes(bytes: number): number {
return Math.round((bytes / 1024 ** 3) * 10) / 10
}
// ── Shared UI ─────────────────────────────────────────────────────────────────
export function Card({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) {
return (
<div style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 12, padding: 20,
...style,
}}>
{children}
</div>
)
}
export function Btn({
children, onClick, variant = 'secondary', size = 'md', disabled,
}: {
children: React.ReactNode
onClick?: () => void
variant?: 'primary' | 'secondary' | 'danger'
size?: 'sm' | 'md'
disabled?: boolean
}) {
const colors = {
primary: { bg: 'var(--color-primary)', color: '#fff', border: 'none' },
secondary: { bg: 'transparent', color: 'var(--color-text-muted)', border: '1px solid var(--color-border)' },
danger: { bg: 'transparent', color: 'var(--color-danger)', border: '1px solid var(--color-danger)' },
}[variant]
return (
<button
onClick={onClick}
disabled={disabled}
style={{
padding: size === 'sm' ? '4px 12px' : '7px 16px',
fontSize: size === 'sm' ? 12 : 13,
fontWeight: 500,
borderRadius: 8,
cursor: disabled ? 'default' : 'pointer',
opacity: disabled ? 0.5 : 1,
...colors,
}}
>
{children}
</button>
)
}
export function Input({ label, value, onChange, type = 'text', placeholder }: {
label: string; value: string; onChange: (v: string) => void; type?: string; placeholder?: string
}) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)' }}>{label}</label>
<input
type={type}
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
style={{
padding: '8px 10px', borderRadius: 8, border: '1px solid var(--color-border)',
background: 'var(--color-surface-2)', color: 'var(--color-text)', fontSize: 13,
outline: 'none',
}}
/>
</div>
)
}
// ── Modal ─────────────────────────────────────────────────────────────────────
export function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
const overlay = useOverlayDismiss(onClose)
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 400, background: 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
}} {...overlay}>
<div style={{
background: 'var(--color-surface)', border: '1px solid var(--color-border)',
borderRadius: 14, width: '100%', maxWidth: 440,
boxShadow: '0 12px 40px rgba(0,0,0,0.25)',
}}>
<div style={{ padding: '18px 22px 14px', borderBottom: '1px solid var(--color-border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--color-text)' }}>{title}</h3>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', fontSize: 18, lineHeight: 1 }}>×</button>
</div>
<div style={{ padding: 22 }}>{children}</div>
</div>
</div>
)
}