Auf 360px (Galaxy Z Flip 7 aufgeklappt) brauchten zwei Spalten 2x160 + 12 = 332px, verfuegbar waren nach 20px Innenrand aber nur 320px - also fiel das Raster auf eine Spalte zurueck. Schmal jetzt 132px Mindestbreite, 10px Abstand, 12px Innenrand. Damit stehen zwei Spalten ab 300px Fensterbreite, ohne dass breite Schirme sich aendern (dort weiter 160px und 20px). Raster und Innenrand stehen als Klassen in index.css: inline gesetzt haette die Media-Query sie nicht erreicht - derselbe Fehler wie zuletzt in der Kopfleiste. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
430 lines
17 KiB
TypeScript
430 lines
17 KiB
TypeScript
import { useState, useEffect } from 'react'
|
||
import { authApi, ocrApi, type User, type OcrStatus } from '../api/client'
|
||
import { applyTheme } from '../theme'
|
||
import { useAppSettings } from '../hooks/useAppSettings'
|
||
import AppPasswords from '../components/AppPasswords'
|
||
import PageHeader from '../components/PageHeader'
|
||
import Tabs from '../components/Tabs'
|
||
|
||
interface Props {
|
||
user: User
|
||
/** Nach dem Speichern aufrufen, damit Seitenleiste und Kopfzeile den
|
||
* neuen Anzeigenamen übernehmen – sonst bliebe der alte bis zum Neuladen. */
|
||
onUserChanged?: () => Promise<void>
|
||
}
|
||
|
||
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`
|
||
}
|
||
|
||
function Card({ children, title }: { children: React.ReactNode; title: string }) {
|
||
return (
|
||
<div style={{
|
||
background: 'var(--color-surface)', border: '1px solid var(--color-border)',
|
||
borderRadius: 12, padding: 24, marginBottom: 20,
|
||
}}>
|
||
<h3 style={{ margin: '0 0 18px', fontSize: 15, fontWeight: 700, color: 'var(--color-text)' }}>{title}</h3>
|
||
{children}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Der Stand der Texterkennung – für die **eigenen** Dateien.
|
||
*
|
||
* Bis zum 23.08.2026 sah diese Zahl nur der Administrator. Das ist die falsche
|
||
* Person: Wissen muss es, wer gerade eine Rechnung hochgeladen hat und sie
|
||
* nicht findet. „Wird noch gelesen" und „ist nicht drin" sehen in einer leeren
|
||
* Trefferliste identisch aus.
|
||
*/
|
||
function IndexingCard() {
|
||
const [status, setStatus] = useState<OcrStatus | null>(null)
|
||
|
||
useEffect(() => {
|
||
let alive = true
|
||
let timer: ReturnType<typeof setTimeout>
|
||
async function tick() {
|
||
try {
|
||
const s = await ocrApi.myStatus()
|
||
if (!alive) return
|
||
setStatus(s)
|
||
// Nur nachfragen, solange sich etwas bewegt – steht die Warteschlange,
|
||
// ändert sich die Zahl erst beim nächsten Upload.
|
||
if ((s.pending ?? 0) + (s.running ?? 0) > 0) timer = setTimeout(tick, 5000)
|
||
} catch {
|
||
if (alive) setStatus(null)
|
||
}
|
||
}
|
||
tick()
|
||
return () => { alive = false; clearTimeout(timer) }
|
||
}, [])
|
||
|
||
if (!status) return null
|
||
|
||
const pending = (status.pending ?? 0) + (status.running ?? 0)
|
||
const indexed = status.indexed ?? 0
|
||
const bad = status.failed ?? 0
|
||
|
||
let text: string
|
||
if (!status.enabled) {
|
||
text = 'Turned off on this server — the contents of your documents are not searchable, only their names.'
|
||
} else if (pending > 0) {
|
||
text = `${indexed} read so far, ${pending} still to go. A scanned page takes about a minute — search will find them once they are done.`
|
||
} else if (indexed === 0) {
|
||
text = 'Nothing read yet. PDFs and images are read automatically after upload, so their contents become searchable.'
|
||
} else {
|
||
text = `${indexed} ${indexed === 1 ? 'document is' : 'documents are'} searchable by their contents — not just by file name.`
|
||
}
|
||
if (bad > 0) text += ` ${bad} could not be read (damaged or password-protected).`
|
||
|
||
return (
|
||
<Card title="Document search">
|
||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
|
||
<span style={{
|
||
width: 8, height: 8, borderRadius: '50%', flexShrink: 0, marginTop: 5,
|
||
background: !status.enabled
|
||
? 'var(--color-text-muted)'
|
||
: pending > 0 ? 'var(--color-warning)' : 'var(--color-success)',
|
||
}} />
|
||
<p style={{ margin: 0, fontSize: 13, color: 'var(--color-text-muted)', lineHeight: 1.55 }}>
|
||
{text}
|
||
</p>
|
||
</div>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 14 }}>
|
||
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)' }}>{label}</label>
|
||
{children}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Input({ value, onChange, type = 'text', placeholder, disabled }: {
|
||
value: string; onChange?: (v: string) => void; type?: string; placeholder?: string; disabled?: boolean
|
||
}) {
|
||
return (
|
||
<input
|
||
type={type}
|
||
value={value}
|
||
onChange={e => onChange?.(e.target.value)}
|
||
placeholder={placeholder}
|
||
disabled={disabled}
|
||
style={{
|
||
padding: '8px 10px', borderRadius: 8, border: '1px solid var(--color-border)',
|
||
background: disabled ? 'var(--color-surface-2)' : 'var(--color-surface-2)',
|
||
color: disabled ? 'var(--color-text-muted)' : 'var(--color-text)',
|
||
fontSize: 13, outline: 'none', opacity: disabled ? 0.7 : 1,
|
||
}}
|
||
/>
|
||
)
|
||
}
|
||
|
||
function SaveBtn({ onClick, loading, saved, disabled }: { onClick: () => void; loading: boolean; saved: boolean; disabled?: boolean }) {
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 4 }}>
|
||
<button
|
||
onClick={onClick}
|
||
disabled={loading || disabled}
|
||
style={{
|
||
padding: '8px 20px', borderRadius: 8, border: 'none',
|
||
background: 'var(--color-primary)', color: '#fff',
|
||
fontSize: 13, fontWeight: 600, cursor: loading || disabled ? 'default' : 'pointer',
|
||
opacity: loading || disabled ? 0.7 : 1,
|
||
}}
|
||
>
|
||
{loading ? 'Saving…' : 'Save'}
|
||
</button>
|
||
{saved && <span style={{ fontSize: 12, color: '#22c55e', fontWeight: 600 }}>✓ Saved</span>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
type Tab = 'account' | 'access' | 'appearance' | 'storage'
|
||
|
||
/**
|
||
* Reiter statt vier Abschnitte untereinander – dieselbe Aufteilung wie im
|
||
* Admin-Bereich, über dieselbe Komponente.
|
||
*
|
||
* Sortiert nach dem, was man tatsächlich aufsucht: erst das eigene Konto, dann
|
||
* der Zugang für Geräte, dann das Aussehen. Der Speicher steht zuletzt; er ist
|
||
* eine Anzeige, kein Bedienelement, und derselbe Balken steht ohnehin unten in
|
||
* der Seitenleiste. Vorher stand er ganz oben und war das Erste, was man sah.
|
||
*/
|
||
export default function ProfilePage({ user, onUserChanged }: Props) {
|
||
const [tab, setTab] = useState<Tab>('account')
|
||
const tabs: { id: Tab; label: string }[] = [
|
||
{ id: 'account', label: 'Account' },
|
||
{ id: 'access', label: 'Access' },
|
||
{ id: 'appearance', label: 'Appearance' },
|
||
{ id: 'storage', label: 'Storage' },
|
||
]
|
||
// Profile
|
||
const [displayName, setDisplayName] = useState(user.display_name ?? '')
|
||
const [profileLoading, setProfileLoading] = useState(false)
|
||
const [profileSaved, setProfileSaved] = useState(false)
|
||
|
||
// Password
|
||
const [currentPw, setCurrentPw] = useState('')
|
||
const [newPw, setNewPw] = useState('')
|
||
const [confirmPw, setConfirmPw] = useState('')
|
||
const [pwLoading, setPwLoading] = useState(false)
|
||
const [pwSaved, setPwSaved] = useState(false)
|
||
const [pwError, setPwError] = useState('')
|
||
|
||
// Persönliche Palette. Leer heisst: die globale Einstellung des
|
||
// Administrators gilt – der Farbwähler zeigt dann deren Wert als Ausgangspunkt.
|
||
const { globalColors, refresh: refreshSettings } = useAppSettings()
|
||
const [primary, setPrimary] = useState(user.primary_color || globalColors.primary || '#6366f1')
|
||
const [accent, setAccent] = useState(user.accent_color || globalColors.accent || '#8b5cf6')
|
||
const [useCustom, setUseCustom] = useState(user.use_custom_theme)
|
||
const [themeLoading, setThemeLoading] = useState(false)
|
||
const [themeSaved, setThemeSaved] = useState(false)
|
||
|
||
// Vorschau: die Wahl wirkt sofort auf die ganze Oberfläche. Wird der Bereich
|
||
// ohne Speichern verlassen, stellt der Abgleich den gesicherten Stand her.
|
||
function preview(p: string, a: string) {
|
||
if (useCustom) applyTheme(p, a)
|
||
}
|
||
|
||
// Umschalten wirkt sofort und speichert – die Farben bleiben dabei erhalten,
|
||
// man kann also beliebig hin und her wechseln.
|
||
async function toggleCustom(enabled: boolean) {
|
||
setUseCustom(enabled)
|
||
applyTheme(
|
||
enabled ? primary : globalColors.primary,
|
||
enabled ? accent : globalColors.accent,
|
||
)
|
||
await authApi.setUseCustomTheme(enabled).catch(() => {})
|
||
await onUserChanged?.()
|
||
}
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
void refreshSettings()
|
||
applyTheme(
|
||
user.use_custom_theme ? (user.primary_color || globalColors.primary) : globalColors.primary,
|
||
user.use_custom_theme ? (user.accent_color || globalColors.accent) : globalColors.accent,
|
||
)
|
||
}
|
||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
async function saveProfile() {
|
||
setProfileLoading(true)
|
||
await authApi.updateProfile(displayName).catch(() => {})
|
||
await onUserChanged?.()
|
||
setProfileLoading(false)
|
||
setProfileSaved(true)
|
||
setTimeout(() => setProfileSaved(false), 2000)
|
||
}
|
||
|
||
async function changePassword() {
|
||
setPwError('')
|
||
if (newPw !== confirmPw) { setPwError('Passwords do not match'); return }
|
||
if (newPw.length < 8) { setPwError('At least 8 characters'); return }
|
||
setPwLoading(true)
|
||
try {
|
||
await authApi.changePassword(currentPw, newPw)
|
||
setCurrentPw(''); setNewPw(''); setConfirmPw('')
|
||
setPwSaved(true)
|
||
setTimeout(() => setPwSaved(false), 2000)
|
||
} catch {
|
||
setPwError('Current password is incorrect')
|
||
} finally {
|
||
setPwLoading(false)
|
||
}
|
||
}
|
||
|
||
async function saveTheme() {
|
||
setThemeLoading(true)
|
||
const result = await authApi.updateColors(primary, accent).catch(() => null)
|
||
// Wer Farben speichert, will sie auch sehen.
|
||
if (result && !useCustom) {
|
||
setUseCustom(true)
|
||
await authApi.setUseCustomTheme(true).catch(() => {})
|
||
applyTheme(primary, accent)
|
||
}
|
||
setThemeLoading(false)
|
||
if (result) {
|
||
// Nutzer neu laden, damit die Palette in der gesamten Anwendung greift.
|
||
await onUserChanged?.()
|
||
setThemeSaved(true)
|
||
setTimeout(() => setThemeSaved(false), 2000)
|
||
}
|
||
}
|
||
|
||
const usedPct = user.quota_bytes > 0 ? Math.min((user.used_bytes / user.quota_bytes) * 100, 100) : 0
|
||
|
||
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" strokeLinecap="round" strokeLinejoin="round">
|
||
<circle cx="12" cy="8" r="4" />
|
||
<path d="M4 21v-1a8 8 0 0116 0v1" />
|
||
</svg>
|
||
}
|
||
title="Settings"
|
||
meta="Your account, devices and appearance"
|
||
/>
|
||
|
||
<div className="aether-content" style={{ flex: 1, overflowY: 'auto' }}>
|
||
<div style={{ maxWidth: 560 }}>
|
||
|
||
<Tabs tabs={tabs} active={tab} onChange={setTab} />
|
||
|
||
{tab === 'account' && (<>
|
||
<Card title="Profile">
|
||
<Field label="Username">
|
||
<Input value={user.username} disabled />
|
||
</Field>
|
||
<Field label="Display name">
|
||
<Input value={displayName} onChange={setDisplayName} placeholder="Your name" />
|
||
</Field>
|
||
<SaveBtn onClick={saveProfile} loading={profileLoading} saved={profileSaved} />
|
||
</Card>
|
||
|
||
{/* Passwortänderung nur für Konten, die hier auch ein Passwort haben.
|
||
Wer sich über den Identitätsanbieter anmeldet, hat keines – das
|
||
Formular könnte nur fehlschlagen, und zwar mit „current password
|
||
incorrect", was nach einem Tippfehler aussieht statt nach dem
|
||
wahren Grund. Der Server weist den Aufruf zusätzlich ab; das hier
|
||
ist die Erklärung, nicht die Absicherung. */}
|
||
{user.is_oidc ? (
|
||
<Card title="Password">
|
||
<p style={{ margin: 0, fontSize: 13, color: 'var(--color-text-muted)', lineHeight: 1.55 }}>
|
||
You sign in through your identity provider, so this account has no
|
||
password here. Change it where you sign in.
|
||
</p>
|
||
</Card>
|
||
) : (
|
||
<Card title="Change password">
|
||
<Field label="Current password">
|
||
<Input type="password" value={currentPw} onChange={setCurrentPw} />
|
||
</Field>
|
||
<Field label="New password">
|
||
<Input type="password" value={newPw} onChange={setNewPw} placeholder="At least 8 characters" />
|
||
</Field>
|
||
<Field label="Confirm new password">
|
||
<Input type="password" value={confirmPw} onChange={setConfirmPw} />
|
||
</Field>
|
||
{pwError && <p style={{ margin: '0 0 10px', fontSize: 12, color: 'var(--color-danger)' }}>{pwError}</p>}
|
||
<SaveBtn onClick={changePassword} loading={pwLoading} saved={pwSaved} disabled={!currentPw || !newPw || !confirmPw} />
|
||
</Card>
|
||
)}
|
||
</>)}
|
||
|
||
{tab === 'access' && (<>
|
||
{/* App-Passwörter sind der einzige Weg, ein Gerät anzubinden – das
|
||
Kontopasswort wird an /dav/ abgelehnt. Der Hinweis gehört deshalb
|
||
hierher und nicht in die Dokumentation. */}
|
||
<Card title="App passwords">
|
||
<p style={{ margin: '0 0 16px', fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
||
For WebDAV and sync clients. Your account password is rejected there
|
||
on purpose — it is also the admin login. Each app password can be
|
||
revoked on its own without locking out your other devices.
|
||
</p>
|
||
<AppPasswords username={user.username} />
|
||
</Card>
|
||
</>)}
|
||
|
||
{tab === 'appearance' && (<>
|
||
<Card title="Colours">
|
||
{/* Schalter zuerst: er bestimmt, ob die Farbwahl darunter überhaupt
|
||
greift. Die Werte bleiben in beiden Stellungen gespeichert. */}
|
||
<div style={{
|
||
display: 'flex', alignItems: 'center', gap: 12,
|
||
padding: '10px 12px', marginBottom: 16, borderRadius: 10,
|
||
background: 'var(--color-surface-2)',
|
||
}}>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
|
||
Use my own colours
|
||
</div>
|
||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2 }}>
|
||
{useCustom
|
||
? 'Your palette is active. Turn off to use the server theme.'
|
||
: 'Using the server theme. Your colours stay saved.'}
|
||
</div>
|
||
</div>
|
||
<button
|
||
role="switch"
|
||
aria-checked={useCustom}
|
||
onClick={() => toggleCustom(!useCustom)}
|
||
style={{
|
||
width: 42, height: 24, borderRadius: 999, flexShrink: 0,
|
||
border: 'none', cursor: 'pointer', padding: 3,
|
||
background: useCustom ? 'var(--color-primary)' : 'var(--color-border)',
|
||
display: 'flex', justifyContent: useCustom ? 'flex-end' : 'flex-start',
|
||
transition: 'background 0.15s',
|
||
}}
|
||
>
|
||
<span style={{
|
||
width: 18, height: 18, borderRadius: '50%', background: '#fff',
|
||
display: 'block', transition: 'transform 0.15s',
|
||
}} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Ausgegraut statt versteckt: man sieht, welche Farben hinterlegt
|
||
sind, auch während das Server-Thema aktiv ist. */}
|
||
<div style={{ opacity: useCustom ? 1 : 0.45, pointerEvents: useCustom ? 'auto' : 'none' }}>
|
||
<Field label="Primary colour">
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
<input
|
||
type="color"
|
||
value={primary}
|
||
onChange={e => { setPrimary(e.target.value); preview(e.target.value, accent) }}
|
||
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)' }}>{primary}</span>
|
||
</div>
|
||
</Field>
|
||
<Field label="Accent colour">
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
<input
|
||
type="color"
|
||
value={accent}
|
||
onChange={e => { setAccent(e.target.value); preview(primary, e.target.value) }}
|
||
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)' }}>{accent}</span>
|
||
</div>
|
||
</Field>
|
||
<SaveBtn onClick={saveTheme} loading={themeLoading} saved={themeSaved} />
|
||
</div>
|
||
</Card>
|
||
</>)}
|
||
|
||
{tab === 'storage' && (<>
|
||
{/* Reine Anzeige – ändern lässt sich das Kontingent nur im
|
||
Admin-Bereich. Der Kartentitel wiederholt die Abschnittsüberschrift
|
||
deshalb nicht, sondern sagt, was in der Karte steht. */}
|
||
<Card title="Space used">
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
|
||
<span style={{ fontSize: 13, color: 'var(--color-text)' }}>{formatBytes(user.used_bytes)} used</span>
|
||
<span style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>{formatBytes(user.quota_bytes)} total</span>
|
||
</div>
|
||
<div style={{ height: 8, background: 'var(--color-surface-2)', borderRadius: 999 }}>
|
||
<div style={{
|
||
width: `${usedPct}%`, height: '100%', borderRadius: 999,
|
||
background: usedPct > 90 ? 'var(--color-danger)' : 'var(--color-primary)',
|
||
transition: 'width 0.3s',
|
||
}} />
|
||
</div>
|
||
</Card>
|
||
<IndexingCard />
|
||
</>)}
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|