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

+22
View File
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<!-- viewport-fit=cover: Als installierte App soll der Inhalt den ganzen
Schirm nutzen und nicht neben der Kamera-Aussparung eingerahmt stehen.
Der Preis ist, dass er dann *unter* Statusleiste und Wischbalken laufen
kann – dagegen die safe-area-Abstände in index.css. -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<!-- Das Plugin fuegt keinen Link mehr ein (manifest: false) – der Server
erzeugt das Manifest aus den Einstellungen. -->
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#6366f1" />
<title>Aether</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+6522
View File
File diff suppressed because it is too large. Load diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "aether-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@vitejs/plugin-react": "^6.0.2",
"pdfjs-dist": "^6.2.108",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"vite": "^8.0.16",
"vite-plugin-pwa": "^1.3.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"tailwindcss": "^4.3.1",
"typescript": "^6.0.3",
"vitest": "^4.1.9"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Aether">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#6366f1"/>
<stop offset="1" stop-color="#4f46e5"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="114" fill="url(#bg)"/>
<!-- Wolke: zwei Kuppen auf einer Grundfläche, bewusst flächig statt Kontur,
damit das Motiv auch bei 48 px noch als Wolke lesbar bleibt. -->
<path fill="#ffffff" d="M170 356q-46 0-78-32t-32-78q0-42 27-73t68-37q19-36 54-58t77-22q54 0 93 36t44 89q35 4 58 30t23 61q0 38-27 66t-66 28z"/>
</svg>

After

Width:  |  Height:  |  Size: 649 B

+63
View File
@@ -0,0 +1,63 @@
import { useEffect } from 'react'
import { useAuth } from './hooks/useAuth'
import { AppSettingsProvider, useAppSettings } from './hooks/useAppSettings'
import { applyTheme } from './theme'
import LoginPage from './pages/LoginPage'
import Shell from './components/Shell'
import SharePage from './pages/SharePage'
// Simple path-based routing without react-router
function getShareToken(): string | null {
const match = window.location.pathname.match(/^\/s\/([^/]+)/)
return match ? match[1] : null
}
export default function App() {
const shareToken = getShareToken()
// Name und Farben umschliessen auch die Share-Seite: sie ist die einzige
// Ansicht, die Fremde zu Gesicht bekommen.
return (
<AppSettingsProvider>
{shareToken ? <SharePage token={shareToken} /> : <AuthedApp />}
</AppSettingsProvider>
)
}
function AuthedApp() {
const { user, loading, login, logout, refreshUser } = useAuth()
const { globalColors } = useAppSettings()
// Die persönliche Palette gilt nur, wenn der Nutzer sie eingeschaltet hat –
// sonst die des Administrators. Läuft bei jeder Änderung am Nutzer erneut,
// damit ein Umschalten im Profil sofort überall greift.
useEffect(() => {
if (!user) return
if (user.use_custom_theme) {
applyTheme(
user.primary_color || globalColors.primary,
user.accent_color || globalColors.accent,
)
} else {
applyTheme(globalColors.primary, globalColors.accent)
}
}, [user, globalColors])
if (loading) {
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{
width: 32, height: 32,
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>
)
}
if (!user) return <LoginPage onLogin={login} />
return <Shell user={user} onLogout={logout} onUserChanged={refreshUser} />
}
+813
View File
@@ -0,0 +1,813 @@
// Typed API client. All fetch calls go through here — never use fetch directly in components.
export interface User {
id: string
username: string
display_name: string
is_admin: boolean
quota_bytes: number
used_bytes: number
/** Persönliche Palette – bleibt erhalten, auch wenn sie gerade nicht
* angewendet wird (siehe use_custom_theme). */
primary_color: string
accent_color: string
use_custom_theme: boolean
/** Konto meldet sich über den Identitätsanbieter an und hat hier kein
* Passwort – die Passwortänderung wird dann ausgeblendet. */
is_oidc?: boolean
created_at: string
}
export interface FileItem {
id: string
/** Datum auf dem Beleg (YYYY-MM-DD) – nicht das des Uploads. */
document_date?: string
owner_id: string | null
group_id: string | null
folder_id: string | null
name: string
mime_type: string
size: number
checksum: string
has_thumbnail: boolean
version: number
versioning_enabled: boolean
created_at: string
updated_at: string
}
export interface FolderItem {
id: string
/** Genau eines von beiden ist gesetzt: persönlicher Ordner oder Gruppenordner. */
owner_id: string | null
group_id: string | null
parent_id: string | null
name: string
created_at: string
}
export interface ListResponse {
files: FileItem[]
folders: FolderItem[]
/** Etiketten je Datei-ID. Kommen mit der Liste in einer Abfrage mit – bei
* fuenfzig Kacheln waeren fuenfzig Einzelabfragen der falsche Weg. */
tags?: Record<string, Tag[]>
/** Absender je Datei-ID – auf demselben Weg wie die Etiketten. */
correspondents?: Record<string, Correspondent>
}
export interface AuthResponse {
user: User
csrf_token: string
}
export interface ShareLink {
id: string
file_id: string
owner_id: string
token: string
has_password: boolean
expires_at: string | null
download_count: number
created_at: string
}
export interface CreateShareRequest {
password?: string
expires_in_hours?: number
}
export interface PublicShareInfo {
link: ShareLink
file_name: string
file_size: number
mime_type: string
}
export interface FileVersion {
id: string
file_id: string
version: number
size: number
checksum: string
created_at: string
}
export interface UploadProgress {
loaded: number
total: number
percent: number
}
class APIError extends Error {
constructor(public status: number, message: string) {
super(message)
this.name = 'APIError'
}
}
let csrfToken = ''
export function setCSRFToken(token: string) {
csrfToken = token
}
async function request<T>(
method: string,
path: string,
body?: unknown,
isFormData = false,
/** Abbruchsignal – die Suche verwirft damit überholte Anfragen. */
signal?: AbortSignal,
): Promise<T> {
const headers: Record<string, string> = {}
if (csrfToken) headers['X-CSRF-Token'] = csrfToken
if (body && !isFormData) headers['Content-Type'] = 'application/json'
const res = await fetch(`/api/v1${path}`, {
method,
headers,
credentials: 'include',
signal,
body: isFormData ? (body as FormData) : body ? JSON.stringify(body) : undefined,
})
if (res.status === 204) return undefined as T
const json = await res.json().catch(() => ({ error: res.statusText }))
if (!res.ok) throw new APIError(res.status, json.error ?? 'Unknown error')
return json.data ?? json as T
}
// XHR-based upload with real progress reporting.
export function uploadWithProgress(
file: File,
folderId: string | undefined,
onProgress: (p: UploadProgress) => void
): Promise<FileItem> {
return new Promise((resolve, reject) => {
const form = new FormData()
// folder_id MUST come before the file so the server can stream the file
// straight to storage without buffering the whole body first.
if (folderId) form.append('folder_id', folderId)
form.append('file', file)
const xhr = new XMLHttpRequest()
xhr.open('POST', '/api/v1/files')
xhr.withCredentials = true
if (csrfToken) xhr.setRequestHeader('X-CSRF-Token', csrfToken)
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
onProgress({ loaded: e.loaded, total: e.total, percent: Math.round((e.loaded / e.total) * 100) })
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 201) {
try {
const json = JSON.parse(xhr.responseText)
resolve(json.data ?? json)
} catch {
reject(new APIError(xhr.status, 'Invalid response'))
}
} else {
let msg = 'Upload failed'
try {
msg = JSON.parse(xhr.responseText).error ?? msg
} catch { /* kein JSON – Standardtext behalten */ }
// 413 kommt hier nur vom Quota-Check; der rohe Servertext sagt einem
// Nutzer vor dem Upload-Fenster wenig.
if (xhr.status === 413) msg = 'Storage full – your quota is not sufficient'
reject(new APIError(xhr.status, msg))
}
})
xhr.addEventListener('error', () => reject(new APIError(0, 'Network error')))
xhr.addEventListener('abort', () => reject(new APIError(0, 'Upload aborted')))
xhr.send(form)
})
}
// ── Auth ─────────────────────────────────────────────────────────────────────
export interface ExternalApp {
name: string
url: string
icon: string
}
export interface PublicConfig {
oidc_enabled: boolean
cloud_name: string
/** Leer, wenn kein Logo hinterlegt ist. Enthält einen Versionsanteil, der
* sich bei jedem Austausch ändert – damit Browser nicht das alte zeigen. */
logo_url?: string
/** Tab-Symbol (64 px) aus dem hochgeladenen App-Symbol. Leer, wenn keins
* hinterlegt ist. Ebenfalls mit Versionsanteil. */
favicon_url?: string
/** Dasselbe Symbol in 192 px – für den Startbildschirm von iOS. */
apple_touch_icon_url?: string
/** Gibt es einen Office-Server? Sonst bietet das Menue das Bearbeiten gar
* nicht erst an – ein Punkt, der immer scheitert, ist schlimmer als keiner. */
office_enabled?: boolean
primary_color?: string
accent_color?: string
external_apps: ExternalApp[]
}
export const publicApi = {
config: (): Promise<PublicConfig> =>
fetch('/api/v1/public/config').then(r => r.json()).then(j => j.data ?? j),
}
export const authApi = {
login: (username: string, password: string) =>
request<AuthResponse>('POST', '/auth/login', { username, password }),
logout: () => request<void>('POST', '/auth/logout'),
me: () => request<AuthResponse>('GET', '/auth/me'),
updateProfile: (displayName: string) =>
request<User>('PATCH', '/auth/profile', { display_name: displayName }),
updateColors: (primaryColor: string, accentColor: string) =>
request<User>('PATCH', '/auth/profile', {
primary_color: primaryColor,
accent_color: accentColor,
}),
setUseCustomTheme: (enabled: boolean) =>
request<User>('PATCH', '/auth/profile', { use_custom_theme: enabled }),
changePassword: (currentPassword: string, newPassword: string) =>
request<void>('POST', '/auth/change-password', { current_password: currentPassword, new_password: newPassword }),
}
// ── Internal shares (people and groups) ───────────────────────────────────────
export type SharePermission = 'read' | 'write'
export interface AclEntry {
id: string
subject_type: 'user' | 'group'
subject_id: string
subject_name: string
permission: SharePermission
created_at: string
}
export interface ShareRecipient {
id: string
name: string
type: 'user' | 'group'
}
export interface SharedResource {
type: 'folder' | 'file'
id: string
name: string
owner_id: string
owner_name: string
permission: SharePermission
size: number
mime_type: string
shared_at: string
/** Gesetzt, wenn der Zugriff über eine Gruppe kommt statt persönlich. */
via_group: string
}
export const aclApi = {
recipients: () => request<ShareRecipient[]>('GET', '/share-recipients'),
sharedWithMe: () => request<SharedResource[]>('GET', '/shared-with-me'),
listForFolder: (id: string) => request<AclEntry[]>('GET', `/folders/${id}/acl`),
listForFile: (id: string) => request<AclEntry[]>('GET', `/files/${id}/acl`),
grantFolder: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: SharePermission) =>
request<AclEntry>('POST', `/folders/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
grantFile: (id: string, subjectId: string, subjectType: 'user' | 'group', permission: SharePermission) =>
request<AclEntry>('POST', `/files/${id}/acl`, { subject_id: subjectId, subject_type: subjectType, permission }),
revoke: (entryId: string) => request<void>('DELETE', `/acl/${entryId}`),
}
// ── App passwords (WebDAV / sync clients) ─────────────────────────────────────
export interface AppPassword {
id: string
name: string
last_used_at: string | null
created_at: string
}
// The plaintext password is returned exactly once, on creation.
export interface AppPasswordCreated extends AppPassword {
password: string
}
export const appPasswordsApi = {
list: () => request<AppPassword[]>('GET', '/app-passwords'),
create: (name: string) =>
request<AppPasswordCreated>('POST', '/app-passwords', { name }),
revoke: (id: string) => request<void>('DELETE', `/app-passwords/${id}`),
}
// ── Files ─────────────────────────────────────────────────────────────────────
// ── Suche ────────────────────────────────────────────────────────────────────
export interface SearchHit {
kind: 'folder' | 'file'
id: string
name: string
/** Ordner, in dem der Treffer liegt – null heisst Wurzel. */
folder_id: string | null
folder_name: string
mime_type?: string
size: number
has_thumbnail: boolean
updated_at: string
/** true, wenn nicht der Name passte, sondern der erkannte Text im Dokument. */
matched_content?: boolean
/** Fundstelle im Text; Treffer in <b>…</b>. Nur bei matched_content. */
snippet?: string
}
export interface OcrStatus {
enabled: boolean
/** Fehlen alle, wenn enabled false ist. */
pending?: number
running?: number
done?: number
failed?: number
/** Dateien mit gespeichertem Text. */
indexed?: number
}
export interface SearchResponse {
hits: SearchHit[]
query: string
/** true, wenn die Liste bei der Obergrenze abgeschnitten wurde. */
truncated?: boolean
}
export interface Tag {
id: string
name: string
/** Leer heisst: die Oberflaeche leitet eine Farbe aus dem Namen ab. */
color: string
/** Emoji oder leer. Steht **neben** der Farbe: Die Farbe traegt die
* Wiedererkennung im Augenwinkel, das Symbol die Bedeutung. Ab etwa fuenf
* Etiketten reicht Farbe allein nicht mehr – dann fragt man sich, ob Blau
* nun „Uni" oder „Versicherung" war. */
icon: string
/** Gesetzt, wenn das Etikett einer Gruppe gehoert – dann sehen es alle
* Mitglieder, und es gilt nur fuer Dateien in deren Gruppenordner. */
group_id?: string
group_name?: string
file_count: number
created_at: string
}
/**
* Absender („Korrespondent") – von wem ein Dokument kommt.
*
* Der Unterschied zum Etikett ist die Anzahl: Etiketten sagen, *wofür* ein
* Dokument gebraucht wird, davon trägt eine Datei beliebig viele. Der Absender
* sagt, *von wem* es kommt – davon hat eine Datei genau einen. Deshalb ein
* Feld und keine Mehrfachauswahl, und deshalb eine eigene Liste: Sonst stünde
* „Stadtwerke" neben „absetzbar" in derselben Aufzählung.
*/
export interface Correspondent {
id: string
name: string
/** Emoji oder leer – dieselbe Regel wie beim Etikett. */
icon: string
group_id?: string
group_name?: string
file_count: number
created_at: string
}
/** Datei-Treffer beim Filtern nach einem Etikett. */
export interface TaggedFile {
id: string
name: string
folder_id: string | null
folder_name: string
mime_type: string
size: number
has_thumbnail: boolean
/** Datum auf dem Beleg – nicht das des Uploads. */
document_date?: string
updated_at: string
}
/**
* Meldet, dass sich an den Etiketten etwas geändert hat.
*
* Die Seitenleiste zeigt Zählstände, die sich ändern, sobald irgendwo
* etikettiert wird – und sie steht neben dem Dialog, in dem das passiert. Eine
* Zahl, die daneben stehenbleibt, während man sie gerade verändert hat, sieht
* nach einem Fehler aus.
*
* Ein Ereignis statt Zustand durch drei Ebenen zu reichen: Der Dialog steckt
* tief im Dateibrowser, die Seitenleiste hängt am Shell – ein gemeinsamer
* Zustand nur für einen Zählstand wäre viel Verkabelung für wenig.
*/
export const tagsChangedEvent = 'aether:tags-changed'
export function notifyTagsChanged() {
window.dispatchEvent(new Event(tagsChangedEvent))
}
/**
* Startet einen Archiv-Download.
*
* Über ein abgeschicktes Formular, nicht über fetch(): Nur eine **Navigation**
* behandelt der Browser als Download. Ein fetch() müsste die Antwort erst
* vollständig in den Speicher holen – bei einem 5-GB-Ordner also 5 GB im
* Arbeitsspeicher, und der Nutzer sähe bis dahin nichts.
*
* POST statt GET, weil eine Auswahl beliebig lang sein kann: Fünfzig
* Kennungen sind schon knapp 1900 Zeichen, und Adressen haben eine Grenze.
*/
export function downloadZip(opts: { fileIds?: string[]; folderId?: string; name?: string }) {
const form = document.createElement('form')
form.method = 'POST'
form.action = '/api/v1/download/zip'
// Das CSRF-Token als Feld – ein Formular kann keine Header setzen. Der
// Server vergleicht es weiterhin gegen das Cookie (Double-Submit).
const add = (name: string, value: string) => {
const input = document.createElement('input')
input.type = 'hidden'
input.name = name
input.value = value
form.appendChild(input)
}
add('csrf_token', csrfToken)
if (opts.folderId) add('folder_id', opts.folderId)
if (opts.name) add('name', opts.name)
for (const id of opts.fileIds ?? []) add('file_ids', id)
document.body.appendChild(form)
form.submit()
form.remove()
}
export const tagsApi = {
list: () => request<Tag[]>('GET', '/tags'),
create: (name: string, color = '', groupId?: string, icon = '') =>
request<Tag>('POST', '/tags', { name, color, icon, group_id: groupId ?? '' }),
update: (id: string, name: string, color: string, icon = '') =>
request<Tag>('PATCH', `/tags/${id}`, { name, color, icon }),
remove: (id: string) => request<void>('DELETE', `/tags/${id}`),
files: (id: string) => request<TaggedFile[]>('GET', `/tags/${id}/files`),
/**
* Setzt Etiketten, Belegdatum und Absender einer Datei in **einem** Vorgang.
*
* Alles drei steht im selben Dialog, also geht es auch zusammen zum Server –
* sonst gäbe es einen Zustand, in dem die Etiketten sitzen und der Absender
* nicht.
*
* Weglassen heisst jeweils „unveraendert", leerer String heisst „loeschen".
* `correspondentName` legt den Absender bei Bedarf an; das ist der Normalfall
* beim Tippen.
*/
setForFile: (fileId: string, tagIds: string[], documentDate?: string,
correspondent?: { id?: string; name?: string }) =>
request<Tag[]>('PUT', `/files/${fileId}/tags`, {
tag_ids: tagIds,
...(documentDate === undefined ? {} : { document_date: documentDate }),
...(correspondent?.name ? { correspondent_name: correspondent.name }
: correspondent?.id === undefined ? {} : { correspondent_id: correspondent.id }),
}),
}
export const correspondentsApi = {
list: () => request<Correspondent[]>('GET', '/correspondents'),
create: (name: string, groupId?: string, icon = '') =>
request<Correspondent>('POST', '/correspondents', { name, icon, group_id: groupId ?? '' }),
update: (id: string, name: string, icon = '') =>
request<Correspondent>('PATCH', `/correspondents/${id}`, { name, icon }),
remove: (id: string) => request<void>('DELETE', `/correspondents/${id}`),
files: (id: string) => request<TaggedFile[]>('GET', `/correspondents/${id}/files`),
}
/** Was der Server über eine Office-Sitzung mitteilt. */
export interface OfficeSession {
enabled: boolean
provider?: string
/** Adresse der Bedienoberflaeche beim Office-Server, samt WOPISrc. */
url: string
/** Kurzlebiges Zugriffstoken – gilt fuer **genau diese** Datei. */
token: string
token_ttl: number
can_write: boolean
file_name: string
}
/**
* Endungen, die ein Office-Server bearbeiten kann.
*
* Dieselbe Positivliste wie im Backend (internal/office). Hier steht sie nur,
* damit das Kontextmenue den Punkt gar nicht erst anbietet, wo er nicht ginge –
* entschieden wird serverseitig.
*/
const officeExtensions = new Set([
'odt', 'docx', 'doc', 'rtf', 'txt',
'ods', 'xlsx', 'xls', 'csv',
'odp', 'pptx', 'ppt',
'odg',
])
export function isOfficeFile(name: string): boolean {
const i = name.lastIndexOf('.')
return i >= 0 && officeExtensions.has(name.slice(i + 1).toLowerCase())
}
export const officeApi = {
session: (fileId: string) =>
request<OfficeSession>('GET', `/office/session/${fileId}`),
}
export const ocrApi = {
/** Stand der Texterkennung für die **eigenen** Dateien. Braucht keine
* Adminrechte – anders als adminApi.ocrStatus, das den ganzen Server zählt. */
myStatus: () => request<OcrStatus>('GET', '/ocr'),
}
export const searchApi = {
query: (q: string, signal?: AbortSignal) =>
request<SearchResponse>('GET', `/search?q=${encodeURIComponent(q)}`, undefined, false, signal),
}
export const filesApi = {
list: (folderId?: string) => {
const qs = folderId ? `?folder_id=${folderId}` : ''
return request<ListResponse>('GET', `/files${qs}`)
},
upload: (file: File, folderId?: string) => {
const form = new FormData()
// folder_id before file – see uploadWithProgress note (streaming upload).
if (folderId) form.append('folder_id', folderId)
form.append('file', file)
return request<FileItem>('POST', '/files', form, true)
},
download: (id: string) => `/api/v1/files/${id}/download`,
/** Dieselbe Datei, aber zum Anzeigen im Browser statt zum Speichern. Der
* Server liefert das nur für eine feste Liste harmloser Typen inline aus. */
inline: (id: string) => `/api/v1/files/${id}/download?inline=1`,
thumbnail: (id: string) => `/api/v1/files/${id}/thumbnail`,
/**
* Der erkannte Text einer Datei, in Seiten zerlegt.
*
* Für die Suche im PDF-Betrachter: Bei einem Scan steht im PDF selbst kein
* Wort, nur ein Bild – ohne diesen Weg fände die Suche im Dokument nichts.
* `source` sagt, woher der Text stammt ('layer' aus dem PDF, 'ocr' erkannt).
*/
textPages: (id: string) =>
request<{ pages: string[]; source: string }>('GET', `/files/${id}/text`),
delete: (id: string) => request<void>('DELETE', `/files/${id}`),
rename: (id: string, name: string) =>
request<FileItem>('PATCH', `/files/${id}/rename`, { name }),
move: (id: string, folderId: string | null) =>
request<FileItem>('PATCH', `/files/${id}/move`, { folder_id: folderId }),
}
// ── Folders ───────────────────────────────────────────────────────────────────
export const foldersApi = {
list: async (parentId?: string): Promise<FolderItem[]> => {
const data = await filesApi.list(parentId)
return data.folders ?? []
},
create: (name: string, parentId?: string) =>
request<FolderItem>('POST', '/folders', { name, parent_id: parentId }),
rename: (id: string, name: string) =>
request<FolderItem>('PATCH', `/folders/${id}/rename`, { name }),
move: (id: string, parentId: string | null) =>
request<FolderItem>('PATCH', `/folders/${id}/move`, { parent_id: parentId }),
delete: (id: string) =>
request<void>('DELETE', `/folders/${id}`),
}
// ── Favorites ─────────────────────────────────────────────────────────────────
export interface FavoriteItem {
id: string
folder_id: string
folder_name: string
folder_parent_id: string | null
position: number
is_expanded: boolean
}
export const favoritesApi = {
list: () => request<FavoriteItem[]>('GET', '/favorites'),
add: (folderId: string) => request<FavoriteItem>('POST', '/favorites', { folder_id: folderId }),
remove: (folderId: string) => request<void>('DELETE', `/favorites/${folderId}`),
setExpanded: (folderId: string, expanded: boolean) =>
request<void>('PATCH', `/favorites/${folderId}/expand`, { expanded }),
reorder: (folderIds: string[]) =>
request<void>('PATCH', '/favorites/reorder', { folder_ids: folderIds }),
}
// ── Trash ─────────────────────────────────────────────────────────────────────
export interface TrashItem extends FileItem {
folder_path: string | null
}
export const trashApi = {
list: () => request<TrashItem[]>('GET', '/trash'),
restore: (id: string) => request<FileItem>('POST', `/trash/${id}/restore`),
deletePermanently: (id: string) => request<void>('DELETE', `/trash/${id}`),
emptyAll: () => request<void>('DELETE', '/trash'),
}
// ── Shares ────────────────────────────────────────────────────────────────────
export const sharesApi = {
create: (fileId: string, opts: CreateShareRequest) =>
request<ShareLink>('POST', `/files/${fileId}/share`, opts),
list: () => request<ShareLink[]>('GET', '/shares'),
delete: (id: string) => request<void>('DELETE', `/shares/${id}`),
// Public endpoints (no auth) – /share/ prefix avoids chi /api/v1 middleware conflict
getInfo: (token: string) =>
fetch(`/share/${token}`)
.then(r => r.json())
.then(j => (j.data ?? j) as PublicShareInfo),
download: (token: string, password?: string) =>
fetch(`/share/${token}/download`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
}),
}
// ── Versions ──────────────────────────────────────────────────────────────────
export const versionsApi = {
list: (fileId: string) =>
request<FileVersion[]>('GET', `/files/${fileId}/versions`),
download: (fileId: string, versionId: string) =>
`/api/v1/files/${fileId}/versions/${versionId}/download`,
restore: (fileId: string, versionId: string) =>
request<FileItem>('POST', `/files/${fileId}/versions/${versionId}/restore`),
setEnabled: (fileId: string, enabled: boolean) =>
request<FileItem>('PATCH', `/files/${fileId}/versioning`, { enabled }),
}
// ── Admin ─────────────────────────────────────────────────────────────────────
export interface AdminUser {
id: string
username: string
display_name: string
is_admin: boolean
quota_bytes: number
used_bytes: number
groups: string[]
created_at: string
}
export interface AdminGroup {
id: string
name: string
description: string
member_count: number
created_at: string
}
export interface GroupMember {
user_id: string
username: string
display_name: string
joined_at: string
}
export interface AppSettings {
cloud_name: string
primary_color: string
accent_color: string
logo_storage_key: string
app_icon_storage_key: string
}
export interface AuditEntry {
id: string
actor_id: string | null
actor_name: string
action: string
target_type: string | null
target_id: string | null
target_name: string | null
ip: string | null
detail: string | null
created_at: string
}
export interface GroupFolder {
id: string
name: string
group_id: string
group_name: string
members: number
used_bytes: number
quota_bytes: number
}
function uploadBranding(kind: 'logo' | 'icon', file: File) {
const form = new FormData()
form.append('file', file)
return request<{ url: string }>('POST', `/admin/branding/${kind}`, form, true)
}
export const adminApi = {
// Branding
uploadLogo: (file: File) => uploadBranding('logo', file),
deleteLogo: () => request<void>('DELETE', '/admin/branding/logo'),
uploadAppIcon: (file: File) => uploadBranding('icon', file),
deleteAppIcon: () => request<void>('DELETE', '/admin/branding/icon'),
// Group folders
listGroupFolders: () => request<GroupFolder[]>('GET', '/admin/group-folders'),
createGroupFolder: (groupId: string, name: string) =>
request<FolderItem>('POST', '/admin/group-folders', { group_id: groupId, name }),
deleteGroupFolder: (id: string) => request<void>('DELETE', `/admin/group-folders/${id}`),
// Audit log
listAudit: (params: { action?: string; actorId?: string; limit?: number; offset?: number } = {}) => {
const qs = new URLSearchParams()
if (params.action) qs.set('action', params.action)
if (params.actorId) qs.set('actor_id', params.actorId)
if (params.limit != null) qs.set('limit', String(params.limit))
if (params.offset != null) qs.set('offset', String(params.offset))
const suffix = qs.toString() ? `?${qs}` : ''
return request<{ entries: AuditEntry[]; total: number }>('GET', `/admin/audit${suffix}`)
},
listAuditActions: () => request<string[]>('GET', '/admin/audit/actions'),
runCleanup: () => request<{
scanned: number; orphans: number; deleted: number
freed_bytes: number; skipped_young: number
}>('POST', '/admin/gc'),
/** Stand der Texterkennung. `enabled: false` heißt: abgeschaltet oder die
* Programme fehlen im Image – kein Fehler, sondern eine Auskunft. */
ocrStatus: () => request<OcrStatus>('GET', '/admin/ocr'),
// Users
listUsers: () => request<AdminUser[]>('GET', '/admin/users'),
createUser: (data: { username: string; display_name: string; password: string; is_admin: boolean; quota_bytes: number }) =>
request<AdminUser>('POST', '/admin/users', data),
updateUser: (id: string, data: { display_name: string; is_admin: boolean; quota_bytes: number }) =>
request<AdminUser>('PATCH', `/admin/users/${id}`, data),
resetPassword: (id: string, password?: string) =>
request<{ new_password: string }>('POST', `/admin/users/${id}/reset-password`, password ? { password } : {}),
deleteUser: (id: string) => request<void>('DELETE', `/admin/users/${id}`),
// Groups
listGroups: () => request<AdminGroup[]>('GET', '/admin/groups'),
createGroup: (name: string, description: string) =>
request<AdminGroup>('POST', '/admin/groups', { name, description }),
updateGroup: (id: string, name: string, description: string) =>
request<AdminGroup>('PATCH', `/admin/groups/${id}`, { name, description }),
deleteGroup: (id: string) => request<void>('DELETE', `/admin/groups/${id}`),
listMembers: (groupId: string) => request<GroupMember[]>('GET', `/admin/groups/${groupId}/members`),
addMember: (groupId: string, userId: string) =>
request<void>('POST', `/admin/groups/${groupId}/members`, { user_id: userId }),
removeMember: (groupId: string, userId: string) =>
request<void>('DELETE', `/admin/groups/${groupId}/members/${userId}`),
// Settings
getSettings: () => request<AppSettings>('GET', '/admin/settings'),
updateSettings: (data: Partial<AppSettings>) =>
request<AppSettings>('PATCH', '/admin/settings', data),
}
export { APIError }
+217
View File
@@ -0,0 +1,217 @@
import { useState, useEffect } from 'react'
import { appPasswordsApi, type AppPassword } from '../api/client'
import ConfirmDialog from './ConfirmDialog'
/** Zugangsdaten für WebDAV-Clients, die das Hauptpasswort ersetzen: Geht ein
* Gerät verloren, widerruft man dieses eine Token statt das Konto neu
* abzusichern. */
export default function AppPasswords({ username }: { username: string }) {
const [list, setList] = useState<AppPassword[]>([])
const [loading, setLoading] = useState(true)
const [name, setName] = useState('')
const [creating, setCreating] = useState(false)
const [error, setError] = useState('')
const [revoking, setRevoking] = useState<AppPassword | null>(null)
// Nur einmal im Klartext vorhanden – danach kennt der Server nur den Hash.
const [freshToken, setFreshToken] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
useEffect(() => { load() }, [])
async function load() {
try {
setList(await appPasswordsApi.list() ?? [])
} catch {
setError('Could not load app passwords')
} finally {
setLoading(false)
}
}
async function create() {
const trimmed = name.trim()
if (!trimmed) return
setCreating(true)
setError('')
try {
const created = await appPasswordsApi.create(trimmed)
setFreshToken(created.password)
setCopied(false)
setName('')
await load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not create app password')
} finally {
setCreating(false)
}
}
async function revoke(item: AppPassword) {
setRevoking(null)
try {
await appPasswordsApi.revoke(item.id)
await load()
} catch {
setError('Could not revoke app password')
}
}
function copyToken() {
if (!freshToken) return
navigator.clipboard?.writeText(freshToken).then(
() => { setCopied(true); setTimeout(() => setCopied(false), 2000) },
() => setError('Copying failed – please select the password manually'),
)
}
return (
<>
<p style={{ margin: '-8px 0 16px', fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
WebDAV and sync clients need an app password — your account password does
not work there. Each one can be revoked on its own, so a lost device costs
you one password, not your account.
</p>
{/* Der Klartext erscheint genau einmal; deshalb prominent und mit
deutlicher Ansage statt als beiläufige Zeile in der Liste. */}
{freshToken && (
<div style={{
padding: 14, borderRadius: 10, marginBottom: 16,
border: '1px solid var(--color-primary)',
background: 'rgba(var(--color-primary-rgb), 0.08)',
}}>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--color-text)', marginBottom: 8 }}>
Copy this password now — it is shown only once
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
<code style={{
flex: 1, padding: '9px 11px', borderRadius: 8,
background: 'var(--color-surface-2)', color: 'var(--color-text)',
fontSize: 14, fontFamily: 'monospace', letterSpacing: 0.5,
userSelect: 'all', overflowX: 'auto', whiteSpace: 'nowrap',
}}>
{freshToken}
</code>
<button
onClick={copyToken}
style={{
padding: '9px 14px', borderRadius: 8, border: 'none', flexShrink: 0,
background: 'var(--color-primary)', color: '#fff',
fontSize: 12, fontWeight: 600, cursor: 'pointer',
}}
>
{copied ? 'Copied' : 'Copy'}
</button>
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.6 }}>
In your WebDAV client use username <strong style={{ color: 'var(--color-text)' }}>{username}</strong> and
this password — not your account password.
</div>
<button
onClick={() => setFreshToken(null)}
style={{
marginTop: 10, padding: '5px 12px', borderRadius: 7,
border: '1px solid var(--color-border)', background: 'transparent',
color: 'var(--color-text-muted)', fontSize: 11, cursor: 'pointer',
}}
>
I've saved it
</button>
</div>
)}
{/* Create */}
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<input
value={name}
onChange={e => setName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') create() }}
placeholder="Device name, e.g. Phone"
style={{
flex: 1, 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={creating || !name.trim()}
style={{
padding: '8px 18px', borderRadius: 8, border: 'none',
background: 'var(--color-primary)', color: '#fff',
fontSize: 13, fontWeight: 600,
cursor: creating || !name.trim() ? 'default' : 'pointer',
opacity: creating || !name.trim() ? 0.6 : 1, flexShrink: 0,
}}
>
{creating ? 'Creating…' : 'Create'}
</button>
</div>
{error && (
<p style={{ margin: '0 0 12px', fontSize: 12, color: 'var(--color-danger)' }}>{error}</p>
)}
{/* List */}
{loading ? (
<p style={{ margin: 0, fontSize: 13, color: 'var(--color-text-muted)' }}>Loading…</p>
) : list.length === 0 ? (
<p style={{ margin: 0, fontSize: 13, color: 'var(--color-text-muted)' }}>
No app passwords yet — you need one before a WebDAV client can connect.
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{list.map(item => (
<div
key={item.id}
style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 12px', borderRadius: 9,
border: '1px solid var(--color-border)',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13, fontWeight: 600, color: 'var(--color-text)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{item.name}
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2 }}>
{item.last_used_at
? `Last used ${new Date(item.last_used_at).toLocaleDateString()}`
: 'Never used'}
{' · '}
Created {new Date(item.created_at).toLocaleDateString()}
</div>
</div>
<button
onClick={() => setRevoking(item)}
style={{
padding: '5px 12px', borderRadius: 7, flexShrink: 0,
border: '1px solid var(--color-danger)', background: 'transparent',
color: 'var(--color-danger)', fontSize: 12, cursor: 'pointer',
}}
>
Revoke
</button>
</div>
))}
</div>
)}
{revoking && (
<ConfirmDialog
title="Revoke app password"
message={`"${revoking.name}" will stop working immediately. Any device using it has to be set up again with a new password.`}
confirmLabel="Revoke"
danger
onConfirm={() => revoke(revoking)}
onCancel={() => setRevoking(null)}
/>
)}
</>
)
}
+114
View File
@@ -0,0 +1,114 @@
import { useEffect, useRef, useState } from 'react'
import { publicApi, type ExternalApp } from '../api/client'
/**
* DERZEIT NICHT EINGEHÄNGT (Stand 2026-07-30).
*
* Auf Wunsch wieder aus der Seitenleiste entfernt – erst soll Aether für sich
* fertig werden. Die Komponente bleibt liegen, weil eine gemeinsame Leiste für
* alle Apps noch überlegt wird. Wird sie nirgends importiert, landet sie auch
* nicht im Bundle. Zum Reaktivieren: in `Sidebar.tsx` neben dem Markenschriftzug
* einsetzen und EXTERNAL_APPS in `deploy/.env` füllen.
*
* Umschalter zu den anderen selbst gehosteten Apps.
*
* Die Einträge kommen aus EXTERNAL_APPS in der .env des Servers – so lassen
* sich Apps ergänzen, ohne das Frontend neu zu bauen. Ist nichts gesetzt,
* rendert die Komponente nichts.
*
* Die Ziele liegen auf anderen Ursprüngen (eigene Subdomain je App). Deshalb
* `target="_blank"`: Ist die andere App als PWA installiert, übernimmt das
* Betriebssystem sie als eigene App; sonst öffnet sie in einem neuen Tab.
* Eine Navigation im selben Fenster würde die installierte Aether-App
* verlassen und in einer Browserleiste landen.
*/
export default function AppSwitcher() {
const [apps, setApps] = useState<ExternalApp[]>([])
const [open, setOpen] = useState(false)
const boxRef = useRef<HTMLDivElement>(null)
useEffect(() => {
publicApi.config()
.then(c => setApps(c.external_apps ?? []))
.catch(() => {})
}, [])
useEffect(() => {
if (!open) return
function onDown(e: MouseEvent) {
if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false)
}
function onEsc(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onEsc)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onEsc)
}
}, [open])
if (apps.length === 0) return null
return (
<div ref={boxRef} style={{ position: 'relative', marginLeft: 'auto' }}>
<button
onClick={() => setOpen(o => !o)}
aria-label="Other apps"
aria-expanded={open}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 28, height: 28, borderRadius: 8, cursor: 'pointer',
background: open ? 'var(--color-surface-2)' : 'transparent',
border: 'none', color: 'var(--color-text-muted)', padding: 0,
}}
>
{/* Raster aus neun Punkten – das übliche Zeichen für einen App-Wechsel */}
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
{[5, 12, 19].map(y => [5, 12, 19].map(x => (
<circle key={`${x}-${y}`} cx={x} cy={y} r="1.8" />
)))}
</svg>
</button>
{open && (
<div style={{
position: 'absolute', top: 34, right: 0, zIndex: 60, minWidth: 190,
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius)',
boxShadow: '0 10px 30px rgba(0,0,0,0.4)',
padding: 4,
}}>
<div style={{
padding: '6px 10px 4px', fontSize: 11, fontWeight: 600,
color: 'var(--color-text-muted)', textTransform: 'uppercase',
letterSpacing: '0.04em',
}}>
My apps
</div>
{apps.map(app => (
<a
key={app.url}
href={app.url}
target="_blank"
rel="noopener noreferrer"
onClick={() => setOpen(false)}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '8px 10px', borderRadius: 8,
fontSize: 13, color: 'var(--color-text)', textDecoration: 'none',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--color-surface-2)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
<span style={{ fontSize: 16, width: 20, textAlign: 'center' }}>{app.icon || '🔗'}</span>
{app.name}
</a>
))}
</div>
)}
</div>
)
}
+87
View File
@@ -0,0 +1,87 @@
import type { FolderItem } from '../api/client'
interface Props {
path: FolderItem[]
onNavigate: (folderId: string | undefined) => void
}
/**
* Der Pfad in der Kopfleiste.
*
* Schriftgröße und -stärke sind dieselben wie bei den Titeln der anderen
* Ansichten („Trash", „Shared links"): 15 px, 600 für den aktuellen Ort. Bis
* zum 23.08.2026 lief das über Tailwind-Klassen (`text-sm`, `font-medium`) und
* war damit 14 px bei Stärke 500 – nebeneinander sieht man den Unterschied
* sofort, auch ohne ihn benennen zu können.
*
* Auch das Symbol war kleiner (15 statt 18). Es ist jetzt Zeichen für Zeichen
* dasselbe wie in der Seitenleiste.
*/
const rootLabel = 'Home'
export default function Breadcrumb({ path, onNavigate }: Props) {
const atRoot = path.length === 0
const segment = (active: boolean) => ({
flexShrink: 0,
display: 'flex',
alignItems: 'center',
// 10 wie in PageHeader zwischen Symbol und Titel – bei 7 stand „Home"
// drei Pixel weiter links als „Trash". Gemessen: 265 statt 268.
gap: 10,
padding: '2px 6px',
borderRadius: 6,
border: 'none',
background: 'none',
cursor: 'pointer',
// Gleiche Schrift wie die Seitentitel – sonst wirkt die Dateiansicht wie
// eine andere Anwendung.
fontSize: 15,
fontWeight: 600,
lineHeight: 1.3,
color: active ? 'var(--color-text)' : 'var(--color-text-muted)',
})
return (
<nav style={{
display: 'flex', alignItems: 'center', gap: 2,
minWidth: 0, overflowX: 'auto',
// Der Wurzel-Knopf hat innen 6 px Rand, damit sein Hover-Feld nicht am
// Text klebt. Ohne diesen Ausgleich begänne das Haus-Symbol 6 px weiter
// rechts als das Symbol in Papierkorb und Co. – beim Umschalten sieht man
// genau diesen Sprung. Gemessen: 246 statt 240.
marginLeft: -6,
}}>
<button onClick={() => onNavigate(undefined)} style={segment(atRoot)} title={rootLabel}>
{/* Das Symbol bleibt gedämpft, auch wenn der Titel hell ist – genau so
steht es im Papierkorb und bei den Freigabe-Links. Vorher erbte es
die Textfarbe und leuchtete auf der Startebene heller als dort. */}
<span style={{ display: 'flex', color: 'var(--color-text-muted)' }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 10.5L12 3l9 7.5" />
<path d="M5.5 9.5V20a1 1 0 001 1h11a1 1 0 001-1V9.5" />
</svg>
</span>
{rootLabel}
</button>
{path.map((folder, i) => (
<span key={folder.id} style={{ display: 'flex', alignItems: 'center', gap: 2, minWidth: 0 }}>
<span style={{ color: 'var(--color-text-muted)', fontSize: 15, opacity: 0.6 }}>/</span>
<button
onClick={() => onNavigate(folder.id)}
style={{
...segment(i === path.length - 1),
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
display: 'block', maxWidth: 220,
}}
title={folder.name}
>
{folder.name}
</button>
</span>
))}
</nav>
)
}
+55
View File
@@ -0,0 +1,55 @@
import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
interface Props {
title: string
message: string
confirmLabel?: string
danger?: boolean
onConfirm: () => void
onCancel: () => void
}
export default function ConfirmDialog({ title, message, confirmLabel = 'Confirm', danger = false, onConfirm, onCancel }: Props) {
const overlay = useOverlayDismiss(onCancel)
return (
<div
style={{ position: 'fixed', inset: 0, zIndex: 200, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.6)' }}
{...overlay}
>
<div style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 14,
padding: 24,
width: 360,
maxWidth: '90vw',
}}>
<h2 style={{ margin: '0 0 8px', fontSize: 16, fontWeight: 600, color: 'var(--color-text)' }}>{title}</h2>
<p style={{ margin: '0 0 24px', fontSize: 14, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>{message}</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
onClick={onCancel}
style={{
padding: '8px 16px', borderRadius: 8,
border: '1px solid var(--color-border)',
background: 'transparent', color: 'var(--color-text-muted)',
fontSize: 13, cursor: 'pointer',
}}
>
Cancel
</button>
<button
onClick={onConfirm}
style={{
padding: '8px 16px', borderRadius: 8, border: 'none',
background: danger ? 'var(--color-danger)' : 'var(--color-primary)',
color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
}}
>
{confirmLabel}
</button>
</div>
</div>
</div>
)
}
+110
View File
@@ -0,0 +1,110 @@
import { useEffect, useRef, type ReactNode } from 'react'
import { CheckIcon } from './icons'
export interface MenuAction {
label: string
icon?: ReactNode
danger?: boolean
/** Setzt einen Haken rechts – für Einträge, die einen Zustand umschalten. */
checked?: boolean
onClick: () => void
}
/** Eine Trennlinie gruppiert zusammengehörende Einträge. */
export type MenuItem = MenuAction | { separator: true }
function isSeparator(item: MenuItem): item is { separator: true } {
return 'separator' in item
}
interface Props {
x: number
y: number
items: MenuItem[]
onClose: () => void
}
const ITEM_HEIGHT = 32
const SEPARATOR_HEIGHT = 9
const MENU_WIDTH = 190
export default function ContextMenu({ x, y, items, onClose }: Props) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose()
}
const esc = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
document.addEventListener('mousedown', handler)
document.addEventListener('keydown', esc)
return () => {
document.removeEventListener('mousedown', handler)
document.removeEventListener('keydown', esc)
}
}, [onClose])
// Höhe aus dem Inhalt schätzen, statt eine feste Zahl anzunehmen: sonst
// klappt ein langes Menü am unteren Rand aus dem Bild heraus.
const height =
items.reduce((h, i) => h + (isSeparator(i) ? SEPARATOR_HEIGHT : ITEM_HEIGHT), 0) + 8
const style: React.CSSProperties = {
position: 'fixed',
top: Math.max(8, Math.min(y, window.innerHeight - height - 8)),
left: Math.max(8, Math.min(x, window.innerWidth - MENU_WIDTH - 8)),
zIndex: 50,
minWidth: MENU_WIDTH,
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius)',
boxShadow: '0 10px 30px rgba(0,0,0,0.4)',
padding: '4px',
}
return (
<div ref={ref} style={style}>
{items.map((item, i) =>
isSeparator(item) ? (
<div
key={`sep-${i}`}
style={{ height: 1, background: 'var(--color-border)', margin: '4px 6px' }}
/>
) : (
<button
key={item.label}
onClick={() => { item.onClick(); onClose() }}
style={{
width: '100%', height: ITEM_HEIGHT,
display: 'flex', alignItems: 'center', gap: 10,
padding: '0 10px', borderRadius: 6,
border: 'none', background: 'transparent', cursor: 'pointer',
fontSize: 13, textAlign: 'left',
color: item.danger ? 'var(--color-danger)' : 'var(--color-text)',
transition: 'background 0.12s',
}}
onMouseEnter={e => (e.currentTarget.style.background = item.danger
? 'rgba(239,68,68,0.12)'
: 'var(--color-surface-2)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
{/* Feste Spalte für das Icon – so stehen alle Beschriftungen bündig,
auch bei Einträgen ohne Icon. */}
<span style={{
width: 15, display: 'flex', alignItems: 'center', justifyContent: 'center',
color: item.danger ? 'var(--color-danger)' : 'var(--color-text-muted)',
}}>
{item.icon}
</span>
<span style={{ flex: 1 }}>{item.label}</span>
{item.checked && (
<span style={{ color: 'var(--color-primary)', display: 'flex' }}>
<CheckIcon size={14} />
</span>
)}
</button>
)
)}
</div>
)
}
+66
View File
@@ -0,0 +1,66 @@
import { type FormEvent, useEffect, useRef } from 'react'
import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
interface Props {
title: string
defaultValue?: string
placeholder?: string
confirmLabel?: string
onConfirm: (value: string) => void
onCancel: () => void
}
export default function InputDialog({ title, defaultValue = '', placeholder, confirmLabel = 'OK', onConfirm, onCancel }: Props) {
const inputRef = useRef<HTMLInputElement>(null)
const overlay = useOverlayDismiss(onCancel)
useEffect(() => {
inputRef.current?.focus()
inputRef.current?.select()
}, [])
function handleSubmit(e: FormEvent) {
e.preventDefault()
const val = inputRef.current?.value.trim()
if (val) onConfirm(val)
}
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-sm rounded-xl p-5 shadow-2xl border"
style={{ background: 'var(--color-surface)', borderColor: 'var(--color-border)' }}>
<h2 className="font-semibold mb-4" style={{ color: 'var(--color-text)' }}>{title}</h2>
<form onSubmit={handleSubmit} className="space-y-3">
<input
ref={inputRef}
type="text"
defaultValue={defaultValue}
placeholder={placeholder}
className="w-full rounded-lg px-3 py-2.5 text-sm outline-none"
style={{
background: 'var(--color-surface-2)',
border: '1px solid var(--color-border)',
color: 'var(--color-text)',
}}
onFocus={e => e.currentTarget.style.borderColor = 'var(--color-primary)'}
onBlur={e => e.currentTarget.style.borderColor = 'var(--color-border)'}
/>
<div className="flex justify-end gap-2">
<button type="button" onClick={onCancel}
className="px-4 py-2 text-sm rounded-lg transition-opacity hover:opacity-70"
style={{ color: 'var(--color-text-muted)' }}>
Cancel
</button>
<button type="submit"
className="px-4 py-2 text-sm rounded-lg font-medium"
style={{ background: 'var(--color-primary)', color: 'var(--color-primary-fg)' }}>
{confirmLabel}
</button>
</div>
</form>
</div>
</div>
)
}
+144
View File
@@ -0,0 +1,144 @@
import { useState } from 'react'
import { filesApi, type FileItem, type FolderItem } from '../api/client'
import { FileTypeIcon, FolderGlyph } from './icons'
import { fileKind, kindColors, extensionOf } from './filetypes'
/**
* Das Bild auf einer Kachel.
*
* Bilder zeigen ihr Vorschaubild. Alles andere bekam vorher dieselbe blasse
* Fläche mit einem kleinen Symbol – eine MP3, ein Archiv und eine Tabelle sahen
* damit fast gleich aus. Jetzt trägt jede Art ihre eigene Farbe, ein
* passendes Symbol und die Endung als Aufkleber: Man erkennt den Typ, bevor man
* den Namen gelesen hat.
*/
export default function FileCover({ file }: { file: FileItem }) {
const [failed, setFailed] = useState(false)
const kind = fileKind(file.mime_type, file.name)
const color = kindColors[kind]
const ext = extensionOf(file.name).toUpperCase()
if (file.has_thumbnail && !failed) {
return (
<>
<img
src={filesApi.thumbnail(file.id)}
alt=""
onError={() => setFailed(true)}
style={{
width: '100%', height: '100%',
objectFit: 'cover',
position: 'absolute', inset: 0,
}}
/>
{/* Auch über dem Vorschaubild: Sonst sieht man einem Bild nicht an, ob
es ein PNG mit Transparenz, ein JPG oder ein GIF ist – und beim
Weitergeben macht das einen Unterschied.
In der Farbe der Art wie überall sonst; ein dunkler Kasten hätte die
eine Kachel aus der Reihe fallen lassen. Damit er auf einem hellen
Motiv nicht verschwimmt, bekommt er einen dunklen Saum. */}
{ext && <Badge text={ext} background={color} ring />}
</>
)
}
return (
<div style={{
position: 'absolute', inset: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
// Verlauf statt einer flachen Fläche: Der Unterschied zwischen zwei
// Arten ist bei 12 % Deckkraft sonst kaum zu sehen.
background: `linear-gradient(135deg, ${color}2e 0%, ${color}12 55%, transparent 100%)`,
}}>
<FileTypeIcon mime={file.mime_type} name={file.name} size={34} />
{ext && <Badge text={ext} background={color} />}
{/* Bei Ton und Video verrät ein Abspielzeichen sofort, dass hier ein
Doppelklick etwas abspielt. */}
{(kind === 'video' || kind === 'audio') && (
<span style={{
position: 'absolute', right: 7, bottom: 6,
width: 18, height: 18, borderRadius: '50%',
background: `${color}33`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<svg width="9" height="9" viewBox="0 0 24 24" fill={color}>
<polygon points="6 4 20 12 6 20" />
</svg>
</span>
)}
</div>
)
}
/** Der Aufkleber unten links. Eine Stelle, damit er über einem Vorschaubild
* und über einer Farbfläche gleich sitzt. */
function Badge({ text, background, ring }: { text: string; background: string; ring?: boolean }) {
return (
<span style={{
position: 'absolute', left: 8, bottom: 7,
padding: '1px 5px', borderRadius: 4,
background, color: '#fff',
// Nur über einem Vorschaubild nötig – auf der ruhigen Farbfläche wäre
// der Saum bloß Unruhe.
boxShadow: ring ? '0 0 0 1px rgba(0,0,0,0.45), 0 1px 3px rgba(0,0,0,0.5)' : 'none',
fontSize: 9, fontWeight: 700, letterSpacing: 0.4,
textTransform: 'uppercase',
maxWidth: 'calc(100% - 16px)', overflow: 'hidden',
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{text}
</span>
)
}
/**
* Das Gegenstück für Ordner.
*
* Vorher ein flaches Symbol auf blasser Fläche – neben den neuen Dateikacheln
* sah der Ordner damit aus wie vergessen. Jetzt zwei Lagen mit Reiter, die
* Tiefe andeuten, dazu derselbe Verlauf wie bei den Dateien.
*
* Ein Gruppenordner trägt die Primärfarbe statt Bernstein und ein
* Personen-Zeichen: Er gehört nicht dir allein, und das soll man sehen, bevor
* man etwas hineinlegt.
*/
export function FolderCover({ folder, highlight }: { folder: FolderItem; highlight?: boolean }) {
const isGroup = folder.group_id !== null
// Der Gruppenfall über die Token-Variable, damit er der eingestellten
// Palette folgt; Bernstein ist fest, weil es die Ordnerfarbe der App ist.
const tint = isGroup ? 'rgba(var(--color-primary-rgb), ALPHA)' : 'rgba(245, 158, 11, ALPHA)'
const at = (a: number) => tint.replace('ALPHA', String(a))
const solid = isGroup ? 'rgb(var(--color-primary-rgb))' : '#f59e0b'
return (
<div style={{
position: 'absolute', inset: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: highlight
? 'rgba(var(--color-primary-rgb), 0.16)'
: `linear-gradient(135deg, ${at(0.3)} 0%, ${at(0.12)} 55%, transparent 100%)`,
transition: 'background 0.12s',
}}>
<FolderGlyph size={48} color={solid} />
{isGroup && (
<span style={{
position: 'absolute', left: 8, bottom: 7,
display: 'flex', alignItems: 'center', gap: 3,
padding: '1px 5px', borderRadius: 4,
background: solid, color: '#fff',
fontSize: 9, fontWeight: 700, letterSpacing: 0.4,
}}>
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4">
<circle cx="9" cy="8" r="3.5" />
<path d="M2 21v-1a7 7 0 0114 0v1" />
<path d="M17 11a3 3 0 100-6M19 21v-1a6 6 0 00-3-5.2" />
</svg>
GROUP
</span>
)}
</div>
)
}
+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>
)
}
+373
View File
@@ -0,0 +1,373 @@
import { lazy, Suspense, useEffect, useState, useSyncExternalStore } from 'react'
import { filesApi, APIError, type FileItem } from '../api/client'
import { fileKind } from './filetypes'
/**
* Nachgeladen, nicht mitgeliefert: PDF.js wiegt rund eine halbe Megabyte im
* Hauptbündel und noch einmal 1,3 MB im Arbeiter. Wer nur Bilder ansieht,
* soll das nicht bei jedem Besuch herunterladen – geholt wird es erst beim
* ersten PDF und liegt danach im Cache des Browsers.
*/
const PdfViewer = lazy(() => import('./PdfViewer'))
interface Props {
file: FileItem
/** Blättern innerhalb des Ordners – null, wenn es nichts zu blättern gibt. */
onPrev?: () => void
onNext?: () => void
onClose: () => void
/** Nach dem Speichern: Liste neu holen, damit Größe und Datum stimmen. */
onSaved?: () => void
/**
* Suchwort, mit dem die Datei gefunden wurde. Der PDF-Betrachter springt
* damit zur Fundstelle, statt auf Seite 1 zu beginnen – der Grund, aus dem
* es überhaupt ein eigener Betrachter geworden ist.
*/
searchTerm?: string
}
/**
* Was der Browser selbst anzeigen kann, zeigt er: Bilder, PDF, Text, Video,
* Ton. Der Server liefert diese Typen mit `inline`, alles andere bleibt ein
* Download – die Liste steht in internal/files/handler.go und ist eine
* Positivliste, damit eine hochgeladene HTML-Datei nie im Ursprung der
* Anwendung läuft.
*/
type Kind = 'image' | 'pdf' | 'text' | 'video' | 'audio' | 'none'
/**
* Der Name zählt mit, nicht nur der MIME-Typ: Ältere Dateien liegen als
* application/octet-stream in der Datenbank, weil der Server bis zum
* 23.08.2026 die Endungen .mp3 und .mp4 nicht kannte. Ohne den Namen ließe
* sich eine vorhandene MP3 also nie abspielen.
*/
function kindOf(mime: string, name = ''): Kind {
const m = (mime || '').toLowerCase()
// SVG bleibt draußen: Der Server liefert sie nie inline aus, weil eine
// SVG-Datei Skripte enthalten darf.
if (m.includes('svg')) return 'none'
switch (fileKind(mime, name)) {
case 'image': return 'image'
case 'pdf': return 'pdf'
case 'video': return 'video'
case 'audio': return 'audio'
case 'text':
case 'code': return 'text'
case 'doc':
// Nur echte Textdateien, kein .docx – das ist ein ZIP-Archiv.
return m.startsWith('text/') || /\.(txt|md|log)$/i.test(name) ? 'text' : 'none'
default: return 'none'
}
}
/**
* Unterhalb dieser Breite ist der Bildschirm ein Telefon.
*
* Dort zählt jeder Pixel: Ein PDF, das nach Rand und Blätterpfeilen nur noch
* die halbe Breite hat, wird bei 51 % dargestellt – lesbar ist das nicht mehr.
*/
const narrowQuery = '(max-width: 640px)'
function useNarrow(): boolean {
return useSyncExternalStore(
cb => {
const m = window.matchMedia(narrowQuery)
m.addEventListener('change', cb)
return () => m.removeEventListener('change', cb)
},
() => window.matchMedia(narrowQuery).matches,
() => false,
)
}
/** Grenze fürs Nachladen von Text. Darüber wird nicht angezeigt, sondern
* heruntergeladen – ein 200-MB-Protokoll bringt jeden Browser ins Stocken. */
const maxTextBytes = 2 * 1024 * 1024
export function canPreview(file: FileItem): boolean {
return kindOf(file.mime_type, file.name) !== 'none'
}
export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, searchTerm }: Props) {
const kind = kindOf(file.mime_type, file.name)
const narrow = useNarrow()
const src = filesApi.inline(file.id)
const [text, setText] = useState<string | null>(null)
const [tooBig, setTooBig] = useState(false)
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState('')
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState('')
const dirty = editing && draft !== (text ?? '')
useEffect(() => {
function onKey(e: KeyboardEvent) {
// Im Textfeld gehören die Pfeiltasten dem Schreiben, nicht dem Blättern.
if (editing) {
if (e.key === 'Escape') tryClose()
return
}
if (e.key === 'Escape') { onClose(); return }
// Die Pfeiltasten blättern zwischen Dateien – aber nicht, während
// jemand in einem Feld schreibt. Der PDF-Betrachter hat eine eigene
// Suche; dort setzte ein Pfeil sonst mitten im Wort die nächste Datei
// vor.
const el = document.activeElement
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) return
if (e.key === 'ArrowLeft') onPrev?.()
else if (e.key === 'ArrowRight') onNext?.()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onClose, onPrev, onNext, editing, dirty])
useEffect(() => {
if (kind !== 'text') return
setText(null); setTooBig(false)
if (file.size > maxTextBytes) { setTooBig(true); return }
setEditing(false); setSaveError('')
let alive = true
fetch(src, { credentials: 'include' })
.then(r => r.text())
.then(t => { if (alive) { setText(t); setDraft(t) } })
.catch(() => { if (alive) setText('(could not load this file)') })
return () => { alive = false }
}, [src, kind, file.size])
/**
* Gespeichert wird über denselben Weg wie ein Upload: gleicher Name, gleicher
* Ordner. Der Server ersetzt die Datei damit und legt – wenn Versionierung
* für sie eingeschaltet ist – die alte Fassung in die Historie. Ein eigener
* Endpunkt zum Schreiben bräuchte dieselben Prüfungen noch einmal.
*/
async function save() {
setSaving(true); setSaveError('')
try {
const blob = new File([draft], file.name, { type: file.mime_type || 'text/plain' })
await filesApi.upload(blob, file.folder_id ?? undefined)
setText(draft)
setEditing(false)
onSaved?.()
} catch (err) {
// Nur-Lese-Zugriff auf eine geliehene Datei ist der häufigste Fall –
// deshalb hier ausgeschrieben statt "403".
setSaveError(
err instanceof APIError && err.status === 403
? 'You only have read access to this file.'
: err instanceof Error ? err.message : 'Could not save',
)
} finally {
setSaving(false)
}
}
function tryClose() {
if (dirty && !confirm('Discard your changes?')) return
onClose()
}
return (
<div
onClick={tryClose}
style={{
position: 'fixed', inset: 0, zIndex: 100,
background: 'rgba(0,0,0,0.82)',
display: 'flex', flexDirection: 'column',
}}
>
{/* Kopfzeile */}
<div
onClick={e => e.stopPropagation()}
style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 14px', flexShrink: 0,
background: 'var(--color-surface)',
borderBottom: '1px solid var(--color-border)',
}}
>
<span style={{
flex: 1, minWidth: 0, fontSize: 14, color: 'var(--color-text)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{file.name}
</span>
{kind === 'text' && !tooBig && text !== null && (
editing ? (
<>
{saveError && (
<span style={{ fontSize: 12, color: 'var(--color-danger)' }}>{saveError}</span>
)}
<button
onClick={() => { setDraft(text ?? ''); setEditing(false); setSaveError('') }}
disabled={saving}
style={{
padding: '6px 12px', borderRadius: 8, fontSize: 12,
border: '1px solid var(--color-border)', background: 'transparent',
color: 'var(--color-text-muted)', cursor: 'pointer',
}}
>
Cancel
</button>
<button
onClick={save}
disabled={saving || !dirty}
style={{
padding: '6px 12px', borderRadius: 8, fontSize: 12, fontWeight: 600,
border: 'none', background: 'var(--color-primary)', color: '#fff',
cursor: saving || !dirty ? 'default' : 'pointer',
opacity: saving || !dirty ? 0.6 : 1,
}}
>
{saving ? 'Saving…' : 'Save'}
</button>
</>
) : (
<button
onClick={() => setEditing(true)}
style={{
padding: '6px 12px', borderRadius: 8, fontSize: 12,
border: '1px solid var(--color-border)', background: 'transparent',
color: 'var(--color-text-muted)', cursor: 'pointer',
}}
>
Edit
</button>
)
)}
<a
href={filesApi.download(file.id)}
download={file.name}
style={{
padding: '6px 12px', borderRadius: 8, fontSize: 12, fontWeight: 600,
background: 'var(--color-primary)', color: '#fff', textDecoration: 'none',
}}
>
Download
</a>
<button
onClick={tryClose}
title="Close (Esc)"
style={{
padding: 6, borderRadius: 8, border: 'none', background: 'none',
color: 'var(--color-text-muted)', cursor: 'pointer', display: 'flex',
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{/* Inhalt */}
<div
onClick={e => e.stopPropagation()}
style={{
flex: 1, minHeight: 0, display: 'flex',
alignItems: 'center', justifyContent: 'center',
// Ein PDF bekommt fast den ganzen Platz: Es bringt seinen eigenen
// weißen Rand mit, ein zweiter darum herum verschenkt nur Breite.
padding: kind === 'pdf' ? (narrow ? 2 : 8) : 16,
}}
>
{/* Am Telefon blättert man nicht per Pfeil zur nächsten Datei, während
man ein Dokument liest – dort lägen die Knöpfe mitten auf dem Text
und würden beim Wischen getroffen. */}
{onPrev && !(kind === 'pdf' && narrow) && <PagerButton dir="prev" onClick={onPrev} />}
{kind === 'image' && (
<img src={src} alt={file.name}
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} />
)}
{kind === 'pdf' && (
// Eigener Betrachter statt des eingebauten im <iframe>: Chrome auf
// Android zeigte darin gar nichts an, und ein Suchtreffer ließ sich
// ihm nicht mitteilen. Siehe PdfViewer.tsx.
<Suspense fallback={
<span style={{ color: 'var(--color-text-muted)', fontSize: 13 }}>Loading…</span>
}>
<PdfViewer url={src} fileId={file.id} searchTerm={searchTerm} />
</Suspense>
)}
{kind === 'video' && (
<video src={src} controls autoPlay
style={{ maxWidth: '100%', maxHeight: '100%', borderRadius: 8 }} />
)}
{kind === 'audio' && (
<audio src={src} controls style={{ width: 'min(520px, 90%)' }} />
)}
{/* Text bekommt die Form eines Blattes statt der ganzen Fläche: über
die volle Breite gelesen springt das Auge beim Zeilenwechsel, und
eine kurze Notiz auf einem riesigen Feld wirkt erschlagend. */}
{kind === 'text' && (
<div style={{
width: '100%', maxWidth: 820, height: '100%', maxHeight: 680,
display: 'flex', flexDirection: 'column',
borderRadius: 10, overflow: 'hidden',
border: `1px solid ${editing ? 'var(--color-primary)' : 'var(--color-border)'}`,
background: 'var(--color-surface)',
boxShadow: '0 12px 40px rgba(0,0,0,0.35)',
}}>
{editing ? (
<textarea
value={draft}
onChange={e => setDraft(e.target.value)}
spellCheck={false}
autoFocus
style={{
flex: 1, resize: 'none', width: '100%',
padding: '18px 20px', border: 'none',
background: 'transparent', color: 'var(--color-text)',
fontFamily: 'ui-monospace, monospace',
fontSize: 13, lineHeight: 1.65, outline: 'none',
}}
/>
) : (
<pre style={{
flex: 1, margin: 0, overflow: 'auto',
padding: '18px 20px',
color: 'var(--color-text)',
fontFamily: 'ui-monospace, monospace',
fontSize: 13, lineHeight: 1.65,
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
}}>
{tooBig
? 'This file is too large to show here – use Download.'
: text ?? 'Loading…'}
</pre>
)}
</div>
)}
{onNext && !(kind === 'pdf' && narrow) && <PagerButton dir="next" onClick={onNext} />}
</div>
</div>
)
}
function PagerButton({ dir, onClick }: { dir: 'prev' | 'next'; onClick: () => void }) {
return (
<button
onClick={onClick}
title={dir === 'prev' ? 'Previous (←)' : 'Next (→)'}
style={{
position: 'absolute', [dir === 'prev' ? 'left' : 'right']: 12,
top: '50%', transform: 'translateY(-50%)',
width: 40, height: 40, borderRadius: '50%',
border: 'none', background: 'rgba(0,0,0,0.5)', color: '#fff',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points={dir === 'prev' ? '15 18 9 12 15 6' : '9 18 15 12 9 6'} />
</svg>
</button>
)
}
+511
View File
@@ -0,0 +1,511 @@
import { useEffect, useRef, useState } from 'react'
import { type Correspondent, type FileItem, type FolderItem, type Tag } from '../api/client'
import { TagRow } from './TagDialog'
import TagChip from './TagChip'
import { FileTypeIcon, FolderGlyph } from './icons'
import FileCover, { FolderCover } from './FileCover'
import { formatBytes } from '../pages/FileBrowser'
/**
* Die beiden Darstellungen einer Ordnerliste: Kacheln und Zeilen.
*
* Lagen bis zum 23.08.2026 unten in FileBrowser.tsx und machten die Seite über
* 1100 Zeilen lang. Sie hängen nur an ihren Eigenschaften, nicht am Zustand der
* Seite – hier stehen sie für sich.
*/
/** Klein in der Liste – dieselbe Form wie auf der Kachel. 24 statt 22 Pixel,
* weil 48/24 glatt aufgeht und die Kanten damit scharf bleiben. */
function FolderIcon({ size = 24, group = false }: { size?: number; group?: boolean }) {
return <FolderGlyph size={size} color={group ? 'rgb(var(--color-primary-rgb))' : '#f59e0b'} />
}
/**
* Zeigt der Nutzer mit dem Finger oder mit einer Maus?
*
* `pointer: coarse` ist die Frage nach der Genauigkeit des Zeigegeräts, nicht
* nach der Bildschirmbreite – ein schmales Fenster auf dem Schreibtisch bleibt
* damit richtig eine Maus, ein Tablet im Querformat richtig ein Finger.
*
* Der Anlass: **Auf dem Handy öffnete gar nichts.** Ordner und Dateien hingen
* an `onDoubleClick`, und den doppelten Klick gibt es auf einem Touchscreen
* praktisch nicht – der Browser meldet zwei einzelne Berührungen, weil man
* nicht zweimal auf denselben Pixel tippt. Der Doppelklick ist eine
* Mausgeste; am Finger ist er nie angekommen.
*/
function useCoarsePointer(): boolean {
const [coarse, setCoarse] = useState(
() => typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches,
)
useEffect(() => {
if (typeof matchMedia !== 'function') return
const mq = matchMedia('(pointer: coarse)')
const on = () => setCoarse(mq.matches)
mq.addEventListener('change', on)
return () => mq.removeEventListener('change', on)
}, [])
return coarse
}
/**
* Die Öffnen-Geste, je nach Zeigegerät.
*
* Maus: Doppelklick – der einfache Klick bleibt zum Auswählen frei.
* Finger: **einfacher Tipp**. So macht es jede Dateiverwaltung am Telefon,
* und etwas anderes wäre dort nicht zu erraten.
*
* Nur für **Ordner**: Die haben keine Auswahl und keinen zweiten Klick-
* Handler, deshalb genügt hier das Umschalten der Eigenschaft. Dateien
* schreiben ihren Handler selbst aus – warum, steht dort.
*/
function openGesture(coarse: boolean, open: () => void) {
return coarse ? { onClick: open } : { onDoubleClick: open }
}
interface ViewProps {
folders: FolderItem[]
files: FileItem[]
onFolderOpen: (f: FolderItem) => void
onFolderMenu: (e: React.MouseEvent, f: FolderItem) => void
onFileMenu: (e: React.MouseEvent, f: FileItem) => void
/** Öffnen (Doppelklick bzw. Tipp): Vorschau, wenn möglich – sonst Download. */
onFileOpen: (file: FileItem) => void
onFileDownload: (file: FileItem) => void
onFolderDrop: (e: React.DragEvent, folder: FolderItem) => void
dragOverFolder: string | null
onFolderDragOver: (id: string | null) => void
/**
* Datei, zu der gerade aus der Suche gesprungen wurde. Sie wird kurz
* umrandet und in den sichtbaren Bereich geholt.
*
* Ohne das wäre der Sprung in einen Ordner mit fünfzig Dateien wertlos: Man
* landet am richtigen Ort und sucht dann von vorn.
*/
highlightId?: string | null
/** Etiketten je Datei-ID – kommen mit der Ordnerliste in einer Abfrage mit. */
fileTags?: Record<string, Tag[]>
/** Absender je Datei-ID. Anders als die Etiketten höchstens einer. */
fileCorrespondents?: Record<string, Correspondent>
/** Ausgewählte Dateien für den Sammel-Download. */
selected?: Set<string>
/** Umschalten; `range` bei gedrückter Umschalttaste. */
onToggleSelect?: (id: string, range: boolean) => void
}
/** Umrandung der eben angesprungenen Datei. */
const highlightRing = '0 0 0 2px var(--color-primary)'
/**
* Holt die markierte Datei in den sichtbaren Bereich.
*
* In einem eigenen Haken, weil Kachel- und Listenansicht ihn beide brauchen –
* und weil es in einem Callback-Ref bei jedem Neuzeichnen erneut scrollen
* würde, auch wenn sich gar nichts geändert hat.
*/
function useScrollToHighlight(highlightId?: string | null) {
const ref = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!highlightId || !ref.current) return
ref.current.scrollIntoView({ block: 'center', behavior: 'smooth' })
}, [highlightId])
return ref
}
export default function GridView({ folders, files, onFolderOpen, onFolderMenu, onFileMenu, onFileOpen, onFolderDrop, dragOverFolder, onFolderDragOver, highlightId, fileTags, fileCorrespondents, selected, onToggleSelect }: ViewProps) {
const highlightRef = useScrollToHighlight(highlightId)
const coarse = useCoarsePointer()
return (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))',
gap: 12,
}}>
{folders.map(folder => {
const isOver = dragOverFolder === folder.id
// Ein Gruppenordner lässt sich nicht verschieben – ihn ziehbar zu
// machen, würde eine Bewegung anbieten, die der Server ablehnt.
const isGroupFolder = folder.group_id !== null
return (
<div
key={folder.id}
draggable={!isGroupFolder}
onDragStart={e => {
if (isGroupFolder) { e.preventDefault(); return }
e.dataTransfer.setData('aether-folder-id', folder.id)
e.dataTransfer.effectAllowed = 'move'
}}
style={{
background: 'var(--color-surface)',
border: `1px solid ${isOver ? 'var(--color-primary)' : 'var(--color-border)'}`,
borderRadius: 12, overflow: 'hidden', cursor: 'grab',
transition: 'border-color 0.15s, transform 0.1s, background 0.1s',
display: 'flex', flexDirection: 'column',
transform: isOver ? 'scale(1.03)' : 'none',
boxShadow: isOver ? '0 0 0 3px rgba(var(--color-primary-rgb), 0.2)' : 'none',
}}
{...openGesture(coarse, () => onFolderOpen(folder))}
onContextMenu={e => onFolderMenu(e, folder)}
onMouseEnter={e => { if (!isOver) { e.currentTarget.style.borderColor = 'var(--color-primary)'; e.currentTarget.style.transform = 'translateY(-1px)' } }}
onMouseLeave={e => { if (!isOver) { e.currentTarget.style.borderColor = 'var(--color-border)'; e.currentTarget.style.transform = 'none' } }}
onDragOver={e => { e.preventDefault(); e.stopPropagation(); onFolderDragOver(folder.id) }}
onDragLeave={e => { e.stopPropagation(); onFolderDragOver(null) }}
onDrop={e => onFolderDrop(e, folder)}
>
<div style={{ height: 100, position: 'relative', overflow: 'hidden' }}>
<FolderCover folder={folder} highlight={isOver} />
</div>
<div style={{ padding: '8px 10px 10px' }}>
<p style={{ margin: 0, fontSize: 12, fontWeight: 500, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{folder.name}
</p>
<p style={{
margin: '2px 0 0', fontSize: 11,
color: isGroupFolder ? 'var(--color-primary)' : 'var(--color-text-muted)',
display: 'flex', alignItems: 'center', gap: 4,
}}>
{isGroupFolder && (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
<circle cx="9" cy="8" r="3.5" />
<path d="M2 21v-1a7 7 0 0114 0v1" />
<path d="M17 11a3 3 0 100-6M19 21v-1a6 6 0 00-3-5.2" />
</svg>
)}
{isGroupFolder ? 'Group folder' : 'Folder'}
</p>
</div>
</div>
)
})}
{files.map(file => (
<div
key={file.id}
draggable
onDragStart={e => {
e.dataTransfer.setData('aether-file-id', file.id)
e.dataTransfer.effectAllowed = 'move'
}}
ref={file.id === highlightId ? highlightRef : undefined}
onDoubleClick={() => { if (!coarse) onFileOpen(file) }}
onClick={e => {
// Am Finger öffnet der Tipp – bzw. schaltet die Auswahl um,
// solange eine läuft. Ohne Strg- und Umschalttaste wäre eine
// Mehrfachauswahl am Telefon sonst gar nicht zu bedienen.
//
// Das steht **hier** und nicht als eigener Handler daneben: In
// JSX gewinnt das zuletzt notierte `onClick`. Ein zweites, per
// Spread eingesetztes wurde von diesem hier stillschweigend
// überschrieben – gemessen: der Tipp auf ein PDF tat nichts,
// während der auf einen Ordner funktionierte.
if (coarse) {
if ((selected?.size ?? 0) > 0) { onToggleSelect?.(file.id, false); return }
onFileOpen(file)
return
}
// **Nur** mit Zusatztaste – der einfache Klick bleibt frei.
//
// Der erste Entwurf wählte auch einfach aus, sobald schon etwas
// markiert war. Das kollidiert mit dem Doppelklick: Dessen erster
// Klick löst dann eine Auswahl aus, und statt die Vorschau zu
// öffnen ändert sich die Markierung. Beim Testen gemessen – aus
// drei Ausgewählten wurden vier, und die Vorschau blieb zu.
//
// Zum Auswählen bleiben das Kästchen und Strg/Umschalt. Das ist
// eindeutig, und der Doppelklick tut wieder genau eine Sache.
if (!onToggleSelect) return
if (e.metaKey || e.ctrlKey || e.shiftKey) {
e.preventDefault()
onToggleSelect(file.id, e.shiftKey)
}
}}
style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 12, overflow: 'hidden', cursor: 'grab',
// boxShadow statt border: Ein dickerer Rahmen würde die Kachel um
// einen Pixel wachsen lassen und die ganze Reihe verschieben.
boxShadow: selected?.has(file.id)
? '0 0 0 2px var(--color-primary)'
: file.id === highlightId ? highlightRing : undefined,
transition: 'border-color 0.15s, transform 0.1s, box-shadow 0.3s',
display: 'flex', flexDirection: 'column',
}}
onContextMenu={e => onFileMenu(e, file)}
onMouseEnter={e => {
e.currentTarget.style.borderColor = 'var(--color-primary)'
e.currentTarget.style.transform = 'translateY(-1px)'
const pick = e.currentTarget.querySelector<HTMLElement>('.aether-pick')
if (pick) pick.style.opacity = '1'
}}
onMouseLeave={e => {
e.currentTarget.style.borderColor = 'var(--color-border)'
e.currentTarget.style.transform = 'none'
const pick = e.currentTarget.querySelector<HTMLElement>('.aether-pick')
if (pick && !selected?.has(file.id) && (selected?.size ?? 0) === 0) pick.style.opacity = '0'
}}
>
<div style={{ height: 100, position: 'relative', background: 'var(--color-surface-2)' }}>
<FileCover file={file} />
{/* Kästchen oben links. Erscheint beim Überfahren oder sobald
irgendetwas ausgewählt ist – dauerhaft sichtbar wäre es an
fünfzig Kacheln nur Unruhe, und die meisten Besuche wählen
nichts aus. */}
{onToggleSelect && (
<button
className="aether-pick"
onClick={e => { e.stopPropagation(); onToggleSelect(file.id, e.shiftKey) }}
title="Select"
style={{
position: 'absolute', top: 6, left: 6,
width: 20, height: 20, borderRadius: 6, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: selected?.has(file.id) ? 'var(--color-primary)' : 'rgba(15,23,42,0.65)',
border: `1px solid ${selected?.has(file.id) ? 'var(--color-primary)' : 'rgba(255,255,255,0.35)'}`,
opacity: selected?.has(file.id) || (selected?.size ?? 0) > 0 ? 1 : 0,
transition: 'opacity 0.12s',
}}
>
{selected?.has(file.id) && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#fff"
strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4 12 10 18 20 6" />
</svg>
)}
</button>
)}
</div>
<div style={{ padding: '8px 10px 10px' }}>
<p style={{ margin: 0, fontSize: 12, fontWeight: 500, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={file.name}>
{file.name}
</p>
{/* Eine Zeile unter dem Namen, zwei Zonen: links **wer**, rechts
**was**. Vorher standen Absender, Größe und Aufkleber
nebeneinander in derselben Reihe – drei Angaben in drei
Schriftgraden auf 130 Pixeln, und jede Kachel brach an einer
anderen Stelle um. Genau das machte das Raster unruhig.
Die Größe weicht dabei nach hinten: Sie steht nur noch da, wo
kein Absender steht (Bilder, Downloads), und sonst in der
Listenansicht und unter „Properties". Beim Überfliegen eines
Rasters sucht man den Absender, nicht die Kilobyte. */}
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
margin: '3px 0 0', minWidth: 0, height: 18,
}}>
{fileCorrespondents?.[file.id] ? (
<span
title={fileCorrespondents[file.id].name}
style={{
fontSize: 11, color: 'var(--color-text-muted)', minWidth: 0,
display: 'flex', alignItems: 'center', gap: 4,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}
>
{fileCorrespondents[file.id].icon && (
<span style={{ flexShrink: 0 }}>{fileCorrespondents[file.id].icon}</span>
)}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
{fileCorrespondents[file.id].name}
</span>
</span>
) : (
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', flexShrink: 0 }}>
{formatBytes(file.size)}
</span>
)}
{(fileTags?.[file.id]?.length ?? 0) > 0 && (
<span style={{
marginLeft: 'auto', minWidth: 0, display: 'flex',
justifyContent: 'flex-end', flexShrink: 0,
}}>
{/* Nur eins, der Rest als „+n": Bei zweien schrumpfen beide
auf „gar…" und „steue…" – zwei unlesbare Aufkleber sagen
weniger als einer lesbarer. Gemessen im Bildschirmfoto. */}
<TagRow tags={fileTags?.[file.id] ?? []} max={1} />
</span>
)}
</div>
</div>
</div>
))}
</div>
)
}
// ── List View ────────────────────────────────────────────────────────────────
export function ListView({ folders, files, onFolderOpen, onFolderMenu, onFileMenu, onFileOpen, onFileDownload, onFolderDrop, dragOverFolder, onFolderDragOver, highlightId, fileTags, fileCorrespondents, selected, onToggleSelect }: ViewProps) {
const highlightRef = useScrollToHighlight(highlightId)
const coarse = useCoarsePointer()
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '4px 12px', marginBottom: 4 }}>
<div style={{ width: 32 }} />
<div style={{ flex: 1, fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Name</div>
<div style={{ width: 80, fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', textAlign: 'right' }}>Size</div>
<div style={{ width: 100, fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', textAlign: 'right' }}>Modified</div>
<div style={{ width: 32 }} />
</div>
{folders.map(folder => {
const isOver = dragOverFolder === folder.id
return (
<div
key={folder.id}
draggable
onDragStart={e => {
e.dataTransfer.setData('aether-folder-id', folder.id)
e.dataTransfer.effectAllowed = 'move'
}}
style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '8px 12px', borderRadius: 8, cursor: 'grab',
background: isOver ? 'rgba(var(--color-primary-rgb), 0.1)' : 'transparent',
outline: isOver ? '2px solid var(--color-primary)' : 'none',
transition: 'background 0.1s',
}}
{...openGesture(coarse, () => onFolderOpen(folder))}
onContextMenu={e => onFolderMenu(e, folder)}
onMouseEnter={e => { if (!isOver) e.currentTarget.style.background = 'var(--color-surface)' }}
onMouseLeave={e => { if (!isOver) e.currentTarget.style.background = 'transparent' }}
onDragOver={e => { e.preventDefault(); e.stopPropagation(); onFolderDragOver(folder.id) }}
onDragLeave={e => { e.stopPropagation(); onFolderDragOver(null) }}
onDrop={e => onFolderDrop(e, folder)}
>
<div style={{ width: 32, display: 'flex', justifyContent: 'center' }}>
<FolderIcon size={24} group={folder.group_id !== null} />
</div>
<div style={{ flex: 1, fontSize: 13, fontWeight: 500, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{folder.name}
</div>
<div style={{ width: 80, fontSize: 12, color: 'var(--color-text-muted)', textAlign: 'right' }}>—</div>
<div style={{ width: 100, fontSize: 12, color: 'var(--color-text-muted)', textAlign: 'right' }}>
{new Date(folder.created_at).toLocaleDateString()}
</div>
<div style={{ width: 32 }} />
</div>
)
})}
{files.map(file => (
<div
key={file.id}
draggable
onDragStart={e => {
e.dataTransfer.setData('aether-file-id', file.id)
e.dataTransfer.effectAllowed = 'move'
}}
ref={file.id === highlightId ? highlightRef : undefined}
style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '8px 12px', borderRadius: 8,
cursor: 'grab', transition: 'background 0.1s, box-shadow 0.3s',
boxShadow: selected?.has(file.id)
? '0 0 0 2px var(--color-primary)'
: file.id === highlightId ? highlightRing : undefined,
}}
onDoubleClick={() => { if (!coarse) onFileOpen(file) }}
onClick={e => {
// Siehe Kachelansicht – ein einziger Handler für beide Geräte.
if (coarse) {
if ((selected?.size ?? 0) > 0) { onToggleSelect?.(file.id, false); return }
onFileOpen(file)
return
}
if (!onToggleSelect) return
if (e.metaKey || e.ctrlKey || e.shiftKey) {
e.preventDefault()
onToggleSelect(file.id, e.shiftKey)
}
}}
onContextMenu={e => onFileMenu(e, file)}
onMouseEnter={e => e.currentTarget.style.background = 'var(--color-surface)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<div
style={{ width: 32, display: 'flex', justifyContent: 'center', flexShrink: 0 }}
onClick={e => {
if (!onToggleSelect) return
e.stopPropagation()
onToggleSelect(file.id, e.shiftKey)
}}
title={onToggleSelect ? 'Select' : undefined}
>
{/* In der Liste ersetzt das Kästchen das Symbol, sobald etwas
ausgewählt ist – in einer Zeile ist kein Platz für beides, und
zwei Spalten nur für den seltenen Fall wären Verschwendung. */}
{onToggleSelect && (selected?.has(file.id) || (selected?.size ?? 0) > 0) ? (
<span style={{
width: 18, height: 18, borderRadius: 5,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: selected?.has(file.id) ? 'var(--color-primary)' : 'transparent',
border: `1px solid ${selected?.has(file.id) ? 'var(--color-primary)' : 'var(--color-border)'}`,
cursor: 'pointer',
}}>
{selected?.has(file.id) && (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#fff"
strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4 12 10 18 20 6" />
</svg>
)}
</span>
) : (
<FileTypeIcon mime={file.mime_type} name={file.name} size={22} />
)}
</div>
{/* Name und Aufkleber teilen sich die Spalte: In einer Zeile ist
kein Platz für eine zweite Zeile darunter, und eine eigene Spalte
stünde bei den meisten Dateien leer. */}
<div style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{
fontSize: 13, color: 'var(--color-text)', minWidth: 0,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}} title={file.name}>
{file.name}
</span>
{(fileTags?.[file.id]?.length ?? 0) > 0 && (
<span style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
{(fileTags?.[file.id] ?? []).slice(0, 2).map(t => (
<TagChip key={t.id} tag={t} size="sm" />
))}
</span>
)}
</div>
{/* Eigene Spalte statt einer zweiten Zeile: In einer Tabelle gibt
es die Spalte ohnehin schon, und eine leere Zelle ändert die
Zeilenhöhe nicht. */}
<div
className="aether-from-col"
title={fileCorrespondents?.[file.id]?.name}
style={{
width: 130, fontSize: 12, color: 'var(--color-text-muted)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}
>
{fileCorrespondents?.[file.id]
? `${fileCorrespondents[file.id].icon} ${fileCorrespondents[file.id].name}`.trim()
: ''}
</div>
<div style={{ width: 80, fontSize: 12, color: 'var(--color-text-muted)', textAlign: 'right' }}>
{formatBytes(file.size)}
</div>
<div style={{ width: 100, fontSize: 12, color: 'var(--color-text-muted)', textAlign: 'right' }}>
{new Date(file.updated_at).toLocaleDateString()}
</div>
<button
onClick={e => { e.stopPropagation(); onFileDownload(file) }}
style={{ width: 32, height: 28, border: 'none', background: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
title="Download"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
</div>
))}
</div>
)
}
+320
View File
@@ -0,0 +1,320 @@
import { useState, useEffect } from 'react'
import { foldersApi, type FolderItem } from '../api/client'
import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
import { FolderGlyph } from './icons'
interface Props {
excludeId?: string
onConfirm: (targetFolderId: string | null) => void
onCancel: () => void
title?: string
}
interface TreeNode {
folder: FolderItem
children: TreeNode[] | null
expanded: boolean
}
function buildNode(folder: FolderItem): TreeNode {
return { folder, children: null, expanded: false }
}
function deepClone(nodes: TreeNode[]): TreeNode[] {
return nodes.map(n => ({
...n,
children: n.children ? deepClone(n.children) : n.children,
}))
}
function getNode(nodes: TreeNode[], path: number[]): TreeNode | null {
let current: TreeNode | null = null
let list = nodes
for (const idx of path) {
current = list[idx] ?? null
if (!current) return null
list = current.children ?? []
}
return current
}
function findLabel(nodes: TreeNode[], id: string): string {
for (const n of nodes) {
if (n.folder.id === id) return n.folder.name
if (n.children) {
const found = findLabel(n.children, id)
if (found) return found
}
}
return ''
}
export default function MoveTo({ excludeId, onConfirm, onCancel, title = 'Move to…' }: Props) {
const overlay = useOverlayDismiss(onCancel)
const [roots, setRoots] = useState<TreeNode[]>([])
const [selected, setSelected] = useState<string | null | undefined>(undefined)
const [loading, setLoading] = useState(true)
useEffect(() => {
foldersApi.list(undefined)
.then(folders => setRoots(folders.filter(f => f.id !== excludeId).map(buildNode)))
.catch(() => {})
.finally(() => setLoading(false))
}, [excludeId])
async function toggleExpand(path: number[]) {
const node = getNode(roots, path)
if (!node) return
// Load children on first expand
if (node.children === null) {
const children = await foldersApi.list(node.folder.id)
setRoots(prev => {
const next = deepClone(prev)
const n = getNode(next, path)
if (!n) return prev
n.children = children.filter(f => f.id !== excludeId).map(buildNode)
n.expanded = true
return next
})
} else {
setRoots(prev => {
const next = deepClone(prev)
const n = getNode(next, path)
if (!n) return prev
n.expanded = !n.expanded
return next
})
}
}
const selectedLabel = selected === undefined ? null
: selected === null ? 'Home'
: findLabel(roots, selected)
return (
<div
style={{
position: 'fixed', inset: 0, zIndex: 300,
background: 'rgba(0,0,0,0.55)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 24,
}}
{...overlay}
>
<div style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 16,
width: '100%', maxWidth: 520,
boxShadow: '0 12px 40px rgba(0,0,0,0.3)',
display: 'flex', flexDirection: 'column',
maxHeight: '82vh',
}}>
{/* Header */}
<div style={{ padding: '18px 22px 14px', borderBottom: '1px solid var(--color-border)', flexShrink: 0 }}>
<h3 style={{ margin: 0, fontSize: 17, fontWeight: 700, color: 'var(--color-text)' }}>{title}</h3>
<p style={{ margin: '4px 0 0', fontSize: 12, color: 'var(--color-text-muted)' }}>
Choose destination
</p>
</div>
{/* Tree */}
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 0' }}>
{/* Home row */}
<LocationRow
label="Home"
depth={0}
selected={selected === null}
onSelect={() => setSelected(null)}
icon={
// Dasselbe Haus wie in der Brotkrume und der Seitenleiste.
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 10.5L12 3l9 7.5" />
<path d="M5.5 9.5V20a1 1 0 001 1h11a1 1 0 001-1V9.5" />
</svg>
}
/>
{loading && (
<p style={{ margin: '10px 22px', fontSize: 13, color: 'var(--color-text-muted)' }}>Loading…</p>
)}
{!loading && roots.length === 0 && (
<p style={{ margin: '10px 22px', fontSize: 13, color: 'var(--color-text-muted)' }}>No folders yet</p>
)}
{!loading && roots.map((node, i) => (
<TreeRow
key={node.folder.id}
node={node}
path={[i]}
depth={1}
selected={selected}
onSelect={setSelected}
onToggle={toggleExpand}
/>
))}
</div>
{/* Footer */}
<div style={{
padding: '14px 22px',
borderTop: '1px solid var(--color-border)',
display: 'flex', gap: 10, alignItems: 'center', flexShrink: 0,
}}>
{selectedLabel !== null ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" strokeWidth="2.5">
<polyline points="9 18 15 12 9 6" />
</svg>
<span style={{ fontSize: 13, color: 'var(--color-text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{selectedLabel}
</span>
</div>
) : <div style={{ flex: 1 }} />}
<button
onClick={onCancel}
style={{
padding: '8px 18px', borderRadius: 9, border: '1px solid var(--color-border)',
background: 'transparent', color: 'var(--color-text-muted)', fontSize: 13, cursor: 'pointer',
}}
>
Cancel
</button>
<button
disabled={selected === undefined}
onClick={() => selected !== undefined && onConfirm(selected)}
style={{
padding: '8px 20px', borderRadius: 9, border: 'none',
background: selected !== undefined ? 'var(--color-primary)' : 'var(--color-surface-2)',
color: selected !== undefined ? '#fff' : 'var(--color-text-muted)',
fontSize: 13, fontWeight: 600,
cursor: selected !== undefined ? 'pointer' : 'default',
transition: 'background 0.15s',
}}
>
Move here
</button>
</div>
</div>
</div>
)
}
// ── Row components ────────────────────────────────────────────────────────────
interface LocationRowProps {
label: string
depth: number
selected: boolean
onSelect: () => void
icon: React.ReactNode
}
function LocationRow({ label, depth, selected, onSelect, icon }: LocationRowProps) {
return (
<button
onClick={onSelect}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
padding: `9px 22px 9px ${22 + depth * 20}px`,
border: 'none', cursor: 'pointer', textAlign: 'left',
background: selected ? 'rgba(var(--color-primary-rgb), 0.12)' : 'transparent',
color: selected ? 'var(--color-primary)' : 'var(--color-text)',
fontSize: 14, fontWeight: selected ? 600 : 400,
borderLeft: selected ? '3px solid var(--color-primary)' : '3px solid transparent',
transition: 'background 0.1s',
}}
onMouseEnter={e => { if (!selected) e.currentTarget.style.background = 'var(--color-surface-2)' }}
onMouseLeave={e => { if (!selected) e.currentTarget.style.background = 'transparent' }}
>
<span style={{ color: selected ? 'var(--color-primary)' : 'var(--color-text-muted)', flexShrink: 0, display: 'flex' }}>
{icon}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
</button>
)
}
interface TreeRowProps {
node: TreeNode
path: number[]
depth: number
selected: string | null | undefined
onSelect: (id: string) => void
onToggle: (path: number[]) => void
}
function TreeRow({ node, path, depth, selected, onSelect, onToggle }: TreeRowProps) {
const isSelected = selected === node.folder.id
const hasChildren = node.children === null || node.children.length > 0
return (
<>
<div style={{ display: 'flex', alignItems: 'center' }}>
{/* Chevron toggle */}
<button
onClick={() => onToggle(path)}
style={{
width: 28, height: 36, flexShrink: 0,
marginLeft: 22 + (depth - 1) * 20,
background: 'none', border: 'none',
cursor: hasChildren ? 'pointer' : 'default',
color: 'var(--color-text-muted)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
opacity: hasChildren ? 1 : 0,
}}
>
<svg
width="11" height="11" viewBox="0 0 24 24"
fill="none" stroke="currentColor" strokeWidth="2.5"
style={{ transform: node.expanded ? 'rotate(90deg)' : 'none', transition: 'transform 0.15s' }}
>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
{/* Folder name */}
<button
onClick={() => onSelect(node.folder.id)}
style={{
flex: 1, display: 'flex', alignItems: 'center', gap: 8,
padding: '9px 22px 9px 6px',
border: 'none', cursor: 'pointer', textAlign: 'left',
background: isSelected ? 'rgba(var(--color-primary-rgb), 0.12)' : 'transparent',
color: isSelected ? 'var(--color-primary)' : 'var(--color-text)',
fontSize: 14, fontWeight: isSelected ? 600 : 400,
borderLeft: isSelected ? '3px solid var(--color-primary)' : '3px solid transparent',
transition: 'background 0.1s',
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--color-surface-2)' }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}
>
<span style={{ flexShrink: 0, display: 'flex' }}>
<FolderGlyph
size={20}
color={node.folder.group_id !== null ? 'rgb(var(--color-primary-rgb))' : '#f59e0b'}
/>
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{node.folder.name}
</span>
</button>
</div>
{node.expanded && node.children && node.children.map((child, i) => (
<TreeRow
key={child.folder.id}
node={child}
path={[...path, i]}
depth={depth + 1}
selected={selected}
onSelect={onSelect}
onToggle={onToggle}
/>
))}
</>
)
}
+137
View File
@@ -0,0 +1,137 @@
import { useEffect, useRef, useState } from 'react'
import { officeApi, type FileItem, type OfficeSession } from '../api/client'
/**
* Ein Dokument im Office-Server bearbeiten.
*
* Die Bedienoberfläche kommt vom Office-Server und läuft in einem Rahmen. Wir
* schicken sie **per Formular** dorthin, nicht über die Adresszeile: Das
* Zugriffstoken gehört in den Rumpf einer POST-Anfrage und nicht in eine URL,
* die im Verlauf des Browsers, im Log des Proxys und im Referrer landet.
*
* Gespeichert wird nicht hier. Der Office-Server holt die Datei selbst beim
* Backend ab und schreibt sie ebenso zurück (siehe internal/office). Dieser
* Rahmen weiß davon nichts – er zeigt nur an.
*/
export default function OfficeEditor({ file, onClose }: {
file: FileItem
onClose: () => void
}) {
const [session, setSession] = useState<OfficeSession | null>(null)
const [error, setError] = useState('')
const formRef = useRef<HTMLFormElement>(null)
useEffect(() => {
let alive = true
officeApi.session(file.id)
.then(s => {
if (!alive) return
if (!s.enabled) { setError('Office editing is switched off on this server.'); return }
setSession(s)
})
.catch(err => {
if (!alive) return
setError(err instanceof Error ? err.message : 'Could not open the editor')
})
return () => { alive = false }
}, [file.id])
// Das Formular abschicken, sobald es steht. Ein useEffect statt eines
// autoSubmit-Attributs: HTML kennt keines, und ein Klick des Nutzers wäre
// ein Schritt, der nichts entscheidet.
useEffect(() => { if (session) formRef.current?.submit() }, [session])
useEffect(() => {
function onKey(e: KeyboardEvent) {
// Escape schließt nur, solange der Rahmen nicht den Tastendruck hat –
// sonst nähme man dem Editor seine eigene Escape-Taste weg.
if (e.key === 'Escape' && document.activeElement?.tagName !== 'IFRAME') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 120,
background: 'var(--color-background)', display: 'flex', flexDirection: 'column',
}}>
<div style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 14px', flexShrink: 0,
background: 'var(--color-surface)',
borderBottom: '1px solid var(--color-border)',
}}>
<span style={{
flex: 1, minWidth: 0, fontSize: 14, color: 'var(--color-text)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{file.name}
</span>
{session && !session.can_write && (
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>read only</span>
)}
{/* Kein eigener Speichern-Knopf: Der Office-Server speichert selbst,
und zwei Knöpfe für dieselbe Sache – von denen einer nichts weiß –
wären schlimmer als keiner. */}
<button
onClick={onClose}
title="Close (Esc)"
style={{
padding: 6, borderRadius: 8, border: 'none', background: 'none',
color: 'var(--color-text-muted)', cursor: 'pointer', display: 'flex',
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
{error && (
<div style={{
position: 'absolute', inset: 0, display: 'flex',
alignItems: 'center', justifyContent: 'center', padding: 24,
color: 'var(--color-text-muted)', fontSize: 13, textAlign: 'center',
}}>
{error}
</div>
)}
{!error && !session && (
<div style={{
position: 'absolute', inset: 0, display: 'flex',
alignItems: 'center', justifyContent: 'center',
color: 'var(--color-text-muted)', fontSize: 13,
}}>
Starting the editor…
</div>
)}
{session && (
<>
<form
ref={formRef}
action={session.url}
method="post"
target="aether-office"
style={{ display: 'none' }}
>
<input type="hidden" name="access_token" value={session.token} />
<input type="hidden" name="access_token_ttl" value={String(session.token_ttl)} />
</form>
<iframe
name="aether-office"
title={file.name}
style={{ width: '100%', height: '100%', border: 'none', background: '#fff' }}
// Der Office-Server braucht Skripte, Formulare und eigene
// Fenster (Druckvorschau). „allow-same-origin" ist nötig, damit
// er seine eigene Sitzung halten kann – er läuft ohnehin in
// einem fremden Ursprung, also nicht in unserem.
sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-downloads allow-modals"
/>
</>
)}
</div>
</div>
)
}
+193
View File
@@ -0,0 +1,193 @@
import type { ReactNode } from 'react'
import UserMenu, { useSidebarToggle } from './UserMenu'
/**
* Die Kopfleiste jeder Ansicht.
*
* Vorher gab es zwei Muster nebeneinander: Dateien, Papierkorb und
* Freigabe-Links hatten eine feste Leiste, „Geteilt mit mir", Admin und Profil
* dagegen eine große Überschrift im Scrollbereich. Beim Umschalten sprang
* dadurch der Inhalt – und schon zwischen den drei Leisten war die Höhe
* unterschiedlich, weil sie sich aus dem jeweils höchsten Bedienelement ergab.
*
* Deshalb hier eine feste Höhe statt einer gewachsenen: Die Leiste ist überall
* gleich hoch, egal was darin steht.
*/
export const headerHeight = 57
/**
* Ab dieser Breite passt alles in eine Zeile.
*
* Darunter wird umgebrochen statt gequetscht: Auf 390 px stand die Brotkrume
* als einzelner Strich da, das Suchfeld schob sich über die Sortierauswahl, der
* Ansichts-Umschalter war ganz verschwunden und der Nutzerbereich am rechten
* Rand abgeschnitten. Eine Zeile, die nicht umbrechen kann, quetscht – und
* ausgerechnet auf dem Gerät, mit dem man unterwegs einen Beleg sucht.
*/
export const wideEnough = '(min-width: 720px)'
interface Props {
/** Strichsymbol, 18 × 18 – dasselbe wie in der Seitenleiste. */
icon?: ReactNode
title?: string
/** Nebenangabe in gedämpfter Schrift, etwa „12 items". */
meta?: ReactNode
/** Statt Symbol und Titel: eigener Inhalt links, z. B. die Brotkrumen. */
left?: ReactNode
/** Bedienelemente – sitzen rechts. */
children?: ReactNode
}
export default function PageHeader({ icon, title, meta, left, children }: Props) {
const openSidebar = useSidebarToggle()
return (
<div className="aether-header" style={{
// Feste Höhe nur auf breiten Schirmen (siehe CSS unten). Auf dem Handy
// wächst die Leiste auf zwei Zeilen, statt ihren Inhalt zu verstecken.
padding: '0 14px',
// Statusleiste der installierten App. Die Kopfleiste ist jetzt die
// oberste Zeile der Anwendung – vorher lag darüber noch eine zweite.
paddingTop: 'var(--safe-top)',
background: 'var(--color-surface)',
borderBottom: '1px solid var(--color-border)',
display: 'flex', alignItems: 'center', gap: 10,
flexShrink: 0,
minWidth: 0,
flexWrap: 'wrap',
}}>
{/* Die Seitenleiste wird auf dem Handy von hier geöffnet.
Vorher stand dafür eine **zweite** Leiste darüber, die nichts weiter
enthielt als dieses Symbol und den Namen der Cloud. Gemessen kostete
sie 57 der 915 Pixel Höhe – und der Name stand ohnehin schon in der
Seitenleiste, die sie öffnete. */}
{openSidebar && (
<button
className="aether-header-menu"
onClick={openSidebar}
title="Menu"
aria-label="Menu"
style={{
// Kein `display` hier: Das schaltet die Breite unten um, und ein
// Inline-Stil schlägt jede Klasse. Genau daran war der Knopf schon
// auf dem Desktop zu sehen, obwohl die Regel ihn ausblendet.
padding: 6, marginLeft: -6, borderRadius: 8, flexShrink: 0,
color: 'var(--color-text-muted)', background: 'none',
border: 'none', cursor: 'pointer',
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
)}
{left ?? (
<>
{icon && (
// strokeLinecap/-join hier statt an jedem Symbol: Beide Eigenschaften
// vererben sich, und ein Symbol ohne eigene Angabe übernimmt sie
// damit. Vorher hatten Papierkorb und Freigabe-Links eckige Enden,
// „Geteilt mit mir" runde – nebeneinander wirkt das unruhig.
<span style={{
display: 'flex', color: 'var(--color-text-muted)', flexShrink: 0,
strokeLinecap: 'round', strokeLinejoin: 'round',
}}>
{icon}
</span>
)}
{title && (
<span style={{ fontSize: 15, fontWeight: 600, color: 'var(--color-text)', flexShrink: 0 }}>
{title}
</span>
)}
</>
)}
{meta && (
<span style={{
fontSize: 12, color: 'var(--color-text-muted)', minWidth: 0,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{meta}
</span>
)}
{/* Rechte Gruppe: erst die Bedienelemente der Ansicht, dann – durch eine
Trennlinie abgesetzt – der Nutzerbereich. Er steht bewusst in *jeder*
Ansicht an derselben Stelle; ein Zugang, den man je nach Seite
woanders sucht, ist keiner. */}
<div className="aether-header-actions" style={{
display: 'flex', alignItems: 'center', gap: 8, minWidth: 0,
}}>
{children}
</div>
{/* Der Nutzerbereich steht **außerhalb** der Bedienelemente.
Auf dem Handy gehört er in die Titelzeile nach rechts – dorthin, wo
man den eigenen Zugang sucht. Vorher stand er in der Reihe mit
„+ Folder" und „Upload" und sah dort aus wie ein weiterer Knopf für
eine Handlung, die er nicht ist.
Umgestellt wird über `order`, nicht über einen zweiten Baum: Zwei
Nutzermenüs nebeneinander hießen zwei Klappmenüs, von denen eines
immer versteckt ist. */}
<div className="aether-header-user" style={{
display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0,
}}>
{children && (
<span className="aether-header-divider" style={{
width: 1, height: 22, background: 'var(--color-border)',
margin: '0 2px', flexShrink: 0,
}} />
)}
<UserMenu />
</div>
<style>{`
.aether-header { min-height: ${headerHeight - 1}px; }
/* Breit: erst die Bedienelemente der Ansicht, dann – durch eine
Trennlinie abgesetzt – der Nutzerbereich, ganz rechts. */
@media ${wideEnough} {
.aether-header-menu { display: none; }
.aether-header-actions { order: 2; margin-left: auto; }
.aether-header-user { order: 3; }
}
@media not all and ${wideEnough} {
.aether-header {
padding-top: calc(8px + var(--safe-top));
padding-bottom: 8px;
}
/* Schmal: Nutzerbereich noch in der Titelzeile ganz rechts, die
Bedienelemente darunter über die volle Breite.
Die Umstellung per order ist hier nicht Zierde: Im Baum steht der Nutzerbereich
**hinter** den Bedienelementen, und die nehmen sich schmal die
volle Breite. Ohne die Umstellung landete er dahinter – also in
einer dritten Zeile, allein und rechts. Genau so sah es aus. */
.aether-header-menu { display: flex; }
.aether-header-user { order: 1; margin-left: auto; }
.aether-header-divider { display: none; }
/* Nichts wird versteckt – man kommt an alles heran, es steht nur
untereinander. */
.aether-header-actions {
order: 2;
width: 100%;
flex-wrap: wrap;
}
/* Das Suchfeld nimmt die Zeile für sich – 220px feste Breite waren
der Grund, warum daneben nichts mehr passte. */
.aether-header-actions input[type="text"],
.aether-header-actions input:not([type]) { width: 100% !important; }
}
@media ${wideEnough} {
.aether-header { height: ${headerHeight - 1}px; padding-left: 20px; padding-right: 20px; }
}
`}</style>
</div>
)
}
+957
View File
@@ -0,0 +1,957 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { filesApi } from '../api/client'
import * as pdfjs from 'pdfjs-dist'
import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist'
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
import './pdf.css'
/**
* Eigener PDF-Betrachter.
*
* Vorher stand hier ein <iframe> mit dem eingebauten Betrachter des Browsers.
* Der ist gut, aber er gehört uns nicht – wir können ihm nichts sagen und
* nichts von ihm erfahren. Zwei Gründe haben den Wechsel getragen:
*
* 1. **Am Handy.** Chrome auf Android zeigt ein PDF in einem <iframe> nicht
* an, sondern bietet es zum Herunterladen an. Für ein Archiv, in das man
* beim Reklamieren im Laden hineinsieht, war die Vorschau dort also
* schlicht nicht vorhanden.
* 2. **Der Suchtreffer.** Die Volltextsuche findet ein Wort auf Seite 7 eines
* Scans – der fremde Betrachter beginnt trotzdem auf Seite 1, und man
* sucht von Hand weiter. Ihm lässt sich kein Suchwort vorgeben.
*
* Was er absichtlich *nicht* kann: Anmerkungen, Formulare, Unterschriften.
* Das Original wird in dieser Anwendung nie verändert – ein Betrachter, der
* schreiben kann, wäre ein Widerspruch dazu.
*/
// Der Arbeiter kommt gebündelt aus node_modules und wird vom eigenen Server
// ausgeliefert. Kein CDN: Die Sicherheitsrichtlinie erlaubt nur 'self', und
// eine Cloud für private Unterlagen soll beim Öffnen eines Dokuments keine
// fremde Adresse anfragen.
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl
/**
* Die 14 Standardschriften (Helvetica, Times, Courier …). Ältere PDFs betten
* sie nicht ein, sondern setzen sie voraus. Fehlen sie, malt PDF.js mit einer
* Ersatzschrift – die Seite ist lesbar, aber Zeilen brechen an anderer Stelle.
* Kopiert wird der Ordner beim Bauen, siehe vite.config.ts.
*/
const standardFontDataUrl = '/pdfjs/standard_fonts/'
/** Zoomstufen des Knopfes. „Fit" liegt außerhalb und wird gerechnet. */
const zoomSteps = [0.5, 0.75, 1, 1.25, 1.5, 2, 3]
/**
* Innenabstand der Seitenliste. Am Telefon fast keiner: Dort ist die Breite
* das knappe Gut, und ein 16-Pixel-Rand links und rechts kostet zusammen mehr
* als ein Zehntel der Darstellungsgröße.
*/
function padOf(width: number): number {
return width < 560 ? 4 : 16
}
/**
* Ab welcher Breite die Leiste als Dreispaltenraster taugt.
*
* Gerechnet, nicht geschätzt: Die Steuerung links braucht 275 Pixel
* (zwei Knöpfe, Seitenzahl, Trenner, Zoom), die Suche in der Mitte 492 – und
* die rechte Spalte ist genauso breit wie die linke, sonst säße die Suche
* nicht mittig. Macht 2 × 275 + 492.
*
* **Vorher stand hier 720, und dazwischen war die Leiste kaputt:** Das Raster
* gab der linken Spalte weniger, als ihr Inhalt braucht, und die Knöpfe
* schrumpften mit. Gemessen bei 980 Pixeln Fensterbreite – erster Knopf 16
* statt 28 Pixel, bei 760 die ganze Gruppe auf 108 statt 275. Am Ende war von
* der Steuerung nichts mehr zu sehen.
*/
const minGridWidth = 2 * 275 + 492
/**
* Breiten rechts vom Suchfeld – und damit das Maß für das Gegengewicht links.
* Beide Seiten gleich breit heißt: Das Feld sitzt in der Mitte der Leiste.
*/
const counterWidth = 54
const hitButtonsWidth = 60
/** Zähler + Knöpfe + die beiden Abstände dazwischen. */
const hitsWidth = counterWidth + hitButtonsWidth + 6
/** Abstand zwischen zwei Seiten, in Pixeln. Auch der Wert, mit dem gerechnet
* wird, wenn die sichtbare Seite bestimmt wird. */
const pageGap = 12
interface Props {
/** Adresse der Datei – dieselbe, die auch das <iframe> bekam. */
url: string
/** ID derselben Datei – für den erkannten Text vom Server. */
fileId: string
/**
* Suchwort aus der Volltextsuche. Ist es gesetzt, springt der Betrachter
* beim Öffnen zur ersten Fundstelle, statt auf Seite 1 zu beginnen.
*/
searchTerm?: string
}
/**
* Eine Fundstelle: Seite und die wievielte auf dieser Seite.
*
* `exact` unterscheidet die beiden Quellen. Bringt das PDF eigenen Text mit,
* steht die Stelle im Dokument und lässt sich einfärben. Bei einem **Scan**
* steht dort nur ein Bild; der Text kommt dann aus der Erkennung auf dem
* Server, und der weiß zwar die Seite, aber nicht den Ort auf dem Blatt –
* dafür müssten die Koordinaten der Erkennung aufbewahrt werden.
*/
interface Hit { page: number; nth: number; exact: boolean }
export default function PdfViewer({ url, fileId, searchTerm }: Props) {
const [doc, setDoc] = useState<PDFDocumentProxy | null>(null)
const [error, setError] = useState('')
const [sizes, setSizes] = useState<{ w: number; h: number }[]>([])
const [scale, setScale] = useState<number | 'fit'>('fit')
const [fitScale, setFitScale] = useState(1)
const [current, setCurrent] = useState(1)
const [query, setQuery] = useState(searchTerm ?? '')
const [hits, setHits] = useState<Hit[]>([])
const [hitIndex, setHitIndex] = useState(0)
const [searching, setSearching] = useState(false)
/** Erkannter Text vom Server, einmal je Datei geholt. Ohne den Zwischen-
* speicher fragte jeder Tastendruck im Suchfeld erneut an. */
const serverText = useRef<string[] | null>(null)
useEffect(() => { serverText.current = null }, [fileId])
const scrollRef = useRef<HTMLDivElement>(null)
const pageRefs = useRef<(HTMLDivElement | null)[]>([])
const effScale = scale === 'fit' ? fitScale : scale
// ---------------------------------------------------------------- Laden
useEffect(() => {
let alive = true
let task: ReturnType<typeof pdfjs.getDocument> | null = null
setDoc(null); setSizes([]); setError(''); setCurrent(1)
task = pdfjs.getDocument({
url,
// Same-Origin, die Sitzung steckt im Cookie – ohne das käme 401.
withCredentials: true,
standardFontDataUrl,
})
task.promise.then(
async d => {
// Beim Verlassen raeumt die Aufraeumfunktion die Ladeaufgabe ab; die
// nimmt Arbeiter und Dokument mit. Hier genuegt es, nichts mehr zu
// setzen.
if (!alive) return
// Die Maße *aller* Seiten vorab: Sonst müsste die Liste mit einer
// geschätzten Höhe beginnen und beim Zeichnen springen. Ein Dokument
// mit gemischtem Hoch- und Querformat ist bei Scans der Normalfall.
const s: { w: number; h: number }[] = []
for (let i = 1; i <= d.numPages; i++) {
const v = (await d.getPage(i)).getViewport({ scale: 1 })
s.push({ w: v.width, h: v.height })
}
if (!alive) return
setSizes(s)
setDoc(d)
},
(err: unknown) => {
if (!alive) return
setError(
err instanceof Error && /password/i.test(err.message)
? 'This PDF is password protected.'
: 'Could not open this PDF.',
)
},
)
return () => {
alive = false
task?.destroy()
}
}, [url])
// ------------------------------------------------------- Breite einpassen
/**
* „Fit" heißt: **auf die Breite, aber nie größer als das Original.**
*
* Zwei Anläufe, beide falsch, beide erst am echten Dokument sichtbar:
*
* 1. *Nur auf die Breite.* Die Vorschau ist fast bildschirmbreit, ein
* A4-Blatt füllte sie bei 199 % – man sah das obere Drittel.
* 2. *Auf die ganze Seite, also gegen beide Kanten.* Klingt richtig und
* ist es für A4 auch. Bei einem **langen** Blatt – ein Merkzettel, eine
* Rechnung mit vierzig Posten auf einer Seite – kam dabei **10 %**
* heraus: Schriftgröße 1,4 Pixel. Gemessen an einem echten Dokument.
*
* Die Höhe taugt also nicht als Grenze, weil eine Seite beliebig lang sein
* darf. Die Breite taugt, denn sie ist bei jedem Papier ähnlich. Bleibt das
* Aufblähen zu verhindern: Über 100 % wird nichts vergrößert – das Original
* ist so gesetzt, dass man es lesen kann.
*
* Am Telefon greift die Grenze von selbst nicht: Dort ist der Bildschirm
* schmaler als das Blatt, die Breite also unter 100 %.
*
* Gerechnet wird gegen die **häufigste** Seitenbreite, nicht gegen die
* breiteste – warum, steht bei `commonWidth`.
*/
const recomputeFit = useCallback(() => {
const el = scrollRef.current
if (!el || sizes.length === 0) return
// Eine Messung von (fast) null ist keine Messung, sondern ein Zeitpunkt:
// Der Beobachter meldet sich auch, während die Vorschau noch aufgeht und
// der Bereich keine Breite hat. Früher lieferte das die Untergrenze von
// 10 % – und weil danach keine Größenänderung mehr kam, blieb das Dokument
// dabei stehen. Gemessen an einem Dokument, das mal mit 96 %, mal mit
// 10 % aufging, je nachdem wie schnell der Rechner gerade war.
if (el.clientWidth < 50) return
// 16 für die Bildlaufleiste, die je nach Browser Breite wegnimmt.
const byWidth = (el.clientWidth - 2 * padOf(el.clientWidth) - 16) / commonWidth(sizes)
setFitScale(Math.max(0.1, Math.min(1, byWidth)))
}, [sizes])
useEffect(() => {
recomputeFit()
const el = scrollRef.current
if (!el) return
const ro = new ResizeObserver(recomputeFit)
ro.observe(el)
return () => ro.disconnect()
}, [recomputeFit])
// ------------------------------------------------------------- Zeichnen
/**
* Gezeichnet wird nur, was in die Nähe des Sichtfelds kommt.
*
* Ein 200-seitiger Scan hätte sonst 200 Leinwände im Speicher – bei A4 und
* doppelter Pixeldichte sind das je rund 8 MB. Der Beobachter meldet
* Seiten eine Bildschirmhöhe im Voraus an; wieder verlassene Seiten geben
* ihre Leinwand frei.
*/
useEffect(() => {
if (!doc || sizes.length === 0) return
const root = scrollRef.current
if (!root) return
// Läuft eine Zeichnung noch, wenn sich der Maßstab ändert, muss sie
// abgebrochen werden – sonst malt sie in der alten Größe fertig.
const running = new Map<number, ReturnType<PDFPageProxy['render']>>()
const drawn = new Set<number>()
async function draw(n: number) {
const host = pageRefs.current[n - 1]
if (!doc || !host || drawn.has(n)) return
drawn.add(n)
const page = await doc.getPage(n)
const viewport = page.getViewport({ scale: effScale })
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const canvas = document.createElement('canvas')
canvas.width = Math.floor(viewport.width * dpr)
canvas.height = Math.floor(viewport.height * dpr)
canvas.style.width = '100%'
canvas.style.height = '100%'
canvas.style.display = 'block'
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.scale(dpr, dpr)
const task = page.render({ canvas, canvasContext: ctx, viewport })
running.set(n, task)
try {
await task.promise
} catch {
drawn.delete(n) // abgebrochen – beim nächsten Mal neu
return
} finally {
running.delete(n)
}
host.querySelector('canvas')?.remove()
host.prepend(canvas)
// Textebene darüber: unsichtbare, aber markierbare Schrift. Sie ist es
// auch, in der die Suche ihre Fundstellen färbt.
const layer = host.querySelector<HTMLDivElement>('.pdf-textLayer')
if (layer) {
layer.replaceChildren()
const tl = new pdfjs.TextLayer({
textContentSource: await page.getTextContent(),
container: layer,
viewport,
})
await tl.render()
host.dataset.text = 'ready'
host.dispatchEvent(new CustomEvent('pdf-text-ready', { bubbles: true }))
}
}
function release(n: number) {
running.get(n)?.cancel()
running.delete(n)
drawn.delete(n)
const host = pageRefs.current[n - 1]
if (!host) return
host.querySelector('canvas')?.remove()
host.querySelector('.pdf-textLayer')?.replaceChildren()
host.querySelector('.pdf-hitLayer')?.replaceChildren()
delete host.dataset.text
}
const io = new IntersectionObserver(entries => {
for (const e of entries) {
const n = Number((e.target as HTMLElement).dataset.page)
if (e.isIntersecting) void draw(n)
else release(n)
}
}, { root, rootMargin: '100% 0px' })
for (const host of pageRefs.current) if (host) io.observe(host)
return () => {
io.disconnect()
for (const t of running.values()) t.cancel()
}
}, [doc, sizes, effScale])
// --------------------------------------------------- Sichtbare Seitenzahl
useEffect(() => {
const root = scrollRef.current
if (!root || sizes.length === 0) return
function onScroll() {
const mid = root!.scrollTop + root!.clientHeight / 2
let acc = 0
for (let i = 0; i < sizes.length; i++) {
const h = sizes[i].h * effScale + pageGap
if (mid < acc + h) { setCurrent(i + 1); return }
acc += h
}
setCurrent(sizes.length)
}
root.addEventListener('scroll', onScroll, { passive: true })
onScroll()
return () => root.removeEventListener('scroll', onScroll)
}, [sizes, effScale])
/**
* Zu einer Seite fahren – **mit** dem Rand darüber.
*
* Vorher stand hier `scrollIntoView({ block: 'start' })`. Das legt die
* Oberkante der Seite genau auf die Oberkante des Rollbereichs und frisst
* dabei den Innenabstand auf. Sichtbar wurde das beim Suchen: Man tippt,
* und das Blatt rutscht um 16 Pixel nach oben, klebt am Fensterrand – ein
* Ruck, für den es keinen Grund gibt.
*
* Jetzt wird von Hand gerollt und der Rand abgezogen. Die Seite steht dann
* dort, wo sie auch beim Öffnen steht.
*/
const goToPage = useCallback((n: number) => {
const root = scrollRef.current
const host = pageRefs.current[n - 1]
if (!root || !host) return
const r = root.getBoundingClientRect()
const h = host.getBoundingClientRect()
root.scrollTo({ top: Math.max(0, root.scrollTop + (h.top - r.top) - padOf(root.clientWidth)) })
}, [])
/**
* Zu einer Fundstelle rollen – **nur wenn nötig**.
*
* Der zweite Teil desselben Rucks: Jeder Tastendruck im Suchfeld ließ die
* Anzeige zur Fundstelle springen, auch wenn die längst zu sehen war. Wer
* „la" tippt, hat nach dem ersten Buchstaben schon eine andere Trefferliste,
* und das Blatt zuckte bei jedem Zeichen.
*
* Steht der Treffer im mittleren Bereich, passiert jetzt gar nichts.
*/
const revealHit = useCallback((el: Element) => {
const root = scrollRef.current
if (!root) return
const r = root.getBoundingClientRect()
const e = el.getBoundingClientRect()
const margin = 60
if (e.top >= r.top + margin && e.bottom <= r.bottom - margin) return
root.scrollTo({ top: Math.max(0, root.scrollTop + (e.top - r.top) - root.clientHeight / 3) })
}, [])
// ----------------------------------------------------------------- Suche
/**
* Gesucht wird im *ganzen* Dokument auf einmal, nicht nur auf der
* gezeichneten Seite: Nur so lässt sich „3 von 7" anzeigen und gezielt
* weiterspringen. Der Text kommt dabei nicht aus der Datenbank, sondern
* aus dem PDF selbst – nur dort steht, an welcher *Stelle* er steht.
*/
useEffect(() => {
const q = query.trim().toLowerCase()
if (!doc || q.length < 2) { setHits([]); setHitIndex(0); return }
let alive = true
setSearching(true)
;(async () => {
const found: Hit[] = []
for (let n = 1; n <= doc.numPages; n++) {
if (!alive) return
const tc = await doc.getPage(n).then(p => p.getTextContent())
// Zusammengesetzt wie in der Anzeige, damit ein über zwei Stücke
// getrenntes Wort trotzdem zählt.
const text = tc.items
.map(it => ('str' in it ? it.str : ''))
.join('')
.toLowerCase()
found.push(...countIn(text, q, n, true))
}
if (!alive) return
// Nichts gefunden? Dann liegt hier womöglich ein **Scan**, in dem gar
// kein Text steht. Der erkannte Text vom Server ist die einzige Quelle,
// die dann etwas weiß – ausgerechnet für den Dateityp, für den die
// Texterkennung überhaupt gebaut wurde.
//
// Nur als Rückfall, nicht immer: Steht der Text im PDF, ist er dort
// genauer verortet, und die Fundstelle lässt sich einfärben.
if (found.length === 0) {
try {
if (!serverText.current) {
serverText.current = (await filesApi.textPages(fileId)).pages
}
if (!alive) return
serverText.current.forEach((page, i) => {
found.push(...countIn(page.toLowerCase(), q, i + 1, false))
})
} catch {
// Kein Text vorhanden oder Erkennung abgeschaltet: dann eben keine
// Treffer. Eine Fehlermeldung wäre hier nur im Weg.
}
}
if (!alive) return
setHits(found)
setHitIndex(0)
setSearching(false)
})()
return () => { alive = false; setSearching(false) }
}, [doc, query, fileId])
/**
* Färbt die Fundstellen einer Seite.
*
* **Nicht** im Text selbst, sondern als eigene Ebene aus Kästchen darüber.
* Der erste Anlauf setzte ein <mark> mitten in die unsichtbare Schrift –
* und genau das war der Grund für die schiefen, oben angeschnittenen
* Streifen:
*
* Ein <mark> ist ein Textstück. Sein Grund ist so hoch, wie die Schrift
* ihn vorgibt (Oberlänge + Unterlänge der **Ersatz**schrift), nicht so
* hoch wie die Zeile. Gemessen: 23 Pixel Kasten auf 17 Pixel Zeile. Der
* Überstand ragte nach oben aus der Textebene heraus – und die schneidet
* ab (`overflow: clip`). Daher „abgeschnitten und nicht gerade".
*
* Jetzt kommt die Geometrie **vollständig** aus der Auswahl – genau die
* Kästen, die der Browser auch beim Markieren mit der Maus malt. Das darf
* er jetzt auch: Unsere Ebene schneidet nichts ab, das war ja allein die
* Textebene.
*
* Und markiert wird das **ganze Wort**, nicht der Buchstabenschnipsel –
* warum, steht bei `wordAround` unten.
*
* Nebenbei bleibt die Schrift unberührt: Markieren und Kopieren gehen
* währenddessen weiter, was beim Umbauen des Textes nicht mehr sicher war.
*
* Grenze, die ich in Kauf nehme: Ein Wort, das PDF.js über zwei Stücke
* verteilt, wird gezählt, aber nicht eingefärbt. Bei erkanntem Text (OCR)
* ist ein Wort praktisch immer ein Stück.
*/
const paint = useCallback((n: number, activeNth: number) => {
const host = pageRefs.current[n - 1]
const layer = host?.querySelector<HTMLDivElement>('.pdf-hitLayer')
if (!host || !layer) return
layer.replaceChildren()
const q = query.trim().toLowerCase()
if (q.length < 2) return
const base = host.getBoundingClientRect()
let nth = 0
/** Zuletzt gezeichnetes Wort – gegen doppelte Kästen (siehe unten). */
let last: { at: number; to: number; boxes: HTMLElement[] } | null = null
for (const span of host.querySelectorAll<HTMLElement>('.pdf-textLayer span')) {
const text = span.textContent ?? ''
const low = text.toLowerCase()
if (!low.includes(q)) continue
const node = span.firstChild
if (!node || node.nodeType !== Node.TEXT_NODE) continue
last = null
let from = 0
for (;;) {
const at = low.indexOf(q, from)
if (at < 0) break
const [ws, we] = wordAround(text, at, at + q.length)
const cur = nth === activeNth
from = at + q.length
nth++
// „la" findet in einem Wort wie „Salatlöffel" zweimal dieselbe Stelle
// zum Malen. Statt zwei deckungsgleicher Kästen: den vorhandenen
// behalten und höchstens zum aktuellen Treffer aufwerten.
if (last && last.at === ws && last.to === we) {
if (cur) last.boxes.forEach(b => b.classList.add('pdf-hit-current'))
continue
}
const range = document.createRange()
range.setStart(node, ws)
range.setEnd(node, we)
const boxes: HTMLElement[] = []
for (const r of range.getClientRects()) {
const box = document.createElement('div')
box.className = 'pdf-hit' + (cur ? ' pdf-hit-current' : '')
box.style.left = `${r.left - base.left}px`
box.style.top = `${r.top - base.top}px`
box.style.width = `${r.width}px`
box.style.height = `${r.height}px`
layer.append(box)
boxes.push(box)
}
last = { at: ws, to: we, boxes }
}
}
}, [query])
/**
* Leerräumen, sobald das Suchfeld leer ist.
*
* Ohne das blieb die letzte Färbung stehen: `paint` stieg bei kurzem
* Suchwort vorne aus und kam nie zum Aufräumen, und ohne Treffer läuft es
* gar nicht erst. Auf dem Blatt lagen dann Streifen zu einem Wort, das im
* Feld längst nicht mehr stand.
*
* Steht vor den beiden Effekten, die färben – innerhalb einer Änderung
* laufen Effekte in der Reihenfolge ihrer Notierung ab, also erst räumen,
* dann neu färben.
*/
useEffect(() => {
for (const host of pageRefs.current) {
host?.querySelector('.pdf-hitLayer')?.replaceChildren()
}
}, [query, hits])
// Zur aktuellen Fundstelle springen und sie färben. Ist die Seite noch
// nicht gezeichnet, wartet das auf ihre Textebene – deshalb das Ereignis.
const target = hits[hitIndex]
useEffect(() => {
if (!target) return
const host = pageRefs.current[target.page - 1]
if (!host) return
// Nur zur Seite fahren, wenn sie **noch nicht gezeichnet** ist. Ist sie da,
// übernimmt revealHit weiter unten – und das rollt nur, wenn der Treffer
// wirklich außerhalb liegt. Ein `goToPage` an dieser Stelle sprang bei
// jedem Tastendruck an den Seitenanfang, obwohl der Treffer schon zu
// sehen war.
if (host.dataset.text !== 'ready' || !target.exact) goToPage(target.page)
// Gescrollt wird **hier**, nicht in paint(): Die Färbung läuft auch dann,
// wenn eine Seite bloß neu gezeichnet wurde – etwa nach dem Zoomen. Stand
// das Scrollen darin, sprang die Anzeige bei jedem Zoomschritt zurück zur
// Fundstelle, obwohl man längst woanders las. (Gemessen: von Seite 4
// zurück auf Seite 3.)
const show = () => {
// Ein Treffer aus dem erkannten Text kennt nur die Seite. Dann bleibt
// es beim Sprung an den Seitenanfang – ein Wort einzufärben, das wir
// nicht verorten können, hieße raten.
if (!target.exact) return
paint(target.page, target.nth)
const el = host.querySelector('.pdf-hit-current')
if (el) revealHit(el)
}
if (host.dataset.text === 'ready') { show(); return }
host.addEventListener('pdf-text-ready', show, { once: true })
return () => host.removeEventListener('pdf-text-ready', show)
}, [target, goToPage, revealHit, paint])
// Frisch gezeichnete Seiten bekommen ihre Färbung nach – sonst wären beim
// Blättern nur die Treffer der Seite markiert, auf der man gelandet ist.
useEffect(() => {
const root = scrollRef.current
if (!root || hits.length === 0) return
function onReady(e: Event) {
const host = (e.target as HTMLElement)
const n = Number(host.dataset.page)
paint(n, target && target.page === n ? target.nth : -1)
}
root.addEventListener('pdf-text-ready', onReady)
return () => root.removeEventListener('pdf-text-ready', onReady)
}, [hits.length, target, paint])
// Kommt der Betrachter aus einem Suchtreffer, steht das Wort schon da.
useEffect(() => { setQuery(searchTerm ?? '') }, [searchTerm, url])
// ------------------------------------------------------------------ Zoom
/**
* Beim Zoomen ändern sich alle Seitenhöhen – die Bildlaufposition zeigt
* danach irgendwohin. Gemerkt wird deshalb die Seite, auf der man gerade
* war, und nach dem Umbau wieder angesteuert. Ohne das landet man nach
* einem Zoomschritt am Anfang eines fremden Blattes.
*/
const keepPage = useRef<number | null>(null)
useEffect(() => {
const n = keepPage.current
if (n === null) return
keepPage.current = null
goToPage(n)
}, [effScale, goToPage])
function zoom(dir: 1 | -1) {
const now = effScale
const next = dir > 0
? zoomSteps.find(s => s > now + 0.001)
: [...zoomSteps].reverse().find(s => s < now - 0.001)
if (next) { keepPage.current = current; setScale(next) }
}
const pages = useMemo(() => sizes.map((_, i) => i + 1), [sizes])
/**
* Reicht die Breite für drei Spalten? Unterhalb bricht die Leiste um, und
* ein Raster ergäbe eine gequetschte Suche neben gequetschten Knöpfen.
*/
const barRef = useRef<HTMLDivElement>(null)
const [barNarrow, setBarNarrow] = useState(false)
useEffect(() => {
const el = barRef.current
if (!el) return
// Dieselbe Vorsicht wie bei der Einpassung: Breite null heißt „noch nicht
// sichtbar", nicht „schmal". Ohne die Prüfung stand die Leiste beim
// Aufgehen der Vorschau im Umbruch-Zustand und blieb dort.
const set = () => { if (el.clientWidth >= 50) setBarNarrow(el.clientWidth < minGridWidth) }
set()
const ro = new ResizeObserver(set)
ro.observe(el)
return () => ro.disconnect()
}, [])
const [pad, setPad] = useState(16)
useEffect(() => {
const el = scrollRef.current
if (!el) return
const set = () => { if (el.clientWidth >= 50) setPad(padOf(el.clientWidth)) }
set()
const ro = new ResizeObserver(set)
ro.observe(el)
return () => ro.disconnect()
}, [doc])
// ----------------------------------------------------------------- Anzeige
if (error) {
return (
<div style={{ color: 'var(--color-text-muted)', fontSize: 14, textAlign: 'center' }}>
{error}
</div>
)
}
return (
<div style={{
width: '100%', height: '100%', display: 'flex', flexDirection: 'column',
borderRadius: 8, overflow: 'hidden', background: 'var(--color-surface)',
}}>
{/*
Werkzeugleiste.
Am Schreibtisch ein **Dreispaltenraster**: Steuerung links, Suche in
der Mitte, rechts eine leere Spalte gleicher Breite. Erst dadurch sitzt
die Suche wirklich mittig – mit zwei Abstandhaltern in einer Reihe
teilen sich diese nur den *Rest* neben der Steuerung, und das Feld
landete gemessen 79 bzw. 142 Pixel daneben.
Am Telefon reicht die Breite für drei Spalten nicht; dort bleibt es
beim Umbruch, und die Suche steht in der zweiten Zeile.
*/}
<div
ref={barRef}
style={{
display: barNarrow ? 'flex' : 'grid',
gridTemplateColumns: barNarrow ? undefined : 'minmax(0, 1fr) auto minmax(0, 1fr)',
alignItems: 'center', gap: 8, flexWrap: 'wrap',
padding: '6px 10px', flexShrink: 0,
borderBottom: '1px solid var(--color-border)',
background: 'var(--color-surface)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<ToolButton label="Previous page" disabled={current <= 1}
onClick={() => goToPage(current - 1)} glyph="up" />
<span style={{
fontSize: 12, color: 'var(--color-text-muted)',
minWidth: 62, flexShrink: 0,
textAlign: 'center', fontVariantNumeric: 'tabular-nums',
}}>
{doc ? `${current} / ${doc.numPages}` : '…'}
</span>
<ToolButton label="Next page" disabled={!doc || current >= doc.numPages}
onClick={() => goToPage(current + 1)} glyph="down" />
<span style={{ width: 1, height: 20, flexShrink: 0, background: 'var(--color-border)' }} />
<ToolButton label="Zoom out" onClick={() => zoom(-1)} glyph="minus" />
{/* Zurück zur eingepassten Größe. Bewusst kein Umschalter zwischen
„Breite" und „100 %": Seit die Einpassung bei 100 % gedeckelt ist,
sind das am Schreibtisch oft dieselbe Zahl – ein Knopf, der nichts
tut, ist schlimmer als keiner. */}
<button
onClick={() => { keepPage.current = current; setScale('fit') }}
disabled={scale === 'fit'}
title="Fit width"
style={{
fontSize: 12, color: 'var(--color-text-muted)', minWidth: 52, flexShrink: 0,
border: 'none', background: 'none',
cursor: scale === 'fit' ? 'default' : 'pointer',
fontVariantNumeric: 'tabular-nums',
}}
>
{Math.round(effScale * 100)}%
</button>
<ToolButton label="Zoom in" onClick={() => zoom(1)} glyph="plus" />
</div>
{/* Suche im Dokument – die mittlere Spalte. Sie ist der Grund, aus dem
es diesen Betrachter gibt; rechts an den Rand gedrängt sah sie aus
wie ein Zusatz, der nicht recht dazugehört. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{/* Gegengewicht. Rechts vom Feld stehen Zähler und Sprungknöpfe;
ohne diesen Ausgleich säße die Gruppe zwar mittig, das **Feld**
aber 79 Pixel daneben – gemessen. Mittig soll das sein, was man
ansieht, nicht der Kasten drumherum.
Am Telefon fällt es weg: Dort gibt es nichts zu zentrieren, und
120 Pixel Leere hätten ein Drittel der Zeile gekostet – während
rechts der zweite Sprungknopf aus dem Bild fiel. */}
{!barNarrow && <span style={{ width: hitsWidth, flexShrink: 0 }} />}
<input
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault()
if (hits.length) setHitIndex(i => (i + (e.shiftKey ? -1 : 1) + hits.length) % hits.length)
}
// Escape gehört hier dem Feld, nicht dem Schließen der Vorschau.
if (e.key === 'Escape' && query) { e.stopPropagation(); setQuery('') }
}}
placeholder="Find in document"
style={{
// Breiter als die 150 vom Anfang: In der Mitte einer 1440 Pixel
// breiten Leiste sah ein Feld in Knopfbreite aus wie ein
// Restposten. Ein Suchbegriff wie „Zahlungsbedingungen" passt
// jetzt auch hinein, ohne zu wandern.
width: 240, minWidth: 0,
padding: '5px 8px', borderRadius: 8, fontSize: 12,
border: '1px solid var(--color-border)',
background: 'var(--color-background)', color: 'var(--color-text)',
outline: 'none',
}}
/>
<span style={{
fontSize: 11, color: 'var(--color-text-muted)',
// Feste Breite, nicht Mindestbreite: Sonst wüchse die rechte Seite
// mit „Searching…" und das Feld rutschte beim Tippen hin und her.
width: counterWidth, flexShrink: 0,
overflow: 'hidden', whiteSpace: 'nowrap',
fontVariantNumeric: 'tabular-nums',
}}>
{searching ? 'Searching…'
: query.trim().length < 2 ? ''
: hits.length === 0 ? 'No hits'
: `${hitIndex + 1} / ${hits.length}${hits[0] && !hits[0].exact ? ' · page' : ''}`}
</span>
{/* Erst zeigen, wenn es etwas zu springen gibt. Ohne Suche stand
hier ein zweites Paar Winkel neben dem zum Blättern – zwei
gleich aussehende Knopfpaare für zwei ganz verschiedene Dinge. */}
{/* Der Platz für die beiden Knöpfe steht immer bereit, auch wenn
sie fehlen. Sonst rückte das Suchfeld beim ersten Treffer um
knapp dreißig Pixel zur Seite – ausgerechnet während man darin
tippt. Leerer Raum fällt niemandem auf, ein wanderndes Feld
schon. */}
<span style={{ width: hitButtonsWidth, flexShrink: 0, display: 'flex', gap: 4 }}>
{hits.length > 0 && (
<>
<ToolButton label="Previous hit" glyph="hitUp"
onClick={() => setHitIndex(i => (i - 1 + hits.length) % hits.length)} />
<ToolButton label="Next hit" glyph="hitDown"
onClick={() => setHitIndex(i => (i + 1) % hits.length)} />
</>
)}
</span>
</div>
{/* Die leere dritte Spalte. Sie trägt nichts und ist trotzdem nötig:
Ohne sie hätte das Raster nur zwei Spalten, und „Mitte" wäre
wieder die Mitte des Restes. */}
{!barNarrow && <div />}
</div>
{/* Seiten */}
<div
ref={scrollRef}
style={{
flex: 1, minHeight: 0, overflow: 'auto',
padding: pad, background: 'var(--color-background)',
// Auf dem Handy zieht man das Dokument mit dem Finger; die
// Vorschau darunter darf dabei nicht mitwandern.
overscrollBehavior: 'contain',
}}
>
{!doc && (
<div style={{ color: 'var(--color-text-muted)', fontSize: 13, textAlign: 'center' }}>
Loading…
</div>
)}
{pages.map(n => (
<div
key={n}
data-page={n}
ref={el => { pageRefs.current[n - 1] = el }}
style={{
position: 'relative',
width: sizes[n - 1].w * effScale,
height: sizes[n - 1].h * effScale,
margin: `0 auto ${pageGap}px`,
background: '#fff',
boxShadow: '0 2px 10px rgba(0,0,0,0.4)',
// Die Textebene rechnet Schriftgrößen gegen diesen Wert.
['--total-scale-factor' as string]: String(effScale),
}}
>
<div className="pdf-textLayer" />
{/* Fundstellen der Suche. Eigene Ebene über der Schrift, damit
sie nichts am Text ändert und nichts abgeschnitten wird. */}
<div className="pdf-hitLayer" />
</div>
))}
</div>
</div>
)
}
/**
* Die häufigste Seitenbreite eines Dokuments.
*
* Vorher stand hier die **breiteste** Seite, mit dem Gedanken: Unterschiedlich
* breite Seiten unterschiedlich zu skalieren sieht nach Fehler aus. Das
* stimmt – nur ist der Preis höher als der Gewinn.
*
* Gemessen an einem zehnseitigen Dokument, in dem **eine** Seite quer liegt:
* Die neun hochkanten Seiten standen dadurch auf 46 % statt 65 %. Auf einem
* 412 Pixel breiten Telefon ist das der Unterschied zwischen lesbar und nicht.
* Eine einzelne Querformat-Seite – ein Anhang, eine breite Tabelle – bestimmt
* so die Darstellung des ganzen Dokuments.
*
* Jetzt entscheidet die Mehrheit. Die abweichende Seite ragt dann über den
* Rand und lässt sich seitlich schieben; das ist genau das, was man bei ihr
* ohnehin tun will.
*
* Bei Gleichstand gewinnt die breitere – dann ragt lieber nichts heraus.
*/
function commonWidth(sizes: { w: number; h: number }[]): number {
const count = new Map<number, number>()
for (const s of sizes) {
const w = Math.round(s.w)
count.set(w, (count.get(w) ?? 0) + 1)
}
let best = 0, bestN = 0
for (const [w, n] of count) {
if (n > bestN || (n === bestN && w > best)) { best = w; bestN = n }
}
return best || 1
}
/**
* Weitet eine Fundstelle auf das **ganze Wort** aus.
*
* Der Grund ist gemessen, nicht Geschmack. Über dem gemalten Bild liegt eine
* unsichtbare *Ersatz*schrift – hier `sans-serif 13,5px` für eine
* Serifenschrift im PDF. PDF.js dehnt sie über `--scale-x` (gemessen 0,937),
* damit die **Zeile insgesamt** stimmt. Die Buchstaben *darin* sitzen deshalb
* um ein bis drei Pixel anders als die gemalten.
*
* Bei einem ganzen Wort fällt das nicht auf. Bei „la" mitten in „Display"
* schon: Der Kasten ist zehn Pixel breit, und drei Pixel Versatz sehen aus,
* als läge er auf den falschen Buchstaben. Genau das war zu sehen.
*
* Gegenprobe im Browser: Unser Kasten und die Auswahl, die der Browser beim
* Markieren mit der Maus malt, liegen auf demselben Pixel (357,08 / 10,38).
* Der Versatz ist also nicht unser Fehler – er lässt sich nur nicht
* wegrechnen, ohne die Schrift des Dokuments zu besitzen. Ihn zu *umgehen*
* geht: ganze Wörter markieren, deren Ränder an Wortgrenzen liegen.
*
* Sehr lange „Wörter" (Adressen, Prüfsummen) bleiben ungedehnt – sonst wäre
* eine halbe Zeile eingefärbt, weil irgendwo darin zwei Buchstaben passten.
*/
const maxWordSnap = 40
function wordAround(text: string, at: number, to: number): [number, number] {
const isWord = (c: string) => /[\p{L}\p{N}]/u.test(c)
let s = at, e = to
while (s > 0 && isWord(text[s - 1])) s--
while (e < text.length && isWord(text[e])) e++
return e - s > maxWordSnap ? [at, to] : [s, e]
}
/** Zählt die Fundstellen eines Suchworts in einem Stück Text. */
function countIn(haystack: string, needle: string, page: number, exact: boolean): Hit[] {
const out: Hit[] = []
let from = 0, nth = 0
for (;;) {
const at = haystack.indexOf(needle, from)
if (at < 0) break
out.push({ page, nth: nth++, exact })
from = at + needle.length
}
return out
}
/**
* Die Knöpfe der Werkzeugleiste steuern **zwei verschiedene** Dinge: links
* die Seite, rechts die Fundstelle. Beide bewegen sich nach oben und unten –
* und mit demselben Winkel-Zeichen sahen beide Paare gleich aus. Wer die
* Leiste zum ersten Mal sieht, kann nicht wissen, welches welches ist.
*
* Deshalb zwei Formen: der schlanke Winkel blättert Seiten, der Pfeil mit
* Schaft springt zur nächsten Fundstelle. Der Pfeil erscheint ohnehin erst,
* wenn es Fundstellen gibt.
*/
const glyphs: Record<string, string> = {
up: '18 15 12 9 6 15',
down: '6 9 12 15 18 9',
}
function ToolButton({ label, onClick, disabled, glyph }: {
label: string; onClick: () => void; disabled?: boolean
glyph: 'up' | 'down' | 'plus' | 'minus' | 'hitUp' | 'hitDown'
}) {
return (
<button
onClick={onClick}
disabled={disabled}
title={label}
aria-label={label}
style={{
width: 28, height: 28, flexShrink: 0, borderRadius: 8, border: 'none',
background: 'none', color: 'var(--color-text-muted)',
cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.35 : 1,
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0,
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round">
{glyph === 'plus' && <><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></>}
{glyph === 'minus' && <line x1="5" y1="12" x2="19" y2="12" />}
{(glyph === 'up' || glyph === 'down') && <polyline points={glyphs[glyph]} />}
{glyph === 'hitUp' && <><line x1="12" y1="19" x2="12" y2="6" /><polyline points="6 12 12 6 18 12" /></>}
{glyph === 'hitDown' && <><line x1="12" y1="5" x2="12" y2="18" /><polyline points="6 12 12 18 18 12" /></>}
</svg>
</button>
)
}
+258
View File
@@ -0,0 +1,258 @@
import { useEffect, useRef, useState } from 'react'
import { searchApi, filesApi, type SearchHit } from '../api/client'
import { FileTypeIcon, FolderGlyph } from './icons'
/**
* Zerlegt die Fundstelle an den Marken aus ts_headline.
*
* Bewusst kein dangerouslySetInnerHTML: Der Text kommt aus einem hochgeladenen
* Dokument, das jeder freigegebene Nutzer beisteuern kann. Als HTML eingesetzt
* wäre ein <script> darin eine Lücke; als Textknoten ist es einfach Text.
* Deshalb markiert die Datenbank mit [[hl]] statt mit <b>.
*/
function highlight(snippet: string) {
return snippet.split(/\[\[\/?hl\]\]/).map((part, i) =>
// Jedes zweite Stück lag zwischen den Marken – das ist der Treffer.
i % 2 === 1
? <mark key={i} style={{
background: 'transparent', color: 'var(--color-primary)', fontWeight: 600,
}}>{part}</mark>
: <span key={i}>{part}</span>
)
}
interface Props {
/** Treffer geöffnet: Ordner betreten bzw. zum Ordner der Datei springen. */
onOpenFolder: (folderId: string | null, folderName: string) => void
/** Zweiter Wert ist das Suchwort – der PDF-Betrachter springt damit zur
* Fundstelle im Dokument. */
onOpenFile: (hit: SearchHit, term: string) => void
}
/** Erst ab zwei Zeichen suchen – ein einzelnes trifft fast alles. Der Server
* hält dieselbe Grenze ein, das hier spart nur die Anfrage. */
const minChars = 2
/** Wartezeit nach dem letzten Tastendruck. Kurz genug, dass es beim Tippen
* mitläuft; lang genug, dass nicht jeder Buchstabe eine Anfrage auslöst. */
const debounceMs = 250
export default function SearchBox({ onOpenFolder, onOpenFile }: Props) {
const [query, setQuery] = useState('')
const [hits, setHits] = useState<SearchHit[]>([])
const [truncated, setTruncated] = useState(false)
const [open, setOpen] = useState(false)
const [busy, setBusy] = useState(false)
const [active, setActive] = useState(0)
const boxRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
// Jede Anfrage bricht die vorige ab. Ohne das kann eine langsame frühere
// Antwort eine neuere überschreiben – man tippt weiter und sieht plötzlich
// wieder die Treffer von vor zwei Buchstaben.
useEffect(() => {
const q = query.trim()
if (q.length < minChars) {
setHits([]); setTruncated(false); setBusy(false)
return
}
const ctrl = new AbortController()
setBusy(true)
const timer = setTimeout(async () => {
try {
const res = await searchApi.query(q, ctrl.signal)
setHits(res.hits ?? [])
setTruncated(res.truncated ?? false)
setActive(0)
setOpen(true)
} catch {
// Abgebrochen oder fehlgeschlagen – ein leeres Feld ist hier die
// ehrlichste Anzeige, eine Fehlermeldung beim Tippen nur im Weg.
} finally {
setBusy(false)
}
}, debounceMs)
return () => { clearTimeout(timer); ctrl.abort() }
}, [query])
// Klick daneben schließt die Liste.
useEffect(() => {
function onDown(e: MouseEvent) {
if (!boxRef.current?.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', onDown)
return () => document.removeEventListener('mousedown', onDown)
}, [])
function choose(hit: SearchHit) {
const term = query.trim()
setOpen(false)
setQuery('')
if (hit.kind === 'folder') onOpenFolder(hit.id, hit.name)
else onOpenFile(hit, term)
}
function onKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Escape') { setOpen(false); inputRef.current?.blur(); return }
if (!open || hits.length === 0) return
if (e.key === 'ArrowDown') { e.preventDefault(); setActive(i => (i + 1) % hits.length) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setActive(i => (i - 1 + hits.length) % hits.length) }
else if (e.key === 'Enter') { e.preventDefault(); choose(hits[active]) }
}
const showPanel = open && query.trim().length >= minChars
return (
<div ref={boxRef} style={{ position: 'relative', flex: '1 1 180px', minWidth: 0, maxWidth: 220 }}>
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
style={{ position: 'absolute', left: 9, color: 'var(--color-text-muted)', pointerEvents: 'none' }}>
<circle cx="11" cy="11" r="7" /><line x1="16.5" y1="16.5" x2="21" y2="21" />
</svg>
<input
ref={inputRef}
value={query}
onChange={e => setQuery(e.target.value)}
onFocus={() => { if (hits.length) setOpen(true) }}
onKeyDown={onKeyDown}
placeholder="Search files and folders"
style={{
// Auf breiten Schirmen 220, auf schmalen so breit wie der Platz:
// Die feste Breite war der Grund, warum daneben nichts mehr passte.
width: 220, maxWidth: '100%', minWidth: 0,
padding: '6px 10px 6px 28px',
borderRadius: 8,
border: '1px solid var(--color-border)',
background: 'var(--color-surface-2)',
color: 'var(--color-text)',
fontSize: 13, outline: 'none',
}}
/>
{busy && (
<div style={{
position: 'absolute', right: 8, width: 12, height: 12,
border: '2px solid var(--color-primary)', borderTopColor: 'transparent',
borderRadius: '50%', animation: 'spin 0.7s linear infinite',
}} />
)}
</div>
{showPanel && (
<div style={{
position: 'absolute', top: 'calc(100% + 6px)', left: 0,
// Breiter als die 380 von vorher: Eine Fundstelle braucht Platz,
// sonst bringen auch zwei Zeilen kaum mehr Wörter unter.
// Auf dem Handy passen keine 460px – dort so breit wie der Schirm
// hergibt, damit die Fundstelle nicht auf drei Wörter schrumpft.
width: 460, maxWidth: 'min(85vw, calc(100vw - 32px))', maxHeight: 420, overflowY: 'auto',
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 10, boxShadow: '0 12px 32px rgba(0,0,0,0.45)',
zIndex: 60, padding: 4,
}}>
{hits.length === 0 && !busy && (
<div style={{ padding: '14px 12px', fontSize: 13, color: 'var(--color-text-muted)' }}>
Nothing found for “{query.trim()}”
</div>
)}
{hits.map((hit, i) => (
<button
key={hit.kind + hit.id}
onClick={() => choose(hit)}
onMouseEnter={() => setActive(i)}
style={{
// Oben ausrichten statt mittig: Ein Treffer mit Fundstelle ist
// drei Zeilen hoch, einer ohne zwei – mittig zentriert wandert
// das Symbol dann von Zeile zu Zeile.
display: 'flex', alignItems: 'flex-start', gap: 10, width: '100%',
padding: '8px 9px', borderRadius: 7, border: 'none', textAlign: 'left',
background: i === active ? 'var(--color-surface-2)' : 'transparent',
cursor: 'pointer',
}}
>
{/* Fester Platz fürs Symbol, egal wie breit es ist. Der Ordner
ist 24 Pixel breit, die Dateisymbole 18 – ohne diese Spalte
begännen Ordnernamen weiter rechts als Dateinamen, und in
einer gemischten Trefferliste steht der Text dann in zwei
Spalten. Gemessen: 107 gegen 95. */}
<span style={{
width: 24, flexShrink: 0, display: 'flex',
alignItems: 'center', justifyContent: 'center',
// Auf Höhe der ersten Zeile, nicht der Mitte des Blocks.
height: 20, marginTop: 1,
}}>
{hit.kind === 'folder' ? (
<FolderGlyph size={24} />
) : hit.has_thumbnail ? (
<img src={filesApi.thumbnail(hit.id)} alt="" style={{
width: 18, height: 18, borderRadius: 3, objectFit: 'cover',
}} />
) : (
<FileTypeIcon mime={hit.mime_type ?? ''} name={hit.name} size={18} />
)}
</span>
<span style={{ minWidth: 0, flex: 1 }}>
<span style={{
display: 'block', fontSize: 13, color: 'var(--color-text)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{hit.name}
</span>
{/* Ohne den Ordner darunter sind drei Dateien namens
„Rechnung.pdf" nicht auseinanderzuhalten. */}
<span style={{
display: 'block', fontSize: 11, color: 'var(--color-text-muted)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
in {hit.folder_name || 'Home'}
</span>
{/* Bei einem Inhaltstreffer passt der Name gerade *nicht* –
ohne die Fundstelle sähe „scan_0042.pdf" wie ein Fehler der
Suche aus.
Zwei Zeilen statt einer abgeschnittenen: Eine Fundstelle,
die nach zehn Wörtern in „…" endet, zeigt oft gerade noch
nicht, worum es geht – und das Suchwort selbst kann darin
fehlen. Gekappt wird jetzt am Ende der zweiten Zeile
(line-clamp), nicht am Ende der ersten.
Die Linie links trennt die Fundstelle vom Ordnernamen
darüber: Beides ist kleine, gedämpfte Schrift, und ohne
Trennung liest es sich als ein Block. */}
{hit.matched_content && hit.snippet && (
<span style={{
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
lineClamp: 2,
overflow: 'hidden',
fontSize: 11, lineHeight: 1.45,
color: 'var(--color-text-muted)',
marginTop: 3, paddingLeft: 8,
// In der Farbe des Treffers, nur blass: --color-border war
// gegen die hervorgehobene Zeile praktisch unsichtbar
// (gemessen rgb(51,65,85) auf fast demselben Grund). So ist
// die Linie auf beiden Untergründen zu sehen und gehört
// sichtbar zum hervorgehobenen Wort.
borderLeft: '2px solid rgba(var(--color-primary-rgb), 0.4)',
}}>
{highlight(hit.snippet)}
</span>
)}
</span>
</button>
))}
{truncated && (
<div style={{ padding: '8px 10px', fontSize: 11, color: 'var(--color-text-muted)' }}>
Only the first {hits.length} matches are shown – narrow the search.
</div>
)}
</div>
)}
</div>
)
}
+190
View File
@@ -0,0 +1,190 @@
import { useState } from 'react'
import { sharesApi, type FileItem, type ShareLink } from '../api/client'
import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
interface Props {
file: FileItem
onClose: () => void
}
export default function ShareDialog({ file, onClose }: Props) {
const [password, setPassword] = useState('')
const [expiresIn, setExpiresIn] = useState('')
const [link, setLink] = useState<ShareLink | null>(null)
const [creating, setCreating] = useState(false)
const [error, setError] = useState('')
const [copied, setCopied] = useState(false)
const overlay = useOverlayDismiss(onClose)
async function createLink() {
setCreating(true)
setError('')
try {
const created = await sharesApi.create(file.id, {
password: password || undefined,
expires_in_hours: expiresIn ? parseInt(expiresIn) : undefined,
})
setLink(created)
} catch (e: any) {
setError(e.message ?? 'Failed to create link')
} finally {
setCreating(false)
}
}
function shareURL(token: string) {
return `${window.location.origin}/s/${token}`
}
function copyLink() {
if (!link) return
navigator.clipboard.writeText(shareURL(link.token))
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div
style={{ position: 'fixed', inset: 0, zIndex: 200, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.6)' }}
{...overlay}
>
<div style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 14,
padding: 24,
width: 400,
maxWidth: '90vw',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 600, color: 'var(--color-text)' }}>Share "{file.name}"</h2>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', padding: 4, borderRadius: 6 }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{!link ? (
<>
<div style={{ marginBottom: 14 }}>
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, color: 'var(--color-text-muted)', marginBottom: 6 }}>
Password (optional)
</label>
<input
type="password"
placeholder="Leave empty for no password"
value={password}
onChange={e => setPassword(e.target.value)}
style={{
width: '100%', padding: '8px 12px', borderRadius: 8,
border: '1px solid var(--color-border)',
background: 'var(--color-surface-2)',
color: 'var(--color-text)', fontSize: 13,
outline: 'none',
}}
/>
</div>
<div style={{ marginBottom: 20 }}>
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, color: 'var(--color-text-muted)', marginBottom: 6 }}>
Expires in
</label>
<select
value={expiresIn}
onChange={e => setExpiresIn(e.target.value)}
style={{
width: '100%', padding: '8px 12px', borderRadius: 8,
border: '1px solid var(--color-border)',
background: 'var(--color-surface-2)',
color: 'var(--color-text)', fontSize: 13,
outline: 'none',
}}
>
<option value="">Never</option>
<option value="1">1 hour</option>
<option value="24">24 hours</option>
<option value="168">7 days</option>
<option value="720">30 days</option>
</select>
</div>
{error && (
<p style={{ color: 'var(--color-danger)', fontSize: 13, marginBottom: 14 }}>{error}</p>
)}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
onClick={onClose}
style={{
padding: '8px 16px', borderRadius: 8, border: '1px solid var(--color-border)',
background: 'transparent', color: 'var(--color-text-muted)', fontSize: 13, cursor: 'pointer',
}}
>
Cancel
</button>
<button
onClick={createLink}
disabled={creating}
style={{
padding: '8px 16px', borderRadius: 8, border: 'none',
background: 'var(--color-primary)', color: '#fff', fontSize: 13, fontWeight: 600,
cursor: creating ? 'not-allowed' : 'pointer', opacity: creating ? 0.7 : 1,
}}
>
{creating ? 'Creating…' : 'Create link'}
</button>
</div>
</>
) : (
<>
<div style={{
background: 'var(--color-surface-2)',
borderRadius: 8, padding: '12px 14px', marginBottom: 16,
display: 'flex', alignItems: 'center', gap: 10,
}}>
<span style={{ flex: 1, fontSize: 12, color: 'var(--color-text)', wordBreak: 'break-all' }}>
{shareURL(link.token)}
</span>
<button
onClick={copyLink}
style={{
padding: '5px 10px', borderRadius: 6, border: '1px solid var(--color-border)',
background: copied ? 'var(--color-success)' : 'transparent',
color: copied ? '#fff' : 'var(--color-text)', fontSize: 12, cursor: 'pointer',
whiteSpace: 'nowrap', flexShrink: 0,
transition: 'background 0.2s',
}}
>
{copied ? '✓ Copied' : 'Copy'}
</button>
</div>
{link.has_password && (
<p style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
<span>🔒</span> Password protected
</p>
)}
{link.expires_at && (
<p style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 16, display: 'flex', alignItems: 'center', gap: 6 }}>
<span>⏰</span> Expires {new Date(link.expires_at).toLocaleString()}
</p>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
onClick={onClose}
style={{
padding: '8px 16px', borderRadius: 8, border: 'none',
background: 'var(--color-primary)', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
}}
>
Done
</button>
</div>
</>
)}
</div>
</div>
)
}
+272
View File
@@ -0,0 +1,272 @@
import { useState, useEffect } from 'react'
import {
aclApi,
type AclEntry, type ShareRecipient, type SharePermission,
} from '../api/client'
import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
interface Props {
/** Was freigegeben wird – Ordner oder einzelne Datei. */
resource: { type: 'folder' | 'file'; id: string; name: string }
onClose: () => void
}
/** Freigabe an Personen und Gruppen innerhalb der Cloud.
*
* Bewusst getrennt vom öffentlichen Freigabelink: Der eine gibt einen
* anonymen Zugang ins Internet, der andere benennt Personen, die sich
* ohnehin anmelden. Ein Dialog für beides würde die Frage „wer kann das
* sehen?" verwischen – und genau die muss beim Teilen eindeutig sein. */
export default function ShareWithDialog({ resource, onClose }: Props) {
const overlay = useOverlayDismiss(onClose)
const [entries, setEntries] = useState<AclEntry[]>([])
const [recipients, setRecipients] = useState<ShareRecipient[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [subject, setSubject] = useState('')
const [permission, setPermission] = useState<SharePermission>('read')
const [saving, setSaving] = useState(false)
useEffect(() => { load() }, [resource.id])
async function load() {
setLoading(true)
try {
const [list, people] = await Promise.all([
resource.type === 'folder'
? aclApi.listForFolder(resource.id)
: aclApi.listForFile(resource.id),
aclApi.recipients(),
])
setEntries(list ?? [])
setRecipients(people ?? [])
} catch {
setError('Could not load sharing settings')
} finally {
setLoading(false)
}
}
async function grant() {
const chosen = recipients.find(r => r.id === subject)
if (!chosen) return
setSaving(true)
setError('')
try {
if (resource.type === 'folder') {
await aclApi.grantFolder(resource.id, chosen.id, chosen.type, permission)
} else {
await aclApi.grantFile(resource.id, chosen.id, chosen.type, permission)
}
setSubject('')
await load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not share')
} finally {
setSaving(false)
}
}
async function revoke(entry: AclEntry) {
try {
await aclApi.revoke(entry.id)
setEntries(list => list.filter(e => e.id !== entry.id))
} catch {
setError('Could not remove access')
}
}
// Wer schon Zugriff hat, gehört nicht noch einmal in die Auswahlliste.
const alreadyShared = new Set(entries.map(e => e.subject_id))
const available = recipients.filter(r => !alreadyShared.has(r.id))
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 300,
background: 'rgba(0,0,0,0.55)',
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20,
}} {...overlay}>
<div style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 16, width: 480, maxWidth: '100%', maxHeight: '85vh',
display: 'flex', flexDirection: 'column', overflow: 'hidden',
boxShadow: '0 12px 40px rgba(0,0,0,0.3)',
}}>
{/* Kopf */}
<div style={{ padding: '18px 22px 14px', borderBottom: '1px solid var(--color-border)' }}>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--color-text)' }}>
Share with people
</h3>
<p style={{
margin: '3px 0 0', fontSize: 12, color: 'var(--color-text-muted)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{resource.name}
</p>
</div>
{/* Hinzufügen */}
<div style={{ padding: '14px 22px', borderBottom: '1px solid var(--color-border)' }}>
<div style={{ display: 'flex', gap: 8 }}>
<select
value={subject}
onChange={e => setSubject(e.target.value)}
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',
}}
>
<option value="">Choose a person or group…</option>
{available.filter(r => r.type === 'user').length > 0 && (
<optgroup label="People">
{available.filter(r => r.type === 'user').map(r => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
</optgroup>
)}
{available.filter(r => r.type === 'group').length > 0 && (
<optgroup label="Groups">
{available.filter(r => r.type === 'group').map(r => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
</optgroup>
)}
</select>
<select
value={permission}
onChange={e => setPermission(e.target.value as SharePermission)}
style={{
padding: '8px 10px', borderRadius: 8, flexShrink: 0,
border: '1px solid var(--color-border)',
background: 'var(--color-surface-2)', color: 'var(--color-text)',
fontSize: 13, outline: 'none',
}}
>
<option value="read">Can view</option>
<option value="write">Can edit</option>
</select>
<button
onClick={grant}
disabled={!subject || saving}
style={{
padding: '8px 16px', borderRadius: 8, border: 'none', flexShrink: 0,
background: 'var(--color-primary)', color: '#fff',
fontSize: 13, fontWeight: 600,
cursor: !subject || saving ? 'default' : 'pointer',
opacity: !subject || saving ? 0.5 : 1,
}}
>
{saving ? '…' : 'Share'}
</button>
</div>
{resource.type === 'folder' && (
<p style={{ margin: '10px 0 0', fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
Everything inside this folder is shared too, including anything added later.
Files stay in your storage and count towards your quota.
</p>
)}
{error && (
<p style={{ margin: '10px 0 0', fontSize: 12, color: 'var(--color-danger)' }}>{error}</p>
)}
</div>
{/* Wer hat Zugriff */}
<div style={{ flex: 1, overflowY: 'auto', padding: '14px 22px' }}>
<div style={{
fontSize: 11, fontWeight: 700, color: 'var(--color-text-muted)',
textTransform: 'uppercase', letterSpacing: 0.4, marginBottom: 10,
}}>
Who has access
</div>
{loading ? (
<p style={{ margin: 0, fontSize: 13, color: 'var(--color-text-muted)' }}>Loading…</p>
) : entries.length === 0 ? (
<p style={{ margin: 0, fontSize: 13, color: 'var(--color-text-muted)' }}>
Only you. Nobody else can see this.
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{entries.map(e => (
<div key={e.id} style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '9px 11px', borderRadius: 9,
border: '1px solid var(--color-border)',
}}>
<span style={{ color: 'var(--color-text-muted)', display: 'flex', flexShrink: 0 }}>
{e.subject_type === 'group' ? <GroupIcon /> : <PersonIcon />}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13, color: 'var(--color-text)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{e.subject_name}
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
{e.subject_type === 'group' ? 'Group' : 'Person'} ·{' '}
{e.permission === 'write' ? 'Can edit' : 'Can view'}
</div>
</div>
<button
onClick={() => revoke(e)}
style={{
padding: '4px 11px', borderRadius: 7, flexShrink: 0,
border: '1px solid var(--color-border)', background: 'transparent',
color: 'var(--color-text-muted)', fontSize: 11, cursor: 'pointer',
}}
>
Remove
</button>
</div>
))}
</div>
)}
</div>
<div style={{
padding: '12px 22px', borderTop: '1px solid var(--color-border)',
display: 'flex', justifyContent: 'flex-end',
}}>
<button
onClick={onClose}
style={{
padding: '8px 18px', borderRadius: 9,
border: '1px solid var(--color-border)', background: 'transparent',
color: 'var(--color-text)', fontSize: 13, cursor: 'pointer',
}}
>
Done
</button>
</div>
</div>
</div>
)
}
function PersonIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="8" r="4" />
<path d="M4 21v-1a7 7 0 0114 0v1" />
</svg>
)
}
function GroupIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="9" cy="8" r="3.5" />
<path d="M2 21v-1a7 7 0 0114 0v1" />
<path d="M17 11a3 3 0 100-6M19 21v-1a6 6 0 00-3-5.2" />
</svg>
)
}
+101
View File
@@ -0,0 +1,101 @@
import { useState } from 'react'
import type { FolderItem, User } from '../api/client'
import { useNavHistory, type View } from '../hooks/useNavHistory'
import Sidebar from './Sidebar'
import FileBrowser from '../pages/FileBrowser'
import TrashPage from '../pages/TrashPage'
import SharedPage from '../pages/SharedPage'
import SharedWithMePage from '../pages/SharedWithMePage'
import AdminPage from '../pages/AdminPage'
import ProfilePage from '../pages/ProfilePage'
import TagsPage from '../pages/TagsPage'
import { UserMenuProvider } from './UserMenu'
export type { View }
interface Props {
user: User
onLogout: () => Promise<void>
/** Lädt den Nutzer neu – für Speicherverbrauch und Anzeigename. */
onUserChanged: () => Promise<void>
}
export default function Shell({ user, onLogout, onUserChanged }: Props) {
const nav = useNavHistory()
const { view, folderPath } = nav
const [sidebarOpen, setSidebarOpen] = useState(false)
// Aus der Seitenleiste kennen wir nur Kennung und Name – die Kette darueber
// holt die Liste nicht mit, also faengt der Pfad hier beim Ordner selbst an.
function navigateToFolder(folderId: string, folderName: string) {
setSidebarOpen(false)
const stub: FolderItem = {
id: folderId, owner_id: null, group_id: null, parent_id: null,
name: folderName, created_at: '',
}
nav.setFolderPath([stub])
}
return (
<UserMenuProvider value={{
user,
onNavigate: (v) => { nav.setView(v); setSidebarOpen(false) },
onLogout,
onOpenSidebar: () => setSidebarOpen(true),
}}>
<div style={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
{/* Mobile overlay */}
{sidebarOpen && (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 40 }}
onClick={() => setSidebarOpen(false)}
/>
)}
<Sidebar
user={user}
view={view}
activeTagId={nav.tagId}
onOpenTag={nav.setTag}
activeCorrespondentId={nav.correspondentId}
onOpenCorrespondent={nav.setCorrespondent}
onNavigate={(v) => { nav.setView(v); setSidebarOpen(false) }}
onNavigateToFolder={navigateToFolder}
open={sidebarOpen}
/>
{/* Main content */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, overflow: 'hidden' }}>
{view === 'files' && (
<FileBrowser
user={user}
folderPath={folderPath}
onNavigate={nav.setFolderPath}
onUserChanged={onUserChanged}
/>
)}
{view === 'trash' && <TrashPage onUserChanged={onUserChanged} />}
{view === 'incoming' && <SharedWithMePage onOpenFolder={navigateToFolder} />}
{view === 'shared' && <SharedPage />}
{view === 'admin' && <AdminPage />}
{view === 'profile' && <ProfilePage user={user} onUserChanged={onUserChanged} />}
{/* Etikett und Absender teilen sich die Seite – es ist dieselbe Frage
(„alles mit diesem Merkmal"), nur ein anderes Merkmal. */}
{(view === 'tags' || view === 'from') && (
<TagsPage
tagId={view === 'tags' ? nav.tagId : undefined}
correspondentId={view === 'from' ? nav.correspondentId : undefined}
onOpenFolder={(id, name) => {
// Klick auf einen Treffer springt zum Ordner – dieselbe Regel wie
// in der Suche: Geöffnet wird per Doppelklick auf die Datei.
if (id) navigateToFolder(id, name)
else nav.setFolderPath([])
}}
/>
)}
</div>
</div>
</UserMenuProvider>
)
}
+745
View File
@@ -0,0 +1,745 @@
import { useState, useEffect, useRef } from 'react'
import { favoritesApi, foldersApi, tagsApi, correspondentsApi, tagsChangedEvent, notifyTagsChanged, type Correspondent, type FavoriteItem, type FolderItem, type Tag } from '../api/client'
import { tagColor } from './TagChip'
import TagEditDialog, { CorrespondentRenameDialog } from './TagEditDialog'
import ContextMenu from './ContextMenu'
import ConfirmDialog from './ConfirmDialog'
import { RenameIcon, TrashIcon } from './icons'
import type { User } from '../api/client'
import type { View } from './Shell'
import { useAppSettings } from '../hooks/useAppSettings'
interface Props {
user: User
/** Gerade angezeigtes Etikett – für die Hervorhebung in der Liste. */
activeTagId?: string
onOpenTag: (tagId: string) => void
activeCorrespondentId?: string
onOpenCorrespondent: (id: string) => void
view: View
onNavigate: (v: View) => void
onNavigateToFolder: (folderId: string, folderName: string) => void
open: boolean
}
const nav: { id: View; label: string; icon: React.ReactNode }[] = [
{
id: 'files',
label: 'Home',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 10.5L12 3l9 7.5" />
<path d="M5.5 9.5V20a1 1 0 001 1h11a1 1 0 001-1V9.5" />
</svg>
),
},
{
id: 'trash',
label: 'Trash',
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 6" />
<path d="M10 11v6M14 11v6M9 6V4h6v2" />
</svg>
),
},
{
id: 'incoming',
label: 'Shared with me',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
<circle cx="12" cy="13" r="2" />
<path d="M8.5 18a3.5 3.5 0 017 0" />
</svg>
),
},
{
id: 'shared',
label: 'Shared links',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
</svg>
),
},
// „Admin" stand hier bis zum 23.08.2026. Der Eintrag ist ins Nutzermenü oben
// rechts gewandert: Diese Leiste zeigt *Orte, an denen Dateien liegen* –
// Home, Papierkorb, Geteiltes. Die Verwaltung ist keiner davon, und mit einem
// Stern-Symbol zwischen Ordnersymbolen sah sie auch nicht danach aus.
]
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 ChevronIcon({ expanded }: { expanded: boolean }) {
return (
<svg
width="12" height="12" viewBox="0 0 24 24"
fill="none" stroke="currentColor" strokeWidth="2.5"
style={{ transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 0.15s', flexShrink: 0 }}
>
<polyline points="9 18 15 12 9 6" />
</svg>
)
}
function FolderIcon({ size = 14 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ flexShrink: 0 }}>
<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
</svg>
)
}
interface SubfolderRowProps {
folder: FolderItem
onNavigate: (id: string, name: string) => void
}
function SubfolderRow({ folder, onNavigate }: SubfolderRowProps) {
return (
<button
onClick={() => onNavigate(folder.id, folder.name)}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 6,
padding: '5px 10px 5px 28px', borderRadius: 6,
border: 'none', cursor: 'pointer', background: 'transparent',
color: 'var(--color-text-muted)', fontSize: 12, textAlign: 'left',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--color-surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<FolderIcon size={12} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{folder.name}</span>
</button>
)
}
interface FavoriteRowProps {
fav: FavoriteItem
onNavigate: (id: string, name: string) => void
onRemove: (fav: FavoriteItem) => void
onToggleExpand: (fav: FavoriteItem) => void
onDragStart: (e: React.DragEvent, fav: FavoriteItem) => void
onDragOver: (e: React.DragEvent, fav: FavoriteItem) => void
onDrop: (e: React.DragEvent, fav: FavoriteItem) => void
dragOverId: string | null
}
function FavoriteRow({ fav, onNavigate, onRemove, onToggleExpand, onDragStart, onDragOver, onDrop, dragOverId }: FavoriteRowProps) {
const [subfolders, setSubfolders] = useState<FolderItem[] | null>(null)
const [showCtx, setShowCtx] = useState(false)
const loaded = useRef(false)
useEffect(() => {
if (fav.is_expanded && !loaded.current) {
loaded.current = true
foldersApi.list(fav.folder_id)
.then(data => setSubfolders(data ?? []))
.catch(() => setSubfolders([]))
}
}, [fav.is_expanded, fav.folder_id])
function handleToggle() {
if (!loaded.current && !fav.is_expanded) {
loaded.current = true
foldersApi.list(fav.folder_id)
.then(data => setSubfolders(data ?? []))
.catch(() => setSubfolders([]))
}
onToggleExpand(fav)
}
const isOver = dragOverId === fav.folder_id
return (
<div>
<div
draggable
onDragStart={e => onDragStart(e, fav)}
onDragOver={e => onDragOver(e, fav)}
onDrop={e => onDrop(e, fav)}
onDragEnd={() => {}}
style={{
display: 'flex', alignItems: 'center', gap: 4,
padding: '6px 6px 6px 8px', borderRadius: 7,
background: isOver ? 'rgba(var(--color-primary-rgb), 0.12)' : 'transparent',
cursor: 'grab',
position: 'relative',
}}
onMouseEnter={e => { if (!isOver) e.currentTarget.style.background = 'var(--color-surface-2)' }}
onMouseLeave={e => { if (!isOver) e.currentTarget.style.background = 'transparent' }}
>
{/* Expand toggle */}
<button
onClick={handleToggle}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center' }}
>
<ChevronIcon expanded={fav.is_expanded} />
</button>
<FolderIcon size={14} />
{/* Name — click navigates */}
<button
onClick={() => onNavigate(fav.folder_id, fav.folder_name)}
style={{
flex: 1, background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--color-text)', fontSize: 13, textAlign: 'left',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
padding: '0 4px',
}}
>
{fav.folder_name}
</button>
{/* Context: remove */}
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowCtx(v => !v)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--color-text-muted)', opacity: 0.6, display: 'flex' }}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="5" r="1" fill="currentColor" />
<circle cx="12" cy="12" r="1" fill="currentColor" />
<circle cx="12" cy="19" r="1" fill="currentColor" />
</svg>
</button>
{showCtx && (
<div
style={{
position: 'absolute', right: 0, top: '100%', zIndex: 200,
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 8, padding: 4, minWidth: 140,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
}}
>
<button
onClick={() => { setShowCtx(false); onRemove(fav) }}
style={{
width: '100%', padding: '6px 10px', background: 'none',
border: 'none', cursor: 'pointer', textAlign: 'left',
fontSize: 12, color: 'var(--color-danger)', borderRadius: 6,
}}
>
Remove from favourites
</button>
</div>
)}
</div>
</div>
{/* Subfolders */}
{fav.is_expanded && subfolders && subfolders.length > 0 && (
<div>
{subfolders.map(sf => (
<SubfolderRow key={sf.id} folder={sf} onNavigate={onNavigate} />
))}
</div>
)}
</div>
)
}
export default function Sidebar({ user, view, activeTagId, onOpenTag,
activeCorrespondentId, onOpenCorrespondent,
onNavigate, onNavigateToFolder, open }: Props) {
const { cloudName, logoUrl } = useAppSettings()
// Der Anzeigename ist optional – ohne ihn bleibt der Anmeldename stehen.
/**
* Etiketten in der Seitenleiste – die zweite Art, an Dateien zu kommen.
*
* Bewusst neben den Favoriten und nicht in der Hauptnavigation: Favoriten
* sind Orte, Etiketten sind Sichten quer über alle Orte. Beides gehört in
* den unteren, persönlichen Teil der Leiste, nicht zu Home und Papierkorb.
*
* Neu geladen wird bei jedem Ansichtswechsel: Die Zählstände ändern sich,
* sobald irgendwo etikettiert wird, und eine Zahl, die tagelang falsch
* stehenbleibt, ist schlimmer als keine.
*/
const [tags, setTags] = useState<Tag[]>([])
/**
* Rechtsklick auf ein Etikett öffnet erst ein kleines Menü, nicht sofort den
* Bearbeiten-Dialog. Ein Fenster, das ungefragt aufspringt, überrumpelt –
* und bei Dateien und Ordnern verhält sich der Rechtsklick genauso.
*/
const [tagMenu, setTagMenu] = useState<{ x: number; y: number; tag: Tag } | null>(null)
const [editTag, setEditTag] = useState<Tag | null>(null)
const [deleteTag, setDeleteTag] = useState<Tag | null>(null)
/**
* Absender als **eigene** Liste, nicht unter den Etiketten.
*
* Das ist der ganze Punkt des Feldes: „Stadtwerke" und „absetzbar" in einer
* Aufzählung wäre Absender und Verwendungszweck durcheinander – bei zwanzig
* Einträgen unauffällig, bei achtzig unbenutzbar.
*/
const [correspondents, setCorrespondents] = useState<Correspondent[]>([])
const [corrMenu, setCorrMenu] = useState<{ x: number; y: number; c: Correspondent } | null>(null)
const [renameCorr, setRenameCorr] = useState<Correspondent | null>(null)
const [deleteCorr, setDeleteCorr] = useState<Correspondent | null>(null)
useEffect(() => {
const reload = () => {
tagsApi.list().then(setTags).catch(() => { /* ohne Etiketten geht es auch */ })
correspondentsApi.list().then(setCorrespondents).catch(() => { /* ebenso */ })
}
reload()
// Sofort nachziehen, wenn irgendwo etikettiert wird – nicht erst beim
// nächsten Ansichtswechsel. Der Absender hängt am selben Dialog und am
// selben Ereignis.
window.addEventListener(tagsChangedEvent, reload)
return () => window.removeEventListener(tagsChangedEvent, reload)
}, [view, activeTagId, activeCorrespondentId])
const usedBytes = user.used_bytes || 0
const totalBytes = user.quota_bytes || 0
const quotaPct = totalBytes > 0 ? Math.min((usedBytes / totalBytes) * 100, 100) : 0
const [favorites, setFavorites] = useState<FavoriteItem[]>([])
const [dragOverId, setDragOverId] = useState<string | null>(null)
const dragItem = useRef<FavoriteItem | null>(null)
useEffect(() => {
favoritesApi.list()
.then(data => setFavorites(data ?? []))
.catch(() => {})
}, [])
function handleToggleExpand(fav: FavoriteItem) {
const next = !fav.is_expanded
setFavorites(fs => fs.map(f => f.folder_id === fav.folder_id ? { ...f, is_expanded: next } : f))
favoritesApi.setExpanded(fav.folder_id, next).catch(() => {})
}
function handleRemove(fav: FavoriteItem) {
setFavorites(fs => fs.filter(f => f.folder_id !== fav.folder_id))
favoritesApi.remove(fav.folder_id).catch(() => {})
}
function handleDragStart(e: React.DragEvent, fav: FavoriteItem) {
dragItem.current = fav
e.dataTransfer.setData('aether-favorite-id', fav.folder_id)
e.dataTransfer.effectAllowed = 'move'
}
function handleDragOver(e: React.DragEvent, fav: FavoriteItem) {
if (!e.dataTransfer.types.includes('aether-favorite-id')) return
e.preventDefault()
setDragOverId(fav.folder_id)
}
function handleDrop(e: React.DragEvent, target: FavoriteItem) {
e.preventDefault()
setDragOverId(null)
const src = dragItem.current
if (!src || src.folder_id === target.folder_id) return
dragItem.current = null
// Reorder locally
const next = [...favorites]
const srcIdx = next.findIndex(f => f.folder_id === src.folder_id)
const tgtIdx = next.findIndex(f => f.folder_id === target.folder_id)
next.splice(srcIdx, 1)
next.splice(tgtIdx, 0, src)
setFavorites(next)
favoritesApi.reorder(next.map(f => f.folder_id)).catch(() => {})
}
// Expose addFavorite so FileBrowser context menu can call it
;(window as any).__aetherAddFavorite = async (folder: FolderItem) => {
try {
const fav = await favoritesApi.add(folder.id)
setFavorites(fs => [...fs, fav])
} catch { /* already favorite */ }
}
;(window as any).__aetherRemoveFavorite = async (folderId: string) => {
setFavorites(fs => fs.filter(f => f.folder_id !== folderId))
await favoritesApi.remove(folderId).catch(() => {})
}
;(window as any).__aetherIsFavorite = (folderId: string) =>
favorites.some(f => f.folder_id === folderId)
return (
<>
<aside className={`aether-sidebar${open ? ' aether-sidebar--open' : ''}`}>
{/* Brand */}
<div style={{ padding: '20px 16px 16px', display: 'flex', alignItems: 'center', gap: 10 }}>
{logoUrl ? (
<img
src={logoUrl}
alt=""
style={{ height: 30, maxWidth: 120, objectFit: 'contain', flexShrink: 0 }}
/>
) : (
<div style={{
width: 30, height: 30, borderRadius: 8,
background: 'var(--color-primary)',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<svg width="16" height="16" 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: 16, color: 'var(--color-text)', letterSpacing: '-0.01em' }}>
{cloudName}
</span>
</div>
{/* Nav */}
<nav style={{ padding: '4px 8px' }}>
{nav.map(item => {
const active = view === item.id
return (
<button
key={item.id}
onClick={() => onNavigate(item.id)}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 10,
padding: '8px 10px', borderRadius: 8,
border: 'none', cursor: 'pointer',
background: active ? 'rgba(var(--color-primary-rgb), 0.15)' : 'transparent',
// Streifen links in der Akzentfarbe – die zweite Farbe braucht
// eine Stelle, an der man sie tatsächlich sieht.
boxShadow: active ? 'inset 3px 0 0 var(--color-accent)' : 'none',
color: active ? 'var(--color-primary)' : 'var(--color-text-muted)',
fontWeight: active ? 600 : 400, fontSize: 14, textAlign: 'left',
transition: 'background 0.15s, color 0.15s', marginBottom: 2,
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'var(--color-surface-2)' }}
onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'transparent' }}
>
{item.icon}
{item.label}
</button>
)
})}
</nav>
{/* Favourites */}
{favorites.length > 0 && (
<div style={{ padding: '8px 8px 0' }}>
<div style={{
fontSize: 10, fontWeight: 600, letterSpacing: '0.08em',
color: 'var(--color-text-muted)', padding: '4px 10px 6px',
textTransform: 'uppercase',
}}>
Favourites
</div>
<div onDragLeave={() => setDragOverId(null)}>
{favorites.map(fav => (
<FavoriteRow
key={fav.folder_id}
fav={fav}
onNavigate={(id, name) => { onNavigate('files'); onNavigateToFolder(id, name) }}
onRemove={handleRemove}
onToggleExpand={handleToggleExpand}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
dragOverId={dragOverId}
/>
))}
</div>
</div>
)}
{/* Etiketten */}
{tags.length > 0 && (
<div style={{ padding: '8px 8px 0' }}>
<div style={{
fontSize: 10, fontWeight: 600, letterSpacing: '0.08em',
color: 'var(--color-text-muted)', padding: '4px 10px 6px',
textTransform: 'uppercase',
}}>
Tags
</div>
{tags.map(tag => {
const active = view === 'tags' && activeTagId === tag.id
const color = tagColor(tag)
return (
<button
key={tag.id}
onClick={() => onOpenTag(tag.id)}
// Rechtsklick öffnet Umbenennen/Farbe/Löschen – dieselbe
// Geste wie bei Dateien und Ordnern.
onContextMenu={e => {
e.preventDefault()
setTagMenu({ x: e.clientX, y: e.clientY, tag })
}}
title={(tag.group_id ? `${tag.name} — shared with ${tag.group_name}` : tag.name) + ' · right-click to edit'}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 9,
padding: '6px 10px', borderRadius: 8, border: 'none', cursor: 'pointer',
background: active ? 'rgba(var(--color-primary-rgb), 0.15)' : 'transparent',
color: active ? 'var(--color-text)' : 'var(--color-text-muted)',
fontSize: 13, textAlign: 'left', marginBottom: 1,
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'var(--color-surface-2)' }}
onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'transparent' }}
>
{/* Symbol, wenn eins gewählt ist – sonst der Farbpunkt.
Nicht beides: Zwei Zeichen nebeneinander machen die
Zeile unruhig, und das Symbol sagt ohnehin mehr. Ohne
Symbol bleibt die Farbe die Wiedererkennung. */}
{tag.icon ? (
<span style={{
width: 14, flexShrink: 0, fontSize: 13, lineHeight: 1,
textAlign: 'center',
}}>{tag.icon}</span>
) : (
<span style={{
width: 8, height: 8, marginLeft: 3, marginRight: 3,
borderRadius: '50%', background: color, flexShrink: 0,
}} />
)}
<span style={{
flex: 1, minWidth: 0, overflow: 'hidden',
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{tag.name}
</span>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', flexShrink: 0 }}>
{tag.file_count}
</span>
</button>
)
})}
</div>
)}
{/* Absender */}
{correspondents.length > 0 && (
<div style={{ padding: '8px 8px 0' }}>
<div style={{
fontSize: 10, fontWeight: 600, letterSpacing: '0.08em',
color: 'var(--color-text-muted)', padding: '4px 10px 6px',
textTransform: 'uppercase',
}}>
From
</div>
{correspondents.map(c => {
const active = view === 'from' && activeCorrespondentId === c.id
return (
<button
key={c.id}
onClick={() => onOpenCorrespondent(c.id)}
onContextMenu={e => {
e.preventDefault()
setCorrMenu({ x: e.clientX, y: e.clientY, c })
}}
title={(c.group_id ? `${c.name} — shared with ${c.group_name}` : c.name) + ' · right-click to edit'}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 9,
padding: '6px 10px', borderRadius: 8, border: 'none', cursor: 'pointer',
background: active ? 'rgba(var(--color-primary-rgb), 0.15)' : 'transparent',
color: active ? 'var(--color-text)' : 'var(--color-text-muted)',
fontSize: 13, textAlign: 'left', marginBottom: 1,
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'var(--color-surface-2)' }}
onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'transparent' }}
>
{/* Gewähltes Symbol, sonst der Umschlag. Kein Farbpunkt wie
beim Etikett: Ein Absender hat keine Farbe, und ein grauer
Punkt wäre ein Etikett, das nichts bedeutet. */}
{c.icon ? (
<span style={{
width: 14, flexShrink: 0, fontSize: 13, lineHeight: 1,
textAlign: 'center',
}}>{c.icon}</span>
) : (
<span style={{ display: 'flex', flexShrink: 0, opacity: 0.75, width: 14, justifyContent: 'center' }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="5" width="20" height="14" rx="2" />
<polyline points="2.5 6.5 12 13 21.5 6.5" />
</svg>
</span>
)}
<span style={{
flex: 1, minWidth: 0, overflow: 'hidden',
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{c.name}
</span>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', flexShrink: 0 }}>
{c.file_count}
</span>
</button>
)
})}
</div>
)}
<div style={{ flex: 1 }} />
{/* Unten links steht nur noch, was zum *Ort* gehört: wie voll er ist.
Der Nutzerbereich sitzt seit dem 23.08.2026 oben rechts in der
Kopfleiste (components/UserMenu.tsx) – dort suchen Menschen den
eigenen Zugang, und das Abmelden liegt nicht mehr einen Fehlklick
neben dem Weg zu den Einstellungen. */}
{totalBytes > 0 && (
<div style={{
// Unterer Abstand plus Wischbalken – sonst läge der Balken in der
// installierten App hinter der Systemleiste.
padding: '12px 16px calc(16px + var(--safe-bottom))',
borderTop: '1px solid var(--color-border)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>Storage</span>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
{formatBytes(usedBytes)} / {formatBytes(totalBytes)}
</span>
</div>
<div style={{ height: 4, background: 'var(--color-surface-2)', borderRadius: 999 }}>
<div style={{
width: `${quotaPct}%`, height: '100%', borderRadius: 999,
background: quotaPct > 90 ? 'var(--color-danger)' : 'var(--color-primary)',
transition: 'width 0.3s',
}} />
</div>
</div>
)}
</aside>
{tagMenu && (
<ContextMenu
x={tagMenu.x}
y={tagMenu.y}
onClose={() => setTagMenu(null)}
items={[
{ label: 'Edit…', icon: <RenameIcon />, onClick: () => setEditTag(tagMenu.tag) },
{ separator: true },
{ label: 'Delete', icon: <TrashIcon />, danger: true, onClick: () => setDeleteTag(tagMenu.tag) },
]}
/>
)}
{corrMenu && (
<ContextMenu
x={corrMenu.x}
y={corrMenu.y}
onClose={() => setCorrMenu(null)}
items={[
{ label: 'Rename…', icon: <RenameIcon />, onClick: () => setRenameCorr(corrMenu.c) },
{ separator: true },
{ label: 'Delete', icon: <TrashIcon />, danger: true, onClick: () => setDeleteCorr(corrMenu.c) },
]}
/>
)}
{renameCorr && (
<CorrespondentRenameDialog
correspondent={renameCorr}
onClose={() => setRenameCorr(null)}
onChanged={updated => {
setCorrespondents(prev => prev.map(c => (c.id === updated.id ? { ...c, ...updated } : c)))
}}
/>
)}
{deleteCorr && (
<ConfirmDialog
title={`Delete “${deleteCorr.name}”?`}
// Die wichtigste Zeile hier: Der Absender verschwindet, die
// Rechnungen bleiben. Ohne diesen Satz klingt „Delete" nach dem
// Gegenteil, und niemand traut sich, aufzuräumen.
message={
deleteCorr.file_count > 0
? `${deleteCorr.file_count} ${deleteCorr.file_count === 1 ? 'document loses' : 'documents lose'} this sender. The documents themselves stay where they are — nothing is deleted.`
: 'Nothing is from this sender yet.'
}
confirmLabel="Delete"
danger
onCancel={() => setDeleteCorr(null)}
onConfirm={async () => {
try {
await correspondentsApi.remove(deleteCorr.id)
setCorrespondents(prev => prev.filter(c => c.id !== deleteCorr.id))
notifyTagsChanged()
} finally {
setDeleteCorr(null)
}
}}
/>
)}
{editTag && (
<TagEditDialog
tag={editTag}
onClose={() => setEditTag(null)}
onChanged={updated => setTags(prev => prev.map(t => t.id === updated.id ? { ...t, ...updated } : t))}
/>
)}
{deleteTag && (
<ConfirmDialog
title={`Delete “${deleteTag.name}”?`}
// Ausdrücklich sagen, was *nicht* passiert. Ein Etikett zu löschen
// ist eine Aussage über die Ordnung, nicht über den Bestand – aber
// das weiß man in dem Moment nicht, und dann klickt man lieber ab.
message={
deleteTag.file_count > 0
? `The tag is removed from ${deleteTag.file_count} ${deleteTag.file_count === 1 ? 'file' : 'files'}. The files themselves stay where they are — nothing is deleted.`
: 'No file carries this tag. Nothing else changes.'
}
confirmLabel="Delete tag"
danger
onCancel={() => setDeleteTag(null)}
onConfirm={async () => {
try {
await tagsApi.remove(deleteTag.id)
notifyTagsChanged()
setTags(prev => prev.filter(t => t.id !== deleteTag.id))
} catch { /* bleibt stehen, dann sieht man es beim nächsten Laden */ }
setDeleteTag(null)
}}
/>
)}
<style>{`
.aether-sidebar {
width: 220px;
/* Statusleiste oben, Aussparung links (Querformat). */
padding-top: var(--safe-top);
padding-left: var(--safe-left);
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--color-surface);
border-right: 1px solid var(--color-border);
height: 100vh;
overflow-y: auto;
overflow-x: hidden;
}
@media (max-width: 767px) {
.aether-sidebar {
position: fixed;
top: 0; left: 0;
z-index: 50;
transform: translateX(-100%);
transition: transform 0.2s ease;
}
.aether-sidebar--open {
transform: translateX(0);
}
}
`}</style>
</>
)
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Die Reiterleiste – eine Definition für Admin-Bereich und Einstellungen.
*
* Beide hatten sie zunächst eigen im Code stehen. Zwei Kopien derselben Leiste
* laufen mit der Zeit auseinander: Die eine bekommt eine andere Schriftstärke,
* die andere einen anderen Abstand, und beim Umschalten merkt man den
* Unterschied, ohne ihn benennen zu können. Genau das ist bei den Kopfleisten
* schon einmal passiert (siehe components/PageHeader.tsx).
*/
interface Props<T extends string> {
tabs: { id: T; label: string }[]
active: T
onChange: (id: T) => void
}
export default function Tabs<T extends string>({ tabs, active, onChange }: Props<T>) {
return (
<div style={{
display: 'flex', gap: 4, marginBottom: 24,
borderBottom: '1px solid var(--color-border)',
// Ein Pixel Innenrand, damit die Unterstreichung des aktiven Reiters
// genau auf der Trennlinie sitzt statt darunter.
paddingBottom: 1,
overflowX: 'auto',
}}>
{tabs.map(t => (
<button
key={t.id}
onClick={() => onChange(t.id)}
style={{
padding: '8px 18px', border: 'none', cursor: 'pointer', fontSize: 14,
background: 'transparent', whiteSpace: 'nowrap', flexShrink: 0,
color: active === t.id ? 'var(--color-primary)' : 'var(--color-text-muted)',
fontWeight: active === t.id ? 700 : 400,
borderBottom: active === t.id ? '2px solid var(--color-primary)' : '2px solid transparent',
marginBottom: -1,
transition: 'color 0.15s',
}}
>
{t.label}
</button>
))}
</div>
)
}
+84
View File
@@ -0,0 +1,84 @@
import type { Tag } from '../api/client'
/**
* Die Farbe eines Etiketts.
*
* Ohne eigene Farbe wird eine aus dem Namen abgeleitet – immer dieselbe für
* denselben Namen. Der Grund: Ein frisch angelegtes Etikett soll nicht grau
* sein und erst eingefärbt werden müssen, aber zwei verschiedene Etiketten
* sollen auch nicht zufällig gleich aussehen.
*/
export const tagPalette = [
'#8b5cf6', '#ec4899', '#06b6d4', '#ef4444', '#f59e0b',
'#22c55e', '#3b82f6', '#14b8a6', '#ea580c', '#6366f1',
]
const palette = tagPalette
export function tagColor(tag: { name: string; color?: string }): string {
if (tag.color) return tag.color
// Einfache Summe über die Zeichen. Keine Streuwertfunktion nötig – es geht
// nur darum, dass dieselbe Eingabe stets dieselbe Farbe ergibt.
let sum = 0
for (const ch of tag.name) sum += ch.codePointAt(0) ?? 0
return palette[sum % palette.length]
}
/** Aufkleber, wie er an Kacheln und in Listen erscheint. */
export default function TagChip({ tag, size = 'md', onRemove }: {
tag: Pick<Tag, 'name' | 'color'> & { icon?: string; group_id?: string }
size?: 'sm' | 'md'
onRemove?: () => void
}) {
const color = tagColor(tag)
const small = size === 'sm'
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 5,
padding: small ? '1px 6px' : '3px 9px',
borderRadius: 999,
fontSize: small ? 10 : 12,
fontWeight: 600,
lineHeight: 1.5,
color,
background: `${color}22`,
border: `1px solid ${color}55`,
maxWidth: '100%',
overflow: 'hidden',
}}>
{/* Gruppen-Etiketten bekommen ein Zeichen davor: Sonst wundert man sich,
warum ein Etikett plötzlich bei jemand anderem auftaucht. */}
{tag.group_id && (
<svg width={small ? 8 : 10} height={small ? 8 : 10} viewBox="0 0 24 24"
fill="none" stroke="currentColor" strokeWidth="2.5" style={{ flexShrink: 0 }}>
<circle cx="9" cy="8" r="3" /><path d="M3 20v-1a5 5 0 015-5h2a5 5 0 015 5v1" />
<path d="M17 11a3 3 0 100-6" /><path d="M20 20v-1a4 4 0 00-2.5-3.7" />
</svg>
)}
{/* Das Symbol steht vor dem Namen und **behält seine eigenen Farben**:
Ein Emoji einzufärben ginge nicht, und es soll auch nicht – gerade
die Farbigkeit macht es auf einen Blick unterscheidbar. */}
{tag.icon && (
<span style={{ flexShrink: 0, fontSize: small ? 10 : 12, lineHeight: 1 }}>
{tag.icon}
</span>
)}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{tag.name}
</span>
{onRemove && (
<button
onClick={e => { e.stopPropagation(); onRemove() }}
title={`Remove ${tag.name}`}
style={{
border: 'none', background: 'none', cursor: 'pointer', padding: 0,
color, display: 'flex', flexShrink: 0, opacity: 0.7,
}}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
<line x1="5" y1="5" x2="19" y2="19" /><line x1="19" y1="5" x2="5" y2="19" />
</svg>
</button>
)}
</span>
)
}
+340
View File
@@ -0,0 +1,340 @@
import { useEffect, useState } from 'react'
import { tagsApi, correspondentsApi, notifyTagsChanged, APIError, type Correspondent, type FileItem, type Tag } from '../api/client'
import TagChip, { tagColor, tagPalette } from './TagChip'
import { ColourPicker, IconPicker } from './TagEditDialog'
/**
* Etiketten, Belegdatum und Absender einer Datei setzen.
*
* Alles drei in einem Dialog und in einem Aufruf: Wer eine Rechnung
* einsortiert, setzt „steuer-2026", das Rechnungsdatum und „Stadtwerke" in
* derselben Handlung. Der Server schreibt sie in einer Transaktion – entweder
* gilt alles oder nichts.
*/
interface Props {
file: FileItem
/** Etiketten, die diese Datei schon trägt. */
current: Tag[]
/** Absender, den sie schon trägt – oder keiner. */
currentCorrespondent?: Correspondent
onClose: () => void
onSaved: (tags: Tag[]) => void
}
export default function TagDialog({ file, current, currentCorrespondent, onClose, onSaved }: Props) {
const [all, setAll] = useState<Tag[]>([])
const [chosen, setChosen] = useState<Set<string>>(() => new Set(current.map(t => t.id)))
// Der Server liefert einen vollen Zeitstempel („2026-01-15T00:00:00Z"), ein
// <input type="date"> versteht aber nur „2026-01-15" und zeigt sonst
// kommentarlos ein leeres Feld – der gesetzte Wert sah damit aus wie keiner.
const [date, setDate] = useState((file.document_date ?? '').slice(0, 10))
const [newName, setNewName] = useState('')
/** Farbe für das nächste neue Etikett. Vorgewählt ist eine aus der Palette,
* damit man nichts wählen *muss* – aber sichtbar, damit man es kann. */
const [newColor, setNewColor] = useState(tagPalette[0])
/** Symbol für das nächste neue Etikett. Leer ist die Vorgabe – ein Emoji
* ist ein Angebot, keine Pflicht. */
const [newIcon, setNewIcon] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
/**
* Der Absender steht als **Text** im Feld, nicht als ausgewählte ID.
*
* Ein Auswahlfeld zwänge dazu, den Absender vorher irgendwo anzulegen –
* beim Einsortieren des ersten Belegs von „Stadtwerke" gäbe es ihn aber
* noch gar nicht. Ein Textfeld mit Vorschlägen kann beides: das Vorhandene
* treffen (Vorschlagsliste) und Neues anlegen (einfach tippen).
*
* Der Server sucht den Namen und legt ihn nur an, wenn es ihn nicht gibt –
* ohne Rücksicht auf Groß-/Kleinschreibung. „stadtwerke" landet also beim
* vorhandenen „Stadtwerke" und erzeugt keinen Zwilling.
*/
const [correspondent, setCorrespondent] = useState(currentCorrespondent?.name ?? '')
const [knownCorrespondents, setKnownCorrespondents] = useState<Correspondent[]>([])
useEffect(() => {
tagsApi.list().then(setAll).catch(() => setError('Could not load tags'))
correspondentsApi.list().then(setKnownCorrespondents).catch(() => {
/* ohne Vorschläge geht es auch – tippen kann man trotzdem */
})
}, [])
/**
* Nur Etiketten desselben Eigentümers sind erlaubt – eine Datei im
* Gruppenordner trägt Gruppen-Etiketten, eine eigene die eigenen. Der Server
* lehnt alles andere ab; die Liste hier zeigt deshalb gar nicht erst, was
* nicht ginge.
*/
const usable = all.filter(t => (file.group_id ? t.group_id === file.group_id : !t.group_id))
/**
* Etikett anlegen – oder das gleichnamige nehmen, wenn es es schon gibt.
*
* **Warum kein Fehler bei einem vorhandenen Namen:** Die Liste in diesem
* Dialog wird beim Öffnen einmal geholt. Legt man anderswo ein Etikett an –
* in einem zweiten Fenster, im vorigen Dialog –, kennt sie es nicht. Man
* tippt den Namen, bekommt „That name is already taken", sieht das Etikett
* aber nirgends und hält die Anwendung für kaputt. Genau das ist Marc am
* 24.08.2026 passiert.
*
* Richtig ist ohnehin, was der Mensch meint: Er will die Datei mit „Finanz"
* versehen. Ob das Etikett dafür neu entsteht oder schon existierte, ist
* seine Sache nicht.
*/
async function createTag() {
const name = newName.trim()
if (!name) return
setBusy(true); setError(null)
try {
const tag = await tagsApi.create(name, newColor, file.group_id ?? undefined, newIcon)
setAll(prev => [...prev, tag])
notifyTagsChanged()
setChosen(prev => new Set(prev).add(tag.id))
setNewName('')
setNewIcon('')
// Nächste Farbe vorwählen, damit zwei nacheinander angelegte Etiketten
// nicht zufällig gleich aussehen.
setNewColor(prev => tagPalette[(tagPalette.indexOf(prev) + 1) % tagPalette.length])
} catch (err) {
if (err instanceof APIError && err.status === 409) {
// Es gibt es schon – Liste neu holen und das gleichnamige anhaken.
try {
const fresh = await tagsApi.list()
setAll(fresh)
const hit = fresh.find(t => t.name.toLowerCase() === name.toLowerCase())
if (hit) {
setChosen(prev => new Set(prev).add(hit.id))
setNewName('')
return
}
} catch { /* dann eben die Meldung unten */ }
}
// Ab hier sagt der Server, was los war – nicht mehr dieser Dialog. Die
// frühere feste Meldung „That name is already taken" behauptete eine
// Ursache und lag bei jedem anderen Fehler daneben.
setError(err instanceof Error ? err.message : 'Could not create the tag')
} finally {
setBusy(false)
}
}
async function save() {
setBusy(true); setError(null)
try {
// Ein geleertes Feld heißt „kein Absender mehr" – deshalb die leere ID
// statt des leeren Namens: Ein leerer Name hieße „nicht mitgeschickt".
const name = correspondent.trim()
const tags = await tagsApi.setForFile(file.id, [...chosen], date,
name ? { name } : { id: '' })
notifyTagsChanged()
onSaved(tags)
onClose()
} catch (err) {
// Auch hier: die Meldung des Servers statt einer geratenen. „check the
// date" stand vorher selbst dann da, wenn gar kein Datum im Spiel war.
setError(err instanceof Error ? err.message : 'Could not save')
setBusy(false)
}
}
function toggle(id: string) {
setChosen(prev => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
return (
<div
onClick={onClose}
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 200, padding: 20,
}}
>
<div
onClick={e => e.stopPropagation()}
style={{
width: 460, maxWidth: '100%', maxHeight: '85vh', overflowY: 'auto',
background: 'var(--color-surface)', border: '1px solid var(--color-border)',
borderRadius: 14, padding: 22, boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
}}
>
<h3 style={{ margin: '0 0 4px', fontSize: 15, fontWeight: 700, color: 'var(--color-text)' }}>
Organise
</h3>
<p style={{
margin: '0 0 18px', fontSize: 12, color: 'var(--color-text-muted)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{file.name}
</p>
{usable.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 7, marginBottom: 18 }}>
{usable.map(tag => {
const on = chosen.has(tag.id)
const color = tagColor(tag)
return (
<button
key={tag.id}
onClick={() => toggle(tag.id)}
style={{
padding: '4px 11px', borderRadius: 999, cursor: 'pointer',
fontSize: 12, fontWeight: 600,
// Gewählt heißt gefüllt, ungewählt nur umrandet. Ein
// Häkchen daneben wäre bei zwanzig Etiketten unruhig.
color: on ? color : 'var(--color-text-muted)',
background: on ? `${color}22` : 'transparent',
border: `1px solid ${on ? `${color}88` : 'var(--color-border)'}`,
transition: 'all 0.12s',
}}
>
{tag.name}
</button>
)
})}
</div>
)}
{/* Neues Etikett direkt hier anlegen. Ein eigener Verwaltungsdialog
wäre ein Umweg: Man merkt beim Einsortieren, dass eins fehlt. */}
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
<input
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void createTag() } }}
placeholder={file.group_id ? 'New group tag…' : 'New tag…'}
style={{
flex: 1, padding: '7px 10px', borderRadius: 8, fontSize: 13,
border: '1px solid var(--color-border)', background: 'var(--color-surface-2)',
color: 'var(--color-text)', outline: 'none',
}}
/>
<button
onClick={() => void createTag()}
disabled={!newName.trim() || busy}
style={{
padding: '7px 14px', borderRadius: 8, fontSize: 12, fontWeight: 600,
border: '1px solid var(--color-border)', background: 'var(--color-surface-2)',
color: 'var(--color-text)', cursor: newName.trim() ? 'pointer' : 'default',
opacity: newName.trim() ? 1 : 0.5,
}}
>
Add
</button>
</div>
{/* Farbwahl erscheint erst beim Tippen: Solange man nichts anlegt, ist
sie nur Fläche, die vom Wesentlichen ablenkt. */}
{newName.trim() && (
<div style={{ marginBottom: 18 }}>
<ColourPicker value={newColor} onChange={setNewColor} />
<div style={{ marginTop: 12 }}>
<IconPicker value={newIcon} onChange={setNewIcon} />
</div>
</div>
)}
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 4 }}>
From
</label>
<input
list="aether-correspondents"
value={correspondent}
onChange={e => setCorrespondent(e.target.value)}
placeholder="Stadtwerke, Amazon, Finanzamt…"
maxLength={80}
style={{
width: '100%', padding: '7px 10px', borderRadius: 8, fontSize: 13,
border: '1px solid var(--color-border)', background: 'var(--color-surface-2)',
color: 'var(--color-text)', outline: 'none',
}}
/>
{/* Vorschläge aus dem Bestand. Ein <datalist> statt einer eigenen
Auswahlliste: Der Browser bringt Filtern, Tastaturbedienung und die
Darstellung am Telefon schon mit, und tippen bleibt trotzdem
möglich – genau das braucht ein Feld, das Vorhandenes treffen *und*
Neues anlegen können muss. */}
<datalist id="aether-correspondents">
{knownCorrespondents
.filter(c => (file.group_id ? c.group_id === file.group_id : !c.group_id))
.map(c => <option key={c.id} value={c.name} />)}
</datalist>
<p style={{ margin: '6px 0 18px', fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
Who the document is from. Exactly one — unlike tags. A new name is
created as you save it.
</p>
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 4 }}>
Document date
</label>
<input
type="date"
value={date}
onChange={e => setDate(e.target.value)}
style={{
width: '100%', padding: '7px 10px', borderRadius: 8, fontSize: 13,
border: '1px solid var(--color-border)', background: 'var(--color-surface-2)',
color: 'var(--color-text)', outline: 'none', colorScheme: 'dark',
}}
/>
<p style={{ margin: '6px 0 0', fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
The date printed on the document — not when you uploaded it. A receipt
from December scanned in January still belongs to the old year.
</p>
{error && (
<p style={{ margin: '14px 0 0', fontSize: 12, color: 'var(--color-danger)' }}>{error}</p>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10, marginTop: 22 }}>
<button
onClick={onClose}
style={{
padding: '8px 16px', border: 'none', background: 'none', cursor: 'pointer',
fontSize: 13, color: 'var(--color-text-muted)',
}}
>
Cancel
</button>
<button
onClick={() => void save()}
disabled={busy}
style={{
padding: '8px 18px', borderRadius: 8, border: 'none', cursor: 'pointer',
fontSize: 13, fontWeight: 600, color: '#fff',
background: 'var(--color-primary)', opacity: busy ? 0.6 : 1,
}}
>
{busy ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
)
}
/** Kleine Reihe Aufkleber, wie sie unter einer Kachel steht. */
export function TagRow({ tags, max = 3 }: { tags: Tag[]; max?: number }) {
if (tags.length === 0) return null
const shown = tags.slice(0, max)
const rest = tags.length - shown.length
return (
<div style={{
display: 'flex', gap: 4, minWidth: 0,
// Kein Umbruch: In einer Kachelzeile ist eine zweite Zeile genau das,
// was hier vermieden werden soll. Was nicht passt, wird zu „+n".
overflow: 'hidden',
}}>
{shown.map(t => <TagChip key={t.id} tag={t} size="sm" />)}
{rest > 0 && (
<span style={{ fontSize: 10, color: 'var(--color-text-muted)', alignSelf: 'center' }}>
+{rest}
</span>
)}
</div>
)
}
+322
View File
@@ -0,0 +1,322 @@
import { useState } from 'react'
import { tagsApi, correspondentsApi, notifyTagsChanged, type Correspondent, type Tag } from '../api/client'
import { tagPalette, tagColor } from './TagChip'
/**
* Ein Etikett umbenennen, einfärben oder löschen.
*
* Erreichbar über Rechtsklick in der Seitenleiste – dort, wo die Etiketten
* stehen. Ein eigener Verwaltungsbereich im Admin-Panel wäre der falsche Ort:
* Etiketten gehören dem Einzelnen, nicht der Anlage.
*/
export default function TagEditDialog({ tag, onClose, onChanged }: {
tag: Tag
onClose: () => void
onChanged: (tag: Tag) => void
}) {
const [name, setName] = useState(tag.name)
const [color, setColor] = useState(tag.color || tagColor(tag))
const [icon, setIcon] = useState(tag.icon ?? '')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function save() {
if (!name.trim()) { setError('Name must not be empty'); return }
setBusy(true); setError(null)
try {
const updated = await tagsApi.update(tag.id, name.trim(), color, icon)
notifyTagsChanged()
onChanged(updated)
onClose()
} catch {
setError('That name is already taken')
setBusy(false)
}
}
return (
<div
onClick={onClose}
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 220, padding: 20,
}}
>
<div
onClick={e => e.stopPropagation()}
style={{
width: 380, maxWidth: '100%',
background: 'var(--color-surface)', border: '1px solid var(--color-border)',
borderRadius: 14, padding: 22, boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
}}
>
<h3 style={{ margin: '0 0 18px', fontSize: 15, fontWeight: 700, color: 'var(--color-text)' }}>
Edit tag
</h3>
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 4 }}>
Name
</label>
<input
value={name}
autoFocus
onChange={e => setName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') void save() }}
style={{
width: '100%', padding: '8px 11px', borderRadius: 8, fontSize: 13, marginBottom: 18,
border: '1px solid var(--color-border)', background: 'var(--color-surface-2)',
color: 'var(--color-text)', outline: 'none',
}}
/>
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 8 }}>
Colour
</label>
<ColourPicker value={color} onChange={setColor} />
<div style={{ marginTop: 16, marginBottom: 4, fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)' }}>
Icon
</div>
<IconPicker value={icon} onChange={setIcon} />
{/* Vorschau: Die Farbe wirkt am Aufkleber anders als am nackten Punkt,
weil sie dort nur als Rahmen und Hintergrundhauch auftaucht. */}
<div style={{ marginTop: 14, fontSize: 11, color: 'var(--color-text-muted)' }}>
Preview:{' '}
<span style={{
display: 'inline-flex', padding: '3px 9px', borderRadius: 999,
fontSize: 12, fontWeight: 600, color,
background: `${color}22`, border: `1px solid ${color}55`,
}}>
{icon && <span style={{ marginRight: 5 }}>{icon}</span>}
{name.trim() || 'tag'}
</span>
</div>
{error && <p style={{ margin: '14px 0 0', fontSize: 12, color: 'var(--color-danger)' }}>{error}</p>}
{/* Löschen sitzt im Menü davor, nicht hier: Ein Dialog, in dem
„Save" und „Delete" nebeneinander liegen, lädt zum Fehlgriff ein. */}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 24, gap: 10 }}>
<button
onClick={onClose}
style={{
padding: '8px 16px', border: 'none', background: 'none', cursor: 'pointer',
fontSize: 13, color: 'var(--color-text-muted)',
}}
>
Cancel
</button>
<button
onClick={() => void save()}
disabled={busy}
style={{
padding: '8px 18px', borderRadius: 8, border: 'none', cursor: 'pointer',
fontSize: 13, fontWeight: 600, color: '#fff',
background: 'var(--color-primary)', opacity: busy ? 0.6 : 1,
}}
>
Save
</button>
</div>
</div>
</div>
)
}
/** Farbwahl aus einer festen Reihe. */
/**
* Die angebotenen Symbole.
*
* Bewusst eine **kurze, geordnete Auswahl** statt der vollen Emoji-Tastatur:
* Bei 3000 Möglichkeiten wählt man beim zweiten Etikett ein anderes Bild für
* dieselbe Sache. Zwanzig Vorschläge decken ab, wofür Menschen Dokumente
* ablegen – wer etwas anderes will, tippt es ins Feld daneben.
*/
export const iconChoices = [
'🧾', '💶', '🎓', '📚', '🏛️', '⚡', '🏠', '🚗', '🏥', '🛒',
'📦', '✈️', '🔧', '💼', '📱', '🐾', '🎁', '📄', '⭐', '🔒',
]
/**
* Symbolwahl. „Kein Symbol" steht als erste Möglichkeit dabei – sonst wäre
* ein einmal gewähltes Emoji nicht mehr loszuwerden.
*/
export function IconPicker({ value, onChange }: { value: string; onChange: (i: string) => void }) {
return (
<div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
<button
onClick={() => onChange('')}
title="No icon"
style={{
width: 30, height: 30, borderRadius: 8, cursor: 'pointer',
border: `2px solid ${value === '' ? 'var(--color-text)' : 'var(--color-border)'}`,
background: 'var(--color-surface-2)', color: 'var(--color-text-muted)',
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0,
}}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="5" y1="19" x2="19" y2="5" />
</svg>
</button>
{iconChoices.map(ic => (
<button
key={ic}
onClick={() => onChange(ic)}
style={{
width: 30, height: 30, borderRadius: 8, cursor: 'pointer', fontSize: 15,
border: `2px solid ${value === ic ? 'var(--color-text)' : 'transparent'}`,
background: 'var(--color-surface-2)', padding: 0, lineHeight: 1,
}}
>
{ic}
</button>
))}
</div>
{/* Freies Feld für alles, was nicht in der Auswahl steht. Zwei Wege zum
selben Ziel sind hier richtig: Die Auswahl ist schnell, das Feld ist
vollständig. */}
<input
value={value}
onChange={e => onChange(e.target.value)}
placeholder="or paste any emoji"
maxLength={16}
style={{
marginTop: 8, width: 150, padding: '5px 9px', borderRadius: 8, fontSize: 13,
border: '1px solid var(--color-border)', background: 'var(--color-surface-2)',
color: 'var(--color-text)', outline: 'none',
}}
/>
</div>
)
}
export function ColourPicker({ value, onChange }: { value: string; onChange: (c: string) => void }) {
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{tagPalette.map(c => (
<button
key={c}
onClick={() => onChange(c)}
title={c}
style={{
width: 26, height: 26, borderRadius: '50%', cursor: 'pointer',
background: c,
// Der gewählte Kreis bekommt einen Ring mit Abstand statt eines
// Häkchens: Auf dunklen Farben wäre ein weißes Häkchen kaum zu
// sehen, auf hellen ein schwarzes.
border: value === c ? '2px solid var(--color-surface)' : '2px solid transparent',
boxShadow: value === c ? `0 0 0 2px ${c}` : 'none',
transition: 'box-shadow 0.12s',
}}
/>
))}
</div>
)
}
/**
* Einen Absender umbenennen.
*
* Weniger als beim Etikett – ein Absender hat keine Farbe. Umbenennen ist
* dafür wichtiger als dort: Aus „Amazon" wird „Amazon EU S.à r.l.", und alle
* bisherigen Rechnungen sollen mitkommen. Genau das leistet ein eigenes Feld
* gegenüber einem in den Dateinamen geschriebenen Absender.
*/
export function CorrespondentRenameDialog({ correspondent, onClose, onChanged }: {
correspondent: Correspondent
onClose: () => void
onChanged: (c: Correspondent) => void
}) {
const [name, setName] = useState(correspondent.name)
const [icon, setIcon] = useState(correspondent.icon ?? '')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function save() {
if (!name.trim()) { setError('Name must not be empty'); return }
setBusy(true); setError(null)
try {
const updated = await correspondentsApi.update(correspondent.id, name.trim(), icon)
notifyTagsChanged()
onChanged(updated)
onClose()
} catch {
setError('That name is already taken')
setBusy(false)
}
}
return (
<div
onClick={onClose}
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 300, padding: 20,
}}
>
<div
onClick={e => e.stopPropagation()}
style={{
width: 360, maxWidth: '100%', background: 'var(--color-surface)',
border: '1px solid var(--color-border)', borderRadius: 14, padding: 22,
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
}}
>
<h3 style={{ margin: '0 0 16px', fontSize: 15, fontWeight: 700, color: 'var(--color-text)' }}>
Edit sender
</h3>
<input
value={name}
onChange={e => setName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') void save() }}
autoFocus
maxLength={80}
style={{
width: '100%', padding: '8px 11px', borderRadius: 8, fontSize: 13,
border: '1px solid var(--color-border)', background: 'var(--color-surface-2)',
color: 'var(--color-text)', outline: 'none',
}}
/>
<div style={{ marginTop: 16, marginBottom: 4, fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)' }}>
Icon
</div>
<IconPicker value={icon} onChange={setIcon} />
<p style={{ margin: '10px 0 0', fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
{correspondent.file_count > 0
? `${correspondent.file_count} ${correspondent.file_count === 1 ? 'document keeps' : 'documents keep'} this sender under the new name.`
: 'Nothing is from this sender yet.'}
</p>
{error && (
<p style={{ margin: '12px 0 0', fontSize: 12, color: 'var(--color-danger)' }}>{error}</p>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10, marginTop: 20 }}>
<button
onClick={onClose}
style={{
padding: '8px 16px', border: 'none', background: 'none', cursor: 'pointer',
fontSize: 13, color: 'var(--color-text-muted)',
}}
>
Cancel
</button>
<button
onClick={() => void save()}
disabled={busy}
style={{
padding: '8px 18px', borderRadius: 8, border: 'none', cursor: 'pointer',
fontSize: 13, fontWeight: 600, color: '#fff',
background: 'var(--color-primary)', opacity: busy ? 0.6 : 1,
}}
>
Save
</button>
</div>
</div>
</div>
)
}
+57
View File
@@ -0,0 +1,57 @@
interface UploadItem {
id: string
name: string
progress: number // 0-100
status: 'uploading' | 'done' | 'error'
error?: string
}
interface Props {
items: UploadItem[]
onDismiss: (id: string) => void
}
export type { UploadItem }
export default function UploadQueue({ items, onDismiss }: Props) {
if (items.length === 0) return null
return (
<div className="fixed bottom-4 right-4 w-72 space-y-2 z-40">
{items.map(item => (
<div
key={item.id}
className="rounded-xl p-3 text-sm shadow-lg border"
style={{ background: 'var(--color-surface)', borderColor: 'var(--color-border)' }}>
<div className="flex items-center justify-between mb-1.5">
<span className="truncate font-medium text-xs" style={{ color: 'var(--color-text)', maxWidth: 180 }}>
{item.name}
</span>
{item.status !== 'uploading' && (
<button onClick={() => onDismiss(item.id)}
className="ml-2 opacity-50 hover:opacity-100 text-xs shrink-0"
style={{ color: 'var(--color-text-muted)' }}>✕</button>
)}
</div>
{item.status === 'uploading' && (
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--color-surface-2)' }}>
<div
className="h-full rounded-full transition-all"
style={{
width: `${item.progress}%`,
background: 'linear-gradient(90deg, var(--color-primary), var(--color-accent))',
}} />
</div>
)}
{item.status === 'done' && (
<p style={{ color: 'var(--color-success)' }} className="text-xs">✓ Uploaded</p>
)}
{item.status === 'error' && (
<p style={{ color: 'var(--color-danger)' }} className="text-xs truncate">✕ {item.error}</p>
)}
</div>
))}
</div>
)
}
+204
View File
@@ -0,0 +1,204 @@
import { createContext, useContext, useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import type { User } from '../api/client'
import type { View } from '../hooks/useNavHistory'
/**
* Der Nutzerbereich – oben rechts in jeder Kopfleiste.
*
* Vorher saß er unten links in der Seitenleiste, mit dem Abmelden-Symbol
* unmittelbar daneben. Zwei Dinge waren daran falsch:
*
* 1. **Abmelden lag einen Fehlklick entfernt** vom Knopf, der zu den
* Einstellungen führt. Es ist die einzige Handlung in dieser Anwendung, die
* man nicht rückgängig machen kann, ohne sich neu anzumelden – und sie war
* die am leichtesten erreichbare.
* 2. Oben rechts ist der Ort, an dem Menschen den eigenen Zugang suchen. Unten
* links steht sonst das, was zum *Ort* gehört – der Speicherbalken bleibt
* deshalb dort.
*
* Weitergereicht wird über einen Context, nicht über Eigenschaften: Sonst
* müsste jede der sechs Seiten Nutzer, Abmelden und Umschalten durchreichen,
* nur damit die Kopfleiste sie hat.
*/
interface UserMenuValue {
user: User
onNavigate: (view: View) => void
onLogout: () => Promise<void>
/** Öffnet die Seitenleiste – nur auf dem Handy sichtbar, siehe PageHeader. */
onOpenSidebar?: () => void
}
const Ctx = createContext<UserMenuValue | null>(null)
export function UserMenuProvider({ value, children }: { value: UserMenuValue; children: ReactNode }) {
return <Ctx.Provider value={value}>{children}</Ctx.Provider>
}
/**
* Den Schalter für die Seitenleiste holt sich die Kopfleiste hier ab.
*
* Über denselben Context wie der Nutzerbereich, aus demselben Grund: Sonst
* müsste jede der sechs Seiten eine Funktion durchreichen, die sie selbst
* nicht braucht.
*/
export function useSidebarToggle(): (() => void) | undefined {
return useContext(Ctx)?.onOpenSidebar
}
/** Anzeigename, sonst die Kennung. Nur *einer* von beiden wird gezeigt – zwei
* Zeilen mit fast demselben Wort darunter sind keine Information. */
export function displayNameOf(user: User): string {
return user.display_name?.trim() || user.username
}
export default function UserMenu() {
const ctx = useContext(Ctx)
const [open, setOpen] = useState(false)
const boxRef = useRef<HTMLDivElement>(null)
// Klick daneben und Escape schließen – wie bei der Suche und den Dialogen.
useEffect(() => {
if (!open) return
function onDown(e: MouseEvent) {
if (!boxRef.current?.contains(e.target as Node)) setOpen(false)
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
}
}, [open])
// Ohne Context (etwa auf der Freigabe-Seite ohne Anmeldung) gibt es nichts
// zu zeigen.
if (!ctx) return null
const { user, onNavigate, onLogout } = ctx
const name = displayNameOf(user)
return (
<div ref={boxRef} style={{ position: 'relative', flexShrink: 0 }}>
<button
onClick={() => setOpen(o => !o)}
title={name}
style={{
display: 'flex', alignItems: 'center', gap: 8,
background: open ? 'var(--color-surface-2)' : 'transparent',
border: 'none', borderRadius: 8, cursor: 'pointer',
padding: '4px 8px 4px 4px', maxWidth: 180,
}}
onMouseEnter={e => { if (!open) e.currentTarget.style.background = 'var(--color-surface-2)' }}
onMouseLeave={e => { if (!open) e.currentTarget.style.background = 'transparent' }}
>
<Avatar name={name} size={26} />
<span style={{
fontSize: 13, fontWeight: 500, color: 'var(--color-text)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{name}
</span>
</button>
{open && (
<div style={{
position: 'absolute', top: 'calc(100% + 6px)', right: 0,
minWidth: 200,
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 10, boxShadow: '0 12px 32px rgba(0,0,0,0.45)',
zIndex: 60, padding: 4,
}}>
{/* Kopf des Menüs: hier ist Platz für beides – der Anzeigename oben,
die Kennung als Nebenangabe. In der Leiste selbst steht nur der
Anzeigename, dort wäre die zweite Zeile nur Doppelung. */}
<div style={{ padding: '8px 10px 10px' }}>
<div style={{
fontSize: 13, fontWeight: 600, color: 'var(--color-text)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{name}
</div>
{user.display_name?.trim() && user.display_name.trim() !== user.username && (
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>{user.username}</div>
)}
{user.is_admin && (
<div style={{ fontSize: 10, fontWeight: 600, color: 'var(--color-primary)', marginTop: 2 }}>
Administrator
</div>
)}
</div>
<Divider />
<Item label="Settings" onClick={() => { setOpen(false); onNavigate('profile') }} icon={
<><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9v0a1.65 1.65 0 001.51 1H21a2 2 0 110 4h-.09a1.65 1.65 0 00-1.51 1z" /></>
} />
{user.is_admin && (
<Item label="Admin panel" onClick={() => { setOpen(false); onNavigate('admin') }} icon={
<><path d="M12 3l8 4v5c0 4.4-3.4 8.3-8 9-4.6-.7-8-4.6-8-9V7l8-4z" /></>
} />
)}
<Divider />
{/* Abmelden zuletzt und mit Abstand – die einzige Handlung hier, die
man nicht zurücknehmen kann. */}
<Item label="Sign out" danger onClick={() => { setOpen(false); void onLogout() }} icon={
<><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" /><polyline points="16 17 21 12 16 7" /><line x1="21" y1="12" x2="9" y2="12" /></>
} />
</div>
)}
</div>
)
}
/** Der farbige Kreis mit dem ersten Buchstaben. */
export function Avatar({ name, size = 26 }: { name: string; size?: number }) {
return (
<span style={{
width: size, height: size, borderRadius: '50%',
background: 'var(--color-primary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: Math.round(size * 0.45), fontWeight: 700, color: '#fff', flexShrink: 0,
}}>
{(name[0] ?? '?').toUpperCase()}
</span>
)
}
function Divider() {
return <div style={{ height: 1, background: 'var(--color-border)', margin: '4px 0' }} />
}
function Item({ label, icon, onClick, danger }: {
label: string
icon: ReactNode
onClick: () => void
danger?: boolean
}) {
const color = danger ? 'var(--color-danger)' : 'var(--color-text)'
return (
<button
onClick={onClick}
style={{
display: 'flex', alignItems: 'center', gap: 10, width: '100%',
padding: '7px 10px', borderRadius: 7, border: 'none', background: 'transparent',
cursor: 'pointer', textAlign: 'left', fontSize: 13, color,
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--color-surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ flexShrink: 0, opacity: danger ? 1 : 0.7 }}>
{icon}
</svg>
{label}
</button>
)
}
+216
View File
@@ -0,0 +1,216 @@
import { useState, useEffect } from 'react'
import { versionsApi, type FileItem, type FileVersion } from '../api/client'
import { formatBytes } from '../pages/FileBrowser'
import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
interface Props {
file: FileItem
onClose: () => void
onRestored: (updated: FileItem) => void
}
// Muss zu maxVersionsKept im Backend passen (internal/files/handler.go).
const MAX_VERSIONS_KEPT = 5
function formatDate(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
day: '2-digit', month: 'short', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})
}
export default function VersionsDialog({ file, onClose, onRestored }: Props) {
const [versions, setVersions] = useState<FileVersion[]>([])
const [loading, setLoading] = useState(true)
const [restoring, setRestoring] = useState<string | null>(null)
const overlay = useOverlayDismiss(onClose)
useEffect(() => {
versionsApi.list(file.id)
.then(data => setVersions(data ?? []))
.catch(() => {})
.finally(() => setLoading(false))
}, [file.id])
async function restore(v: FileVersion) {
setRestoring(v.id)
try {
const updated = await versionsApi.restore(file.id, v.id)
onRestored(updated)
onClose()
} catch {
setRestoring(null)
}
}
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 200,
background: 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 16,
}} {...overlay}>
<div style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 16,
width: 520, maxWidth: '100%', maxHeight: '80vh',
display: 'flex', flexDirection: 'column',
overflow: 'hidden',
}}>
{/* Header */}
<div style={{
padding: '16px 20px',
borderBottom: '1px solid var(--color-border)',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<div>
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 600, color: 'var(--color-text)' }}>
Version history
</h3>
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'var(--color-text-muted)' }}>
{file.name}
</p>
<p style={{ margin: '4px 0 0', fontSize: 11, color: 'var(--color-text-muted)' }}>
{file.versioning_enabled
? `Versioning on – the last ${MAX_VERSIONS_KEPT} revisions are kept`
: 'Versioning off – overwriting replaces the file with no history'}
</p>
</div>
<button
onClick={onClose}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', padding: 4, borderRadius: 6 }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{/* Current version */}
<div style={{
padding: '12px 20px',
borderBottom: '1px solid var(--color-border)',
background: 'rgba(var(--color-primary-rgb), 0.05)',
display: 'flex', alignItems: 'center', gap: 12,
}}>
<div style={{
width: 32, height: 32, borderRadius: 8,
background: 'var(--color-primary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 11, fontWeight: 700, color: '#fff', flexShrink: 0,
}}>
v{file.version}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ margin: 0, fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
Current version
</p>
<p style={{ margin: '2px 0 0', fontSize: 11, color: 'var(--color-text-muted)' }}>
{formatBytes(file.size)} · {formatDate(file.updated_at)}
</p>
</div>
<a
href={`/api/v1/files/${file.id}/download`}
download={file.name}
style={{
padding: '5px 12px', borderRadius: 6,
border: '1px solid var(--color-border)',
color: 'var(--color-text)', fontSize: 12,
textDecoration: 'none', whiteSpace: 'nowrap',
}}
>
Download
</a>
</div>
{/* Older versions */}
<div style={{ flex: 1, overflowY: 'auto', padding: 16 }}>
{loading && (
<div style={{ display: 'flex', justifyContent: 'center', padding: 32 }}>
<div style={{ width: 20, height: 20, 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 && versions.length === 0 && (
<div style={{ textAlign: 'center', padding: 32, color: 'var(--color-text-muted)', fontSize: 13 }}>
No older versions yet.
<br />
<span style={{ fontSize: 11, marginTop: 4, display: 'block' }}>
Upload a file with the same name to create a version.
</span>
</div>
)}
{!loading && versions.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{versions.map(v => (
<div key={v.id} style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 12px', borderRadius: 10,
border: '1px solid var(--color-border)',
background: 'var(--color-surface)',
}}>
<div style={{
width: 32, height: 32, borderRadius: 8,
background: 'var(--color-surface-2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', flexShrink: 0,
}}>
v{v.version}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ margin: 0, fontSize: 12, color: 'var(--color-text)' }}>
{formatDate(v.created_at)}
</p>
<p style={{ margin: '2px 0 0', fontSize: 11, color: 'var(--color-text-muted)' }}>
{formatBytes(v.size)}
</p>
</div>
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<a
href={versionsApi.download(file.id, v.id)}
download={file.name}
style={{
padding: '4px 10px', borderRadius: 6,
border: '1px solid var(--color-border)',
color: 'var(--color-text)', fontSize: 11,
textDecoration: 'none', whiteSpace: 'nowrap',
}}
>
Download
</a>
<button
onClick={() => restore(v)}
disabled={restoring === v.id}
style={{
padding: '4px 10px', borderRadius: 6,
border: '1px solid var(--color-primary)',
background: 'transparent', color: 'var(--color-primary)',
fontSize: 11, cursor: 'pointer', whiteSpace: 'nowrap',
opacity: restoring === v.id ? 0.6 : 1,
}}
>
{restoring === v.id ? 'Restoring…' : 'Restore'}
</button>
</div>
</div>
))}
</div>
)}
</div>
<div style={{
padding: '10px 20px',
borderTop: '1px solid var(--color-border)',
fontSize: 11, color: 'var(--color-text-muted)',
textAlign: 'center',
}}>
Up to {MAX_VERSIONS_KEPT} revisions are kept
</div>
</div>
</div>
)
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Dateiarten an einer Stelle. Symbol, Farbe und Cover leiteten ihre
* Entscheidung vorher jeweils selbst aus dem MIME-Typ ab – dabei laufen die
* drei mit der Zeit auseinander, und eine MP3 bekommt ein Symbol für Ton, aber
* die Farbe für „unbekannt".
*/
export type FileKind =
| 'image' | 'video' | 'audio' | 'pdf' | 'archive'
| 'sheet' | 'slides' | 'doc' | 'code' | 'text' | 'other'
/** Endungen, die der MIME-Typ oft nicht verrät. Ein `.tar.gz` kommt je nach
* Betreuer als application/gzip, application/x-tar oder octet-stream an. */
const archiveExt = ['zip', 'tar', 'gz', 'tgz', 'bz2', 'xz', 'rar', '7z', 'zst', 'deb', 'rpm']
const codeExt = [
'js', 'ts', 'tsx', 'jsx', 'go', 'py', 'rs', 'rb', 'php', 'java', 'c', 'h', 'cpp',
'sh', 'bash', 'sql', 'json', 'yaml', 'yml', 'toml', 'xml', 'html', 'css', 'ini', 'conf',
]
const sheetExt = ['csv', 'tsv', 'xls', 'xlsx', 'ods', 'numbers']
const slidesExt = ['ppt', 'pptx', 'odp', 'key']
/** Nur die Textverarbeitung. Markdown und .txt gehören zu 'text' – sie sind
* lesbarer Text, kein Office-Format, und lassen sich hier sogar bearbeiten. */
const docExt = ['doc', 'docx', 'odt', 'rtf', 'pages']
const textExt = ['md', 'txt', 'log', 'rst']
export function extensionOf(name: string): string {
const dot = name.lastIndexOf('.')
if (dot <= 0 || dot === name.length - 1) return ''
// Bei .tar.gz ist die zweite Endung die aussagekräftige.
const ext = name.slice(dot + 1).toLowerCase()
if (ext === 'gz' && name.toLowerCase().endsWith('.tar.gz')) return 'tar.gz'
return ext
}
export function fileKind(mime: string, name = ''): FileKind {
const m = (mime || '').toLowerCase()
const ext = extensionOf(name).replace('tar.', '')
if (m.startsWith('image/')) return 'image'
if (m.startsWith('video/')) return 'video'
if (m.startsWith('audio/')) return 'audio'
if (m.includes('pdf')) return 'pdf'
if (archiveExt.includes(ext) ||
m.includes('zip') || m.includes('tar') || m.includes('gzip') ||
m.includes('compressed') || m.includes('x-rar') || m.includes('7z')) return 'archive'
if (sheetExt.includes(ext) ||
m.includes('spreadsheet') || m.includes('excel') || m.includes('csv')) return 'sheet'
if (slidesExt.includes(ext) ||
m.includes('presentation') || m.includes('powerpoint')) return 'slides'
if (textExt.includes(ext)) return 'text'
if (codeExt.includes(ext)) return 'code'
if (docExt.includes(ext) ||
m.includes('msword') || m.includes('wordprocessing') ||
m.includes('opendocument.text') || m.includes('rtf')) return 'doc'
if (m.startsWith('text/')) return 'text'
return 'other'
}
/** Je Art eine Farbe. Sie trägt das Symbol, den Rahmen und den Verlauf des
* Covers – so ist eine Datei schon an der Farbe einzuordnen. */
export const kindColors: Record<FileKind, string> = {
image: '#8b5cf6',
video: '#ec4899',
audio: '#06b6d4',
pdf: '#ef4444',
archive: '#f59e0b',
sheet: '#22c55e',
// Kräftiges Orangerot – deutlich genug vom Bernstein der Archive und vom
// Rot der PDFs entfernt, damit die drei nebeneinander unterscheidbar sind.
slides: '#ea580c',
doc: '#3b82f6',
code: '#14b8a6',
text: '#94a3b8',
other: '#6366f1',
}
export function kindColor(mime: string, name = ''): string {
return kindColors[fileKind(mime, name)]
}
+256
View File
@@ -0,0 +1,256 @@
/**
* Einheitlicher Icon-Satz für Menüs und Werkzeugleisten.
*
* Bewusst Striche statt Emoji: Emoji werden von jedem Betriebssystem anders
* gezeichnet, haben eigene Farben und optische Größen – nebeneinander wirkt
* eine Liste davon unruhig. Diese hier erben Farbe (`currentColor`) und Größe
* vom Aufrufer und sitzen alle auf demselben Raster.
*/
import { fileKind, kindColor, kindColors } from './filetypes'
interface IconProps {
size?: number
}
const base = (size: number) => ({
width: size,
height: size,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.75,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const,
style: { flexShrink: 0 },
})
export const DownloadIcon = ({ size = 15 }: IconProps) => (
<svg {...base(size)}>
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
)
export const ShareIcon = ({ size = 15 }: IconProps) => (
<svg {...base(size)}>
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<line x1="8.6" y1="10.5" x2="15.4" y2="6.5" />
<line x1="8.6" y1="13.5" x2="15.4" y2="17.5" />
</svg>
)
export const PeopleIcon = ({ size = 15 }: IconProps) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="9" cy="8" r="3.5" />
<path d="M2 21v-1a7 7 0 0114 0v1" />
<path d="M17 11a3 3 0 100-6M19 21v-1a6 6 0 00-3-5.2" />
</svg>
)
export const InfoIcon = ({ size = 15 }: IconProps) => (
<svg {...base(size)}>
<circle cx="12" cy="12" r="9" />
<path d="M12 11v5" />
<path d="M12 8h.01" />
</svg>
)
export const HistoryIcon = ({ size = 15 }: IconProps) => (
<svg {...base(size)}>
<path d="M3 3v5h5" />
<path d="M3.05 13A9 9 0 106 5.3L3 8" />
<path d="M12 7v5l4 2" />
</svg>
)
export const RenameIcon = ({ size = 15 }: IconProps) => (
<svg {...base(size)}>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.12 2.12 0 013 3L7 19l-4 1 1-4z" />
</svg>
)
export const MoveIcon = ({ size = 15 }: IconProps) => (
<svg {...base(size)}>
<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
<polyline points="12 11 15 14 12 17" />
<line x1="9" y1="14" x2="15" y2="14" />
</svg>
)
export const TrashIcon = ({ size = 15 }: IconProps) => (
<svg {...base(size)}>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14H6L5 6M10 11v6M14 11v6M9 6V4h6v2" />
</svg>
)
export const FolderOpenIcon = ({ size = 15 }: IconProps) => (
<svg {...base(size)}>
<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v1H3z" />
<path d="M3 10h18l-2 8a2 2 0 01-2 1.6H5A2 2 0 013 18z" />
</svg>
)
/** Gefüllt, wenn der Ordner bereits Favorit ist. */
export const StarIcon = ({ size = 15, filled = false }: IconProps & { filled?: boolean }) => (
<svg {...base(size)} fill={filled ? 'currentColor' : 'none'}>
<polygon points="12 2.8 15 9 21.6 9.8 16.8 14.3 18.1 21 12 17.8 5.9 21 7.2 14.3 2.4 9.8 9 9" />
</svg>
)
export const CheckIcon = ({ size = 15 }: IconProps) => (
<svg {...base(size)}>
<polyline points="20 6 9 17 4 12" />
</svg>
)
// ── Dateitypen ───────────────────────────────────────────────────────────────
// Lagen bis 22.08.2026 in FileBrowser.tsx. Hierher gezogen, als die Suche sie
// ebenfalls brauchte – eine Komponente sollte nichts aus einer Seite
// importieren. Welche Art eine Datei hat, entscheidet filetypes.ts.
export function getMimeColor(mime: string, name = ''): string {
return kindColor(mime, name)
}
export function FileTypeIcon({ mime, name = '', size = 20 }: { mime: string; name?: string; size?: number }) {
const kind = fileKind(mime, name)
const color = kindColors[kind]
const svg = (children: React.ReactNode, filled = false) => (
<svg width={size} height={size} viewBox="0 0 24 24"
fill={filled ? color : 'none'} stroke={filled ? 'none' : color} strokeWidth="1.5"
strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
)
switch (kind) {
case 'image':
return svg(<>
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</>)
case 'video':
return svg(<>
<rect x="2" y="4" width="20" height="16" rx="2" />
<polygon points="10 9 15 12 10 15" fill={color} stroke="none" />
<line x1="6" y1="4" x2="6" y2="20" />
<line x1="18" y1="4" x2="18" y2="20" />
</>)
case 'audio':
return svg(<>
<path d="M9 18V6l10-2v12" />
<circle cx="6.5" cy="18" r="2.5" />
<circle cx="16.5" cy="16" r="2.5" />
</>)
case 'pdf':
// Dieselbe Blattform wie beim Dokument, nur in Rot. Ein eigener
// gefuellter Block war der Versuch, beide staerker zu trennen – dafuer
// reichen Farbe und der Aufkleber mit der Endung.
return svg(<>
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<line x1="10" y1="9" x2="8" y2="9" />
</>)
case 'archive':
// Kiste mit Verschluss – ein Ordnersymbol wäre hier irreführend.
return svg(<>
<path d="M3 7l2-3h14l2 3v13a1 1 0 01-1 1H4a1 1 0 01-1-1z" />
<line x1="3" y1="7" x2="21" y2="7" />
<path d="M10 11h4" />
<path d="M10 14h4" />
</>)
case 'sheet':
// Tabellenraster mit hervorgehobener Kopfzeile – so liest man es sofort
// als Tabelle und nicht als Fenster.
return svg(<>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M3 8.5h18" />
<line x1="3" y1="14" x2="21" y2="14" />
<line x1="9.5" y1="8.5" x2="9.5" y2="21" />
</>)
case 'slides':
// Leinwand auf Fuß mit einem Balkendiagramm – das Bild, das jeder mit
// einer Präsentation verbindet.
return svg(<>
<rect x="3" y="3" width="18" height="13" rx="2" />
<path d="M12 16v3" />
<path d="M8.5 21h7" />
<path d="M7.5 12.5v-2M11 12.5V8M14.5 12.5v-3.5" />
</>)
case 'code':
return svg(<>
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</>)
case 'doc':
case 'text':
return svg(<>
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<line x1="10" y1="9" x2="8" y2="9" />
</>)
default:
return svg(<>
<path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z" />
<polyline points="13 2 13 9 20 9" />
</>)
}
}
/**
* Der Ordner – eine Definition für Kachel, Liste und Suchtreffer. Lag vorher
* dreimal im Code und lief prompt auseinander.
*
* Ganzzahlige Koordinaten auf dem 48er-Raster, und die Anzeigegröße ist ein
* Vielfaches davon: So landet jede Kante auf einem ganzen Pixel statt zwischen
* zweien, wo die Kantenglättung sie weichzeichnet.
*
* Die vordere Lage deckt voll, nur die hintere ist durchscheinend. Beide
* durchscheinend zu machen, sah auf dem dunklen Grund blass aus.
*/
export function FolderGlyph({ size = 48, color = '#f59e0b' }: { size?: number; color?: string }) {
return (
<svg width={size} height={size} viewBox="0 0 48 48" fill="none">
{/* Hintere Lage mit Reiter – schaut oben links hervor */}
<path d="M5 13a3 3 0 013-3h8l4 4h17a3 3 0 013 3v5H5z" fill={color} opacity="0.55" />
{/* Vordere Tasche */}
<rect x="5" y="15" width="38" height="26" rx="4" fill={color} />
{/* Feine helle Oberkante, sonst wirkt die Tasche wie ein flacher Klecks */}
<rect x="5" y="15" width="38" height="3" rx="1.5" fill="#fff" opacity="0.16" />
</svg>
)
}
/** Etikett – dieselbe Form wie in der Kopfleiste der Etikettenansicht. */
export function TagIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 7v6.2a2 2 0 00.6 1.4l6.8 6.8a2 2 0 002.8 0l6.2-6.2a2 2 0 000-2.8l-6.8-6.8A2 2 0 0011.2 5H5a2 2 0 00-2 2z" />
<circle cx="7.5" cy="9.5" r="1.2" fill="currentColor" stroke="none" />
</svg>
)
}
/** Ein Blatt mit Stift – „im Office-Server bearbeiten". Bewusst nicht das
* Symbol eines bestimmten Anbieters: Dahinter kann Collabora oder OnlyOffice
* stecken, und das soll dem Menüpunkt nicht anzusehen sein. */
export function OfficeIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M13 3H7a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2v-6" />
<path d="M18.5 2.5a2.1 2.1 0 013 3L15 12l-4 1 1-4z" />
</svg>
)
}
+208
View File
@@ -0,0 +1,208 @@
/*
* Textebene von PDF.js.
*
* Der Block unten ist **wörtlich** aus pdfjs-dist/web/pdf_viewer.css
* übernommen, nur die Klasse heißt `.pdf-textLayer` statt `.textLayer`.
*
* Beim ersten Anlauf hatte ich ihn von Hand gekürzt – „wir zeigen nur Seiten
* an, der Rest ist Werkzeugleiste". Dabei fielen drei Zeilen weg, die wie
* Kleinkram aussahen und keiner waren:
*
* --min-font-size / --text-scale-factor / scale(var(--min-font-size-inv))
*
* PDF.js misst beim ersten Aufbau, wie klein der Browser Schrift überhaupt
* noch darstellt (Chrome erzwingt eine Mindestgröße), setzt daraus
* `--min-font-size` und rechnet sie mit der Skalierung wieder heraus. Ohne
* dieses Paar wird jede kleine Schrift vom Browser aufgeblasen: Die
* unsichtbaren Textstücke sitzen dann größer da als die gemalten Buchstaben
* darunter – zu sehen erst beim **Markieren**, wo die Auswahl als grobe,
* versetzte Kästen über dem Text lag statt auf ihm.
*
* Deshalb hier nichts mehr von Hand kürzen. Was die Datei mitbringt, gehört
* zusammen; was wir zusätzlich wollen, steht darunter.
*/
.pdf-textLayer{
color-scheme:only light;
position:absolute;
text-align:initial;
inset:0;
overflow:clip;
opacity:1;
line-height:1;
letter-spacing:normal;
word-spacing:normal;
-webkit-text-size-adjust:none;
-moz-text-size-adjust:none;
text-size-adjust:none;
forced-color-adjust:none;
transform-origin:0 0;
caret-color:CanvasText;
z-index:0;
&.highlighting{
touch-action:none;
}
:is(span, br){
color:transparent;
position:absolute;
white-space:pre;
cursor:text;
transform-origin:0% 0%;
-webkit-user-select:text;
-moz-user-select:text;
user-select:text;
}
--min-font-size:1;
--text-scale-factor:calc(var(--total-scale-factor) * var(--min-font-size));
--min-font-size-inv:calc(1 / var(--min-font-size));
> :not(.markedContent),
.markedContent span:not(.markedContent){
z-index:1;
--font-height:0;
font-size:calc(var(--text-scale-factor) * var(--font-height));
--scale-x:1;
--rotate:0deg;
transform:rotate(var(--rotate)) scaleX(var(--scale-x)) scale(var(--min-font-size-inv));
}
.markedContent{
display:contents;
}
span[role="img"]{
-webkit-user-select:none;
-moz-user-select:none;
user-select:none;
cursor:default;
}
.highlight{
--highlight-bg-color:rgb(180 0 170 / 0.25);
--highlight-selected-bg-color:rgb(0 100 0 / 0.25);
--highlight-backdrop-filter:none;
--highlight-selected-backdrop-filter:none;
@media screen and (forced-colors: active){
--highlight-bg-color:transparent;
--highlight-selected-bg-color:transparent;
--highlight-backdrop-filter:var(--hcm-highlight-filter);
--highlight-selected-backdrop-filter:var(
--hcm-highlight-selected-filter
);
}
margin:-1px;
padding:1px;
background-color:var(--highlight-bg-color);
backdrop-filter:var(--highlight-backdrop-filter);
border-radius:4px;
&.appended{
position:initial;
}
&.begin{
border-radius:4px 0 0 4px;
}
&.end{
border-radius:0 4px 4px 0;
}
&.middle{
border-radius:0;
}
&.selected{
background-color:var(--highlight-selected-bg-color);
backdrop-filter:var(--highlight-selected-backdrop-filter);
scroll-margin-top:50px;
}
}
::-moz-selection{
background:rgba(0 0 255 / 0.25);
background:color-mix(in srgb, AccentColor, transparent 50%);
color:transparent;
}
::selection{
background:rgba(0 0 255 / 0.25);
background:color-mix(in srgb, AccentColor, transparent 50%);
color:transparent;
}
&.selectionRendering{
::-moz-selection{
background:transparent;
color:transparent;
}
::selection{
background:transparent;
color:transparent;
}
}
br::-moz-selection{
background:transparent;
}
br::selection{
background:transparent;
}
.endOfContent{
display:block;
position:absolute;
inset:100% 0 0;
z-index:0;
cursor:default;
-webkit-user-select:none;
-moz-user-select:none;
user-select:none;
}
&.selecting .endOfContent{
top:0;
}
}
/*
* Fundstellen der Suche.
*
* Eine eigene Ebene über der Textebene, kein <mark> im Text. Warum, steht
* ausführlich in PdfViewer.tsx bei `paint` – kurz: Der Grund eines <mark>
* ragte oben aus der Textebene heraus, und die schneidet ab (`overflow:
* clip`). Das sah man als angeschnittene Streifen. Diese Ebene schneidet
* nichts ab; die Kästen sind dieselben, die der Browser beim Markieren mit
* der Maus malt.
*
* `pointer-events: none`, damit die Ebene das Markieren mit der Maus nicht
* abfängt – darunter liegt die Schrift, die man auswählen können soll.
*
* Die Farbe bleibt durchsichtig: Der lesbare Text steht auf dem gemalten Bild
* *darunter*. Mit 0,95 Deckkraft lag ein satter Block über dem Wort, und
* ausgerechnet das gesuchte Wort war das einzige, das man nicht mehr lesen
* konnte. Der Betrachter von PDF.js selbst arbeitet aus demselben Grund mit
* 0,25.
*/
.pdf-hitLayer {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
}
.pdf-hit {
position: absolute;
background: rgba(245, 158, 11, 0.3);
border-radius: 2px;
}
.pdf-hit.pdf-hit-current {
background: rgba(245, 158, 11, 0.55);
/* Der aktuelle Treffer bekommt zusätzlich eine Kante statt noch mehr Farbe –
Deckkraft würde wieder das Wort verschlucken. */
box-shadow: 0 0 0 1px rgba(180, 83, 9, 0.75);
}
+115
View File
@@ -0,0 +1,115 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'
import { publicApi } from '../api/client'
import { applyTheme } from '../theme'
/**
* Hält die serverseitigen Anzeige-Einstellungen (Name, Farben) und wendet das
* Farbschema an.
*
* Bewusst über den öffentlichen Endpunkt: so ist schon die Login-Seite im
* richtigen Namen und Farbschema, bevor überhaupt jemand angemeldet ist.
* `refresh()` holt den gespeicherten Stand erneut – das nutzt das Admin-Panel
* nach dem Speichern und um eine nicht gespeicherte Vorschau zu verwerfen.
*/
interface AppSettingsValue {
cloudName: string
/** Leer, wenn kein Logo hinterlegt ist. */
logoUrl: string
/** Leer, wenn kein eigenes App-Symbol hinterlegt ist. */
faviconUrl: string
/** Die 192er-Fassung desselben Symbols – Vorschau im Admin-Bereich. */
appIconUrl: string
/** Die vom Administrator gesetzten Farben (Grundlage, wenn der Nutzer
* keine eigenen hat). */
globalColors: { primary?: string; accent?: string }
/** Gibt es einen Office-Server? Steuert nur, ob der Menuepunkt erscheint. */
officeEnabled: boolean
refresh: () => Promise<void>
}
const Ctx = createContext<AppSettingsValue>({
cloudName: 'Aether',
officeEnabled: false,
logoUrl: '',
faviconUrl: '',
appIconUrl: '',
globalColors: {},
refresh: async () => {},
})
/**
* Tauscht das Tab-Symbol aus. Der Eintrag in index.html wird ersetzt statt
* ergaenzt – haengt man einen zweiten an, sucht sich der Browser einen davon
* aus, und welchen, ist von Browser zu Browser verschieden.
*
* Ohne eigenes Favicon steht wieder die mitgelieferte Datei da, damit ein
* Entfernen im Admin-Bereich nicht einen leeren Platz hinterlaesst.
*/
/**
* Faerbt die Systemleisten der installierten App.
*
* `<meta name="theme-color">` stand fest auf dem Indigo der Voreinstellung –
* das Manifest nannte aber die eingestellte Primaerfarbe. In der installierten
* App war die Statusleiste damit in einer Farbe, die sonst nirgends vorkommt.
*/
function applyThemeColor(primary?: string) {
if (!primary) return
const meta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]')
?? document.head.appendChild(Object.assign(document.createElement('meta'), { name: 'theme-color' }))
meta.content = primary
}
function applyFavicon(url: string, touchUrl: string) {
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
?? document.head.appendChild(Object.assign(document.createElement('link'), { rel: 'icon' }))
if (url) {
link.removeAttribute('type') // Typ kommt aus dem Content-Type der Antwort
link.href = url
} else {
link.type = 'image/svg+xml'
link.href = '/icon.svg'
}
// Das Symbol auf dem Startbildschirm von iOS – dafuer die 192er-Fassung,
// nicht die 64er aus dem Tab. Android holt seines aus dem Manifest, das der
// Server erzeugt.
const touch = document.querySelector<HTMLLinkElement>('link[rel="apple-touch-icon"]')
if (touch) touch.href = touchUrl || '/icon-192.png'
}
export function AppSettingsProvider({ children }: { children: ReactNode }) {
const [cloudName, setCloudName] = useState('Aether')
const [logoUrl, setLogoUrl] = useState('')
const [faviconUrl, setFaviconUrl] = useState('')
const [appIconUrl, setAppIconUrl] = useState('')
const [globalColors, setGlobalColors] = useState<{ primary?: string; accent?: string }>({})
const [officeEnabled, setOfficeEnabled] = useState(false)
const refresh = useCallback(async () => {
try {
const cfg = await publicApi.config()
setCloudName(cfg.cloud_name || 'Aether')
setLogoUrl(cfg.logo_url ?? '')
setFaviconUrl(cfg.favicon_url ?? '')
setAppIconUrl(cfg.apple_touch_icon_url ?? '')
applyFavicon(cfg.favicon_url ?? '', cfg.apple_touch_icon_url ?? '')
setGlobalColors({ primary: cfg.primary_color, accent: cfg.accent_color })
setOfficeEnabled(!!cfg.office_enabled)
applyTheme(cfg.primary_color, cfg.accent_color)
applyThemeColor(cfg.primary_color)
document.title = cfg.cloud_name || 'Aether'
} catch {
/* Erreichbarkeit des Servers ist nicht Sache des Themings – Vorgaben bleiben */
}
}, [])
useEffect(() => { void refresh() }, [refresh])
return (
<Ctx.Provider value={{ cloudName, logoUrl, faviconUrl, appIconUrl, globalColors, officeEnabled, refresh }}>{children}</Ctx.Provider>
)
}
export function useAppSettings() {
return useContext(Ctx)
}
+55
View File
@@ -0,0 +1,55 @@
import { useState, useEffect, useCallback } from 'react'
import { authApi, setCSRFToken, type User } from '../api/client'
export interface AuthState {
user: User | null
loading: boolean
login: (username: string, password: string) => Promise<void>
logout: () => Promise<void>
/** Lädt den Nutzer neu – vor allem für den Speicherverbrauch, der sich
* bei jedem Hoch- und Herunterladen ändert. */
refreshUser: () => Promise<void>
}
export function useAuth(): AuthState {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
authApi.me()
.then(({ user, csrf_token }) => {
setCSRFToken(csrf_token)
setUser(user)
})
.catch(() => setUser(null))
.finally(() => setLoading(false))
}, [])
const login = useCallback(async (username: string, password: string) => {
const { user, csrf_token } = await authApi.login(username, password)
setCSRFToken(csrf_token)
setUser(user)
}, [])
const refreshUser = useCallback(async () => {
try {
const { user, csrf_token } = await authApi.me()
// /auth/me erneuert das CSRF-Cookie bei jedem Aufruf. Wird das neue
// Token hier nicht übernommen, passen Cookie und Kopfzeile nicht mehr
// zusammen und jeder folgende schreibende Aufruf scheitert mit 403.
setCSRFToken(csrf_token)
setUser(user)
} catch {
/* Eine fehlgeschlagene Aktualisierung darf die Sitzung nicht beenden –
der angezeigte Wert bleibt dann eben kurz veraltet. */
}
}, [])
const logout = useCallback(async () => {
await authApi.logout()
setCSRFToken('')
setUser(null)
}, [])
return { user, loading, login, logout, refreshUser }
}
+176
View File
@@ -0,0 +1,176 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { FolderItem } from '../api/client'
export type View = 'files' | 'trash' | 'incoming' | 'shared' | 'admin' | 'profile' | 'tags' | 'from'
const views: View[] = ['files', 'trash', 'incoming', 'shared', 'admin', 'profile', 'tags', 'from']
export interface NavState {
view: View
/** Ordnerkette der Dateiansicht – leer heisst Wurzel. */
folderPath: FolderItem[]
/** Gewaehltes Etikett in der Etikettenansicht. Steht im Pfad (`/tags/<id>`),
* damit ein Lesezeichen auf „alle Steuerbelege 2026" zeigen kann. */
tagId?: string
/** Gewaehlter Absender (`/from/<id>`) – „alles von den Stadtwerken". */
correspondentId?: string
}
/** Was im History-Eintrag steckt. Der Zaehler unterscheidet zwei Eintraege am
* selben Ort und wandert beim Zurueckgehen mit. */
interface NavEntry extends NavState {
index: number
}
const stateKey = 'aether:nav'
function entryOf(raw: unknown): NavEntry | null {
const holder = raw as Record<string, unknown> | null
const e = holder?.[stateKey] as NavEntry | undefined
if (!e || !views.includes(e.view) || !Array.isArray(e.folderPath)) return null
return {
view: e.view,
folderPath: e.folderPath,
tagId: typeof e.tagId === 'string' ? e.tagId : undefined,
correspondentId: typeof e.correspondentId === 'string' ? e.correspondentId : undefined,
index: typeof e.index === 'number' ? e.index : 0,
}
}
/**
* Die Ansicht steht im Pfad (`/admin`), damit ein Lesezeichen sie trifft. Der
* Server liefert fuer jeden unbekannten Pfad die Anwendung aus (`spaHandler`),
* die Adresse ist also direkt aufrufbar.
*/
function viewFromUrl(): View {
const first = window.location.pathname.split('/')[1]
return views.includes(first as View) ? (first as View) : 'files'
}
function idFromUrl(prefix: string): string | undefined {
const parts = window.location.pathname.split('/')
return parts[1] === prefix && parts[2] ? parts[2] : undefined
}
function urlFor(s: NavState): string {
if (s.view === 'files') return '/'
if (s.view === 'tags' && s.tagId) return `/tags/${s.tagId}`
if (s.view === 'from' && s.correspondentId) return `/from/${s.correspondentId}`
return `/${s.view}`
}
function sameSpot(a: NavState, b: NavState): boolean {
if (a.view !== b.view) return false
// Zwei Etiketten sind zwei Orte – sonst ersetzte der Wechsel den Eintrag im
// Verlauf und die Zurueck-Taste spraenge an ihnen vorbei.
if (a.view === 'tags') return a.tagId === b.tagId
if (a.view === 'from') return a.correspondentId === b.correspondentId
if (a.view !== 'files') return true
const x = a.folderPath.at(-1)?.id
const y = b.folderPath.at(-1)?.id
return x === y
}
/**
* Haelt Ansicht und Ordnerkette im Verlauf des Browsers. Damit gehen die
* Zurueck-Taste, die Maustasten an der Seite und die Wischgeste – und ein
* Neuladen landet wieder da, wo man war, denn den State bringt der Browser
* zum Eintrag mit.
*
* Bedient wird das ausschliesslich ueber den Browser. Eigene Vor/Zurueck-Knoepfe
* standen bis zum 23.08.2026 in der Werkzeugleiste und sind wieder raus: Zwei
* Bedienelemente fuer dieselbe Sache, von denen eines nur die halbe Wahrheit
* kennt – ob es vorwaerts weitergeht, verraet der Browser einer Seite naemlich
* nicht.
*/
export function useNavHistory() {
const [state, setState] = useState<NavState>(() => {
const restored = entryOf(window.history.state)
return restored
? {
view: restored.view, folderPath: restored.folderPath,
tagId: restored.tagId, correspondentId: restored.correspondentId,
}
: {
view: viewFromUrl(), folderPath: [],
tagId: idFromUrl('tags'), correspondentId: idFromUrl('from'),
}
})
const indexRef = useRef(entryOf(window.history.state)?.index ?? 0)
// Der erste Eintrag hat noch keinen State – ohne ihn faende ein spaeteres
// Zurueck nichts vor.
useEffect(() => {
if (entryOf(window.history.state)) return
const entry: NavEntry = { ...state, index: indexRef.current }
window.history.replaceState({ [stateKey]: entry }, '', urlFor(state))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
function onPop(e: PopStateEvent) {
const entry = entryOf(e.state)
if (!entry) return
indexRef.current = entry.index
setState({
view: entry.view, folderPath: entry.folderPath,
tagId: entry.tagId, correspondentId: entry.correspondentId,
})
}
window.addEventListener('popstate', onPop)
return () => window.removeEventListener('popstate', onPop)
}, [])
// Der Verlauf wird beim Navigieren beschrieben – die Funktionen brauchen den
// aktuellen Stand, ohne selbst neu zu entstehen.
const stateRef = useRef(state)
stateRef.current = state
const navigate = useCallback((next: NavState) => {
// Denselben Ort nicht zweimal in den Verlauf legen: sonst braeuchte ein
// Klick auf den Ordner, in dem man schon steht, ein Zurueck.
if (sameSpot(stateRef.current, next)) {
const entry: NavEntry = { ...next, index: indexRef.current }
window.history.replaceState({ [stateKey]: entry }, '', urlFor(next))
setState(next)
return
}
const index = indexRef.current + 1
indexRef.current = index
window.history.pushState({ [stateKey]: { ...next, index } }, '', urlFor(next))
setState(next)
}, [])
// Die Ordnerkette faehrt bei jedem Ansichtswechsel mit, sonst stuende man
// nach einem Abstecher in den Papierkorb wieder in der Wurzel.
const setView = useCallback((view: View) => {
navigate({ view, folderPath: stateRef.current.folderPath })
}, [navigate])
const setFolderPath = useCallback((folderPath: FolderItem[]) => {
navigate({ view: 'files', folderPath })
}, [navigate])
/** Zu einem Etikett springen – eigener Eintrag im Verlauf. */
const setTag = useCallback((tagId: string) => {
navigate({ view: 'tags', folderPath: stateRef.current.folderPath, tagId })
}, [navigate])
/** Zu einem Absender springen – „alles von den Stadtwerken". */
const setCorrespondent = useCallback((correspondentId: string) => {
navigate({ view: 'from', folderPath: stateRef.current.folderPath, correspondentId })
}, [navigate])
return {
view: state.view,
folderPath: state.folderPath,
tagId: state.tagId,
correspondentId: state.correspondentId,
setView,
setFolderPath,
setTag,
setCorrespondent,
navigate,
}
}
+25
View File
@@ -0,0 +1,25 @@
import { useRef, type MouseEvent } from 'react'
/**
* Klick auf das Overlay schließt den Dialog – aber nur, wenn Drücken UND
* Loslassen beide auf dem Overlay passiert sind.
*
* Ohne die Merker-Prüfung reicht ein reines `onClick` nicht: Markiert man Text
* im Dialog und lässt die Maus außerhalb los, feuert der Browser `click` auf
* dem gemeinsamen Vorfahren – das Overlay. Der Dialog schlösse dann mitten in
* der Textauswahl. Ein `e.target === e.currentTarget`-Check allein hilft dabei
* nicht, weil das Ziel in genau diesem Fall tatsächlich das Overlay ist.
*/
export function useOverlayDismiss(onDismiss: () => void) {
const startedOnOverlay = useRef(false)
return {
onMouseDown: (e: MouseEvent) => {
startedOnOverlay.current = e.target === e.currentTarget
},
onClick: (e: MouseEvent) => {
if (startedOnOverlay.current && e.target === e.currentTarget) onDismiss()
startedOnOverlay.current = false
},
}
}
+82
View File
@@ -0,0 +1,82 @@
@import "tailwindcss";
/* ── Design tokens (CSS variables) ──────────────────────────────────────────
Admin sets these globally in Phase 4. Users can override their own palette.
Defaults here are the Aether built-in theme. */
:root {
--color-primary: #6366f1;
--color-primary-rgb: 99, 102, 241; /* für halbtransparente Flächen */
--color-primary-fg: #ffffff;
--color-accent: #8b5cf6;
--color-accent-rgb: 139, 92, 246;
--color-accent-fg: #ffffff;
--color-background: #0f172a;
--color-surface: #1e293b;
--color-surface-2: #334155;
--color-border: #334155;
--color-text: #f1f5f9;
--color-text-muted: #94a3b8;
--color-danger: #ef4444;
/* Ordnergelb. Vier Stellen benutzten diesen Namen bereits, definiert war er
nie – die Symbole erschienen deshalb schwarz. */
--color-warning: #f59e0b;
--color-success: #22c55e;
--radius: 0.5rem;
}
body {
background-color: var(--color-background);
color: var(--color-text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
-webkit-font-smoothing: antialiased;
margin: 0;
/* Kein Gummiband-Scrollen am Rand: In der installierten App sieht ein
Inhalt, der sich über den Anschlag hinausziehen lässt, nach einem Fehler
aus – es gibt ja keine Adressleiste, die dabei mitfährt. */
overscroll-behavior-y: none;
}
/* ── Sichere Bereiche (installierte App) ────────────────────────────────────
Mit viewport-fit=cover reicht die Seite bis unter Statusleiste und
Wischbalken. Ohne diese Abstände säße der obere Rand hinter der Uhr und der
Speicherbalken der Seitenleiste hinter dem Wischbalken – erreichbar wäre er
dann nicht mehr.
**Als Variablen, nicht als Klassen.** Vorher standen hier
`.aether-safe-top { padding-top: env(...) }` und zwei Geschwister – und sie
wirkten nirgends. Alle drei Stellen setzen ihren Abstand als Inline-Stil
(`style={{ padding: '12px 16px' }}`), und ein Inline-Stil schlägt jede
Klasse. Der Abstand wurde also berechnet und sofort überschrieben; in der
installierten App klebte die Kopfleiste an der Statusleiste.
Mit Variablen gibt es diesen Wettstreit nicht: Die Stelle, die den Abstand
ohnehin setzt, rechnet ihn selbst ein –
`paddingTop: 'calc(12px + var(--safe-top))'`.
env() ist auf Geräten ohne Aussparung 0, die Variablen kosten dort nichts.
Nur in der installierten App gesetzt: Im Browser übernimmt die Adressleiste
diese Aufgabe schon. */
:root {
--safe-top: 0px;
--safe-bottom: 0px;
--safe-left: 0px;
}
@media (display-mode: standalone) {
:root {
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
}
}
* {
box-sizing: border-box;
}
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--color-surface-2); border-radius: 999px; }
::-webkit-scrollbar-thumb:hover { background: var(--color-text-muted); }
::selection { background: var(--color-primary); color: #fff; }
+12
View File
@@ -0,0 +1,12 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
+54
View File
@@ -0,0 +1,54 @@
import { useState, useEffect } from 'react'
import { adminApi, type AdminUser } from '../api/client'
import PageHeader from '../components/PageHeader'
import Tabs from '../components/Tabs'
type Tab = 'users' | 'groups' | 'theming' | 'audit'
import UsersTab from './admin/UsersTab'
import GroupsTab from './admin/GroupsTab'
import ThemingTab from './admin/ThemingTab'
import AuditTab from './admin/AuditTab'
export default function AdminPage() {
const [tab, setTab] = useState<Tab>('users')
const [allUsers, setAllUsers] = useState<AdminUser[]>([])
useEffect(() => {
adminApi.listUsers().then(data => setAllUsers(data ?? [])).catch(() => {})
}, [])
const tabs: { id: Tab; label: string }[] = [
{ id: 'users', label: 'Users' },
{ id: 'groups', label: 'Groups' },
{ id: 'theming', label: 'Theming' },
// „Activity" statt „Audit log": Der Reiter zeigt nicht mehr nur das
// Protokoll, sondern davor auch den Stand der Texterkennung und die
// Speicherbereinigung – alles, was im Hintergrund läuft. Die Kennung
// bleibt 'audit', sie steht in keiner Adresse und muss nicht wandern.
{ id: 'audit', label: 'Activity' },
]
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">
<path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z" />
<path d="M9.5 12.5l1.8 1.8 3.4-3.6" />
</svg>
}
title="Admin panel"
meta="Users, groups, appearance and activity"
/>
<div style={{ flex: 1, overflowY: 'auto', padding: 20 }}>
<Tabs tabs={tabs} active={tab} onChange={setTab} />
{tab === 'users' && <UsersTab />}
{tab === 'groups' && <GroupsTab allUsers={allUsers} />}
{tab === 'theming' && <ThemingTab />}
{tab === 'audit' && <AuditTab />}
</div>
</div>
)
}
File diff suppressed because it is too large. Load diff
+163
View File
@@ -0,0 +1,163 @@
import { useState, useEffect, type FormEvent } from 'react'
import { APIError, publicApi, type PublicConfig } from '../api/client'
interface Props {
onLogin: (username: string, password: string) => Promise<void>
}
export default function LoginPage({ onLogin }: Props) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [config, setConfig] = useState<PublicConfig>({ oidc_enabled: false, cloud_name: 'Aether', external_apps: [] })
useEffect(() => {
// Show OIDC error from redirect if present
const params = new URLSearchParams(window.location.search)
if (params.get('error')) setError('SSO sign-in failed. Please sign in locally.')
publicApi.config().then(setConfig).catch(() => {})
}, [])
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
try {
await onLogin(username, password)
} catch (err) {
setError(err instanceof APIError ? err.message : 'Login failed')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="w-full max-w-sm">
{/* Logo / branding */}
<div className="text-center mb-8">
{/* Eigenes Logo ersetzt die Standardwolke, wenn eines hinterlegt ist.
Kein farbiger Kasten dahinter: Ein Logo bringt seinen eigenen
Hintergrund mit, und ein Kachelrahmen drumherum sähe nach Versehen
aus. */}
{config.logo_url ? (
<img
src={config.logo_url}
alt={config.cloud_name}
className="mx-auto mb-4 object-contain"
style={{ maxHeight: 64, maxWidth: 200 }}
/>
) : (
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-4"
style={{ background: 'var(--color-primary)' }}>
<svg className="w-7 h-7 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" 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>
)}
<h1 className="text-2xl font-semibold" style={{ color: 'var(--color-text)' }}>{config.cloud_name}</h1>
<p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>Sign in</p>
</div>
{/* OIDC login */}
{config.oidc_enabled && (
<a
href="/api/v1/auth/oidc/login"
className="flex items-center justify-center gap-3 w-full rounded-xl px-4 py-3 text-sm font-semibold mb-4 border transition-all"
style={{
background: 'var(--color-surface)',
borderColor: 'var(--color-border)',
color: 'var(--color-text)',
textDecoration: 'none',
}}
onMouseEnter={e => (e.currentTarget.style.borderColor = 'var(--color-primary)')}
onMouseLeave={e => (e.currentTarget.style.borderColor = 'var(--color-border)')}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4l3 3" />
</svg>
Sign in with SSO / OIDC
</a>
)}
{config.oidc_enabled && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
<hr style={{ flex: 1, border: 'none', borderTop: '1px solid var(--color-border)' }} />
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>or</span>
<hr style={{ flex: 1, border: 'none', borderTop: '1px solid var(--color-border)' }} />
</div>
)}
<form onSubmit={handleSubmit}
className="rounded-xl p-6 space-y-4 border"
style={{ background: 'var(--color-surface)', borderColor: 'var(--color-border)' }}>
{error && (
<div className="rounded-lg px-4 py-3 text-sm"
style={{ background: '#7f1d1d33', color: '#fca5a5', border: '1px solid #7f1d1d' }}>
{error}
</div>
)}
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--color-text-muted)' }}>
Username
</label>
<input
type="text"
autoComplete="username"
required
value={username}
onChange={e => setUsername(e.target.value)}
className="w-full rounded-lg px-3 py-2.5 text-sm outline-none transition-all"
style={{
background: 'var(--color-surface-2)',
border: '1px solid var(--color-border)',
color: 'var(--color-text)',
}}
onFocus={e => e.currentTarget.style.borderColor = 'var(--color-primary)'}
onBlur={e => e.currentTarget.style.borderColor = 'var(--color-border)'}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--color-text-muted)' }}>
Password
</label>
<input
type="password"
autoComplete="current-password"
required
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full rounded-lg px-3 py-2.5 text-sm outline-none transition-all"
style={{
background: 'var(--color-surface-2)',
border: '1px solid var(--color-border)',
color: 'var(--color-text)',
}}
onFocus={e => e.currentTarget.style.borderColor = 'var(--color-primary)'}
onBlur={e => e.currentTarget.style.borderColor = 'var(--color-border)'}
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-lg py-2.5 text-sm font-semibold transition-opacity disabled:opacity-60"
style={{ background: 'var(--color-primary)', color: 'var(--color-primary-fg)' }}>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
{/* WebDAV hint */}
<p style={{ textAlign: 'center', fontSize: 11, color: 'var(--color-text-muted)', marginTop: 16 }}>
WebDAV: <code style={{ fontFamily: 'monospace' }}>/dav/</code>
</p>
</div>
</div>
)
}
+429
View File
@@ -0,0 +1,429 @@
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 style={{ flex: 1, overflowY: 'auto', padding: 20 }}>
<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>
)
}
+177
View File
@@ -0,0 +1,177 @@
import { useState, useEffect } from 'react'
import { sharesApi, type PublicShareInfo } from '../api/client'
interface Props {
token: string
}
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 default function SharePage({ token }: Props) {
const [info, setInfo] = useState<PublicShareInfo | null>(null)
const [error, setError] = useState('')
const [password, setPassword] = useState('')
const [needsPassword, setNeedsPassword] = useState(false)
const [downloading, setDownloading] = useState(false)
const [wrongPassword, setWrongPassword] = useState(false)
useEffect(() => {
sharesApi.getInfo(token)
.then(data => {
setInfo(data)
setNeedsPassword(data.link.has_password)
})
.catch(() => setError('This link is not valid or has expired.'))
}, [token])
async function download() {
setDownloading(true)
setWrongPassword(false)
try {
const res = await sharesApi.download(token, needsPassword ? password : undefined)
if (res.status === 401) {
setWrongPassword(true)
setDownloading(false)
return
}
if (!res.ok) {
setError('Download failed.')
setDownloading(false)
return
}
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = info?.file_name ?? 'download'
a.click()
URL.revokeObjectURL(url)
} catch {
setError('Download failed.')
}
setDownloading(false)
}
return (
<div style={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--color-background)',
padding: 24,
}}>
{/* Brand */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 40 }}>
<div style={{
width: 32, height: 32, borderRadius: 8,
background: 'var(--color-primary)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<svg width="18" height="18" 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: 18, color: 'var(--color-text)' }}>Aether</span>
</div>
<div style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 16,
padding: 32,
width: 420,
maxWidth: '100%',
textAlign: 'center',
}}>
{error ? (
<>
<div style={{ fontSize: 48, marginBottom: 16 }}>🔗</div>
<h2 style={{ margin: '0 0 8px', fontSize: 18, color: 'var(--color-text)' }}>Link not found</h2>
<p style={{ color: 'var(--color-text-muted)', fontSize: 14 }}>{error}</p>
</>
) : !info ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
<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>
) : (
<>
{/* File icon */}
<div style={{ fontSize: 52, marginBottom: 16 }}>
{info.mime_type.startsWith('image/') ? '🖼' :
info.mime_type.startsWith('video/') ? '🎬' :
info.mime_type.startsWith('audio/') ? '🎵' :
info.mime_type.includes('pdf') ? '📄' :
info.mime_type.includes('zip') ? '📦' : '📁'}
</div>
<h2 style={{ margin: '0 0 6px', fontSize: 18, fontWeight: 600, color: 'var(--color-text)', wordBreak: 'break-word' }}>
{info.file_name}
</h2>
<p style={{ margin: '0 0 24px', fontSize: 13, color: 'var(--color-text-muted)' }}>
{formatBytes(info.file_size)}
{info.link.expires_at && (
<span> · Expires {new Date(info.link.expires_at).toLocaleDateString()}</span>
)}
</p>
{needsPassword && (
<div style={{ marginBottom: 16 }}>
<input
type="password"
placeholder="Enter password"
value={password}
onChange={e => setPassword(e.target.value)}
onKeyDown={e => e.key === 'Enter' && download()}
style={{
width: '100%', padding: '10px 14px', borderRadius: 8,
border: `1px solid ${wrongPassword ? 'var(--color-danger)' : 'var(--color-border)'}`,
background: 'var(--color-surface-2)',
color: 'var(--color-text)', fontSize: 14, outline: 'none',
boxSizing: 'border-box',
}}
/>
{wrongPassword && (
<p style={{ margin: '6px 0 0', fontSize: 12, color: 'var(--color-danger)' }}>
Incorrect password
</p>
)}
</div>
)}
<button
onClick={download}
disabled={downloading || (needsPassword && !password)}
style={{
width: '100%', padding: '11px 0', borderRadius: 10, border: 'none',
background: 'var(--color-primary)', color: '#fff',
fontSize: 15, fontWeight: 600, cursor: downloading ? 'wait' : 'pointer',
opacity: (downloading || (needsPassword && !password)) ? 0.7 : 1,
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
{downloading ? 'Downloading…' : 'Download'}
</button>
<p style={{ margin: '16px 0 0', fontSize: 11, color: 'var(--color-text-muted)' }}>
Shared via Aether · Downloaded {info.link.download_count}×
</p>
</>
)}
</div>
</div>
)
}
+170
View File
@@ -0,0 +1,170 @@
import { useState, useEffect } from 'react'
import { sharesApi, type ShareLink } from '../api/client'
import ConfirmDialog from '../components/ConfirmDialog'
import PageHeader from '../components/PageHeader'
function shareURL(token: string): string {
return `${window.location.origin}/s/${token}`
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' })
}
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString(undefined, { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })
}
export default function SharedPage() {
const [links, setLinks] = useState<ShareLink[]>([])
const [loading, setLoading] = useState(true)
const [copied, setCopied] = useState<string | null>(null)
const [confirmDelete, setConfirmDelete] = useState<ShareLink | null>(null)
useEffect(() => {
sharesApi.list()
.then(data => setLinks(data ?? []))
.catch(() => {})
.finally(() => setLoading(false))
}, [])
async function copy(token: string) {
await navigator.clipboard.writeText(shareURL(token))
setCopied(token)
setTimeout(() => setCopied(null), 2000)
}
async function deleteLink(link: ShareLink) {
await sharesApi.delete(link.id)
setLinks(l => l.filter(x => x.id !== link.id))
setConfirmDelete(null)
}
const now = new Date()
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">
<circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" />
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
</svg>
}
title="Shared links"
meta={links.length > 0 ? `${links.length} link${links.length !== 1 ? 's' : ''}` : undefined}
/>
<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 && links.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 }}>No shared links yet</p>
<p style={{ color: 'var(--color-text-muted)', fontSize: 12, margin: 0 }}>Select a file and click "Share" to create a link</p>
</div>
)}
{!loading && links.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{links.map(link => {
const expired = link.expires_at ? new Date(link.expires_at) < now : false
return (
<div key={link.id} style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '12px 14px',
background: 'var(--color-surface)',
border: `1px solid ${expired ? 'var(--color-danger)' : 'var(--color-border)'}`,
borderRadius: 10,
opacity: expired ? 0.6 : 1,
}}>
{/* Icon */}
<div style={{
width: 36, height: 36, borderRadius: 8, flexShrink: 0,
background: expired ? 'rgba(239,68,68,0.1)' : 'rgba(var(--color-primary-rgb), 0.1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke={expired ? 'var(--color-danger)' : 'var(--color-primary)'} strokeWidth="2">
<path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71" />
</svg>
</div>
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{
margin: 0, fontSize: 12, color: 'var(--color-text)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
fontFamily: 'monospace',
}}>
{shareURL(link.token)}
</p>
<div style={{ marginTop: 4, display: 'flex', gap: 10, flexWrap: 'wrap', fontSize: 11, color: 'var(--color-text-muted)' }}>
<span>Created {formatDate(link.created_at)}</span>
{link.has_password && <span>🔒 Password protected</span>}
{link.expires_at && (
<span style={{ color: expired ? 'var(--color-danger)' : 'inherit' }}>
{expired ? 'Expired' : 'Expires'} {formatDateTime(link.expires_at)}
</span>
)}
<span>{link.download_count}× downloaded</span>
</div>
</div>
{/* Actions */}
<button
onClick={() => copy(link.token)}
title="Copy link"
style={{
padding: '5px 12px', borderRadius: 6, flexShrink: 0,
border: '1px solid var(--color-border)',
background: copied === link.token ? 'rgba(34,197,94,0.1)' : 'transparent',
color: copied === link.token ? '#22c55e' : 'var(--color-text)',
fontSize: 12, cursor: 'pointer', whiteSpace: 'nowrap',
}}
>
{copied === link.token ? '✓ Copied' : 'Copy link'}
</button>
<button
onClick={() => setConfirmDelete(link)}
title="Delete link"
style={{
padding: '5px 8px', borderRadius: 6, flexShrink: 0,
border: '1px solid var(--color-border)',
background: 'transparent', color: 'var(--color-text-muted)',
cursor: 'pointer', display: 'flex', alignItems: 'center',
}}
>
<svg width="14" height="14" 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>
</button>
</div>
)
})}
</div>
)}
</div>
{confirmDelete && (
<ConfirmDialog
title="Delete share link"
message="The link will stop working immediately. The file itself is not affected."
confirmLabel="Delete link"
danger
onConfirm={() => deleteLink(confirmDelete)}
onCancel={() => setConfirmDelete(null)}
/>
)}
</div>
)
}
+129
View File
@@ -0,0 +1,129 @@
import { useState, useEffect } from 'react'
import { aclApi, filesApi, type SharedResource } from '../api/client'
import { formatBytes } from './FileBrowser'
import PageHeader from '../components/PageHeader'
import { FolderGlyph } from '../components/icons'
interface Props {
/** Öffnet einen freigegebenen Ordner in der Dateiansicht. */
onOpenFolder: (folderId: string, folderName: string) => void
}
/** „Geteilt mit mir": alles, worauf man Zugriff bekommen hat, ohne es zu
* besitzen. Die Daten liegen weiterhin beim Eigentümer – hier wird nur
* darauf verwiesen. */
export default function SharedWithMePage({ onOpenFolder }: Props) {
const [items, setItems] = useState<SharedResource[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
aclApi.sharedWithMe()
.then(d => setItems(d ?? []))
.catch(() => {})
.finally(() => setLoading(false))
}, [])
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">
<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
<circle cx="12" cy="13" r="2" />
<path d="M8.5 18a3.5 3.5 0 017 0" />
</svg>
}
title="Shared with me"
meta="Files and folders other people gave you access to"
/>
<div style={{ flex: 1, overflowY: 'auto', padding: 20 }}>
{loading ? (
<p style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>Loading…</p>
) : items.length === 0 ? (
<div style={{
padding: 40, textAlign: 'center', borderRadius: 12,
border: '1px dashed var(--color-border)',
}}>
<p style={{ margin: 0, fontSize: 14, color: 'var(--color-text)' }}>Nothing shared with you yet</p>
<p style={{ margin: '6px 0 0', fontSize: 12, color: 'var(--color-text-muted)' }}>
When someone shares a file or folder, it shows up here.
</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxWidth: 760 }}>
{items.map(item => (
<div
key={`${item.type}-${item.id}`}
onClick={() => item.type === 'folder' && onOpenFolder(item.id, item.name)}
style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '13px 16px', borderRadius: 11,
border: '1px solid var(--color-border)',
background: 'var(--color-surface)',
cursor: item.type === 'folder' ? 'pointer' : 'default',
}}
onMouseEnter={e => { if (item.type === 'folder') e.currentTarget.style.borderColor = 'var(--color-primary)' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--color-border)' }}
>
<span style={{ flexShrink: 0, display: 'flex' }}>
{item.type === 'folder' ? (
<FolderGlyph size={24} />
) : (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-muted)" strokeWidth="1.8">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
)}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 14, fontWeight: 600, color: 'var(--color-text)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{item.name}
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2 }}>
Shared by {item.owner_name}
{/* Ohne diesen Hinweis wundert man sich, woher eine Freigabe
kommt, die man nie persönlich bekommen hat. */}
{item.via_group && ` via ${item.via_group}`}
{item.type === 'file' && ` · ${formatBytes(item.size)}`}
</div>
</div>
<span style={{
flexShrink: 0, padding: '3px 10px', borderRadius: 999,
fontSize: 11, fontWeight: 600,
background: item.permission === 'write'
? 'rgba(var(--color-primary-rgb), 0.12)'
: 'var(--color-surface-2)',
color: item.permission === 'write' ? 'var(--color-primary)' : 'var(--color-text-muted)',
}}>
{item.permission === 'write' ? 'Can edit' : 'View only'}
</span>
{item.type === 'file' && (
<a
href={filesApi.download(item.id)}
download={item.name}
onClick={e => e.stopPropagation()}
style={{
flexShrink: 0, padding: '5px 12px', borderRadius: 7,
border: '1px solid var(--color-border)',
color: 'var(--color-text)', fontSize: 12, textDecoration: 'none',
}}
>
Download
</a>
)}
</div>
))}
</div>
)}
</div>
</div>
)
}
+221
View File
@@ -0,0 +1,221 @@
import { useEffect, useState } from 'react'
import { tagsApi, correspondentsApi, filesApi, downloadZip, type Correspondent, type Tag, type TaggedFile } from '../api/client'
import PageHeader from '../components/PageHeader'
import { FileTypeIcon } from '../components/icons'
import TagChip, { tagColor } from '../components/TagChip'
import { formatBytes } from './FileBrowser'
/**
* Alles, was ein Etikett trägt – über Ordnergrenzen hinweg.
*
* Das ist die Ansicht, für die Etiketten gebaut werden. Die Suche beantwortet
* „wo steht dieses Wort"; hier geht es um „zeig mir alles, was ich als
* absetzbar markiert habe" – eine Frage, die kein Volltext beantworten kann,
* weil „absetzbar" in keiner Rechnung steht.
*
* Sortiert nach Belegdatum, nicht nach Namen: Wer im März alle Belege des
* Vorjahres zusammensucht, will sie in zeitlicher Ordnung.
*/
interface Props {
tagId?: string
/**
* Absender statt Etikett. Dieselbe Seite, weil es dieselbe Frage in anderer
* Richtung ist: „zeig mir alles mit diesem Merkmal, quer über die Ordner".
* Eine zweite, fast gleiche Seite liefe mit der Zeit auseinander – genau
* das war bei den Kopfleisten schon einmal passiert.
*/
correspondentId?: string
onOpenFolder: (folderId: string | null, folderName: string) => void
}
export default function TagsPage({ tagId, correspondentId, onOpenFolder }: Props) {
const [tag, setTag] = useState<Tag | null>(null)
const [correspondent, setCorrespondent] = useState<Correspondent | null>(null)
const [files, setFiles] = useState<TaggedFile[]>([])
const [loading, setLoading] = useState(true)
const [missing, setMissing] = useState(false)
/** Was gerade gefiltert wird – Etikett oder Absender. */
const subject = correspondentId ? correspondent : tag
const id = correspondentId ?? tagId
const isFrom = !!correspondentId
useEffect(() => {
if (!id) { setLoading(false); return }
let alive = true
setLoading(true); setMissing(false)
setTag(null); setCorrespondent(null)
const load = isFrom
? Promise.all([correspondentsApi.list(), correspondentsApi.files(id)])
.then(([all, hits]) => {
const found = all.find(c => c.id === id) ?? null
return [found, hits] as const
})
: Promise.all([tagsApi.list(), tagsApi.files(id)])
.then(([all, hits]) => {
const found = all.find(t => t.id === id) ?? null
return [found, hits] as const
})
load
.then(([found, hits]) => {
if (!alive) return
if (isFrom) setCorrespondent(found as Correspondent | null)
else setTag(found as Tag | null)
setMissing(!found)
setFiles(hits)
})
.catch(() => { if (alive) setMissing(true) })
.finally(() => { if (alive) setLoading(false) })
return () => { alive = false }
}, [id, isFrom])
const total = files.reduce((sum, f) => sum + f.size, 0)
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<PageHeader
icon={isFrom ? <CorrespondentGlyph /> : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 7v6.2a2 2 0 00.6 1.4l6.8 6.8a2 2 0 002.8 0l6.2-6.2a2 2 0 000-2.8l-6.8-6.8A2 2 0 0011.2 5H5a2 2 0 00-2 2z" />
<circle cx="7.5" cy="9.5" r="1.5" fill="currentColor" stroke="none" />
</svg>
)}
title={subject?.name ?? (isFrom ? 'From' : 'Tags')}
meta={
loading ? '' :
missing ? '' :
`${files.length} ${files.length === 1 ? 'file' : 'files'} · ${formatBytes(total)}`
}
>
{/* Der Zweck der ganzen Kette: filtern, alles holen, weitergeben.
„Alle Belege 2026 an den Steuerberater" ist genau ein Klick. */}
{subject && files.length > 0 && (
<button
onClick={() => downloadZip({ fileIds: files.map(f => f.id), name: subject.name })}
style={{
display: 'flex', alignItems: 'center', gap: 7,
padding: '6px 14px', borderRadius: 8, border: 'none', cursor: 'pointer',
fontSize: 12, fontWeight: 600, color: '#fff', background: 'var(--color-primary)',
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
Download all as ZIP
</button>
)}
</PageHeader>
<div style={{ flex: 1, overflowY: 'auto', padding: 20 }}>
{loading && (
<p style={{ color: 'var(--color-text-muted)', fontSize: 13 }}>Loading…</p>
)}
{!loading && !id && (
<Empty text={isFrom
? 'Pick a sender in the sidebar to see everything from them.'
: 'Pick a tag in the sidebar to see everything that carries it.'} />
)}
{!loading && id && missing && (
<Empty text={isFrom ? 'This sender no longer exists.' : 'This tag no longer exists.'} />
)}
{!loading && subject && files.length === 0 && (
<Empty text={isFrom
? `Nothing is from “${subject.name}” yet. Right-click a file and choose “Organise…”.`
: `Nothing is tagged “${subject.name}” yet. Right-click a file and choose “Organise…”.`} />
)}
{!loading && subject && files.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{files.map(f => (
<button
key={f.id}
onDoubleClick={() => onOpenFolder(f.folder_id, f.folder_name)}
onClick={() => onOpenFolder(f.folder_id, f.folder_name)}
title={`Open ${f.folder_name || 'Home'}`}
style={{
display: 'flex', alignItems: 'center', gap: 12, width: '100%',
padding: '9px 12px', borderRadius: 8, border: 'none',
background: 'transparent', cursor: 'pointer', textAlign: 'left',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--color-surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
{/* Vorschaubild, wo es eins gibt – sonst das Typsymbol. In
einer Liste reicht die kleine Fassung; die großen Cover
gehören zu den Kacheln. */}
{f.has_thumbnail ? (
<img src={filesApi.thumbnail(f.id)} alt="" style={{
width: 22, height: 22, borderRadius: 4, objectFit: 'cover', flexShrink: 0,
}} />
) : (
<span style={{ display: 'flex', flexShrink: 0 }}>
<FileTypeIcon mime={f.mime_type} name={f.name} size={22} />
</span>
)}
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{
display: 'block', fontSize: 13, color: 'var(--color-text)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{f.name}
</span>
<span style={{ display: 'block', fontSize: 11, color: 'var(--color-text-muted)' }}>
in {f.folder_name || 'Home'}
</span>
</span>
{/* Das Belegdatum steht rechts, weil danach sortiert wird – wer
eine Liste nach einer Spalte sortiert, sucht diese Spalte. */}
<span style={{
width: 96, textAlign: 'right', fontSize: 12, flexShrink: 0,
color: f.document_date ? 'var(--color-text)' : 'var(--color-text-muted)',
}}>
{f.document_date ? f.document_date.slice(0, 10) : '—'}
</span>
<span style={{ width: 76, textAlign: 'right', fontSize: 12, color: 'var(--color-text-muted)', flexShrink: 0 }}>
{formatBytes(f.size)}
</span>
</button>
))}
</div>
)}
{!isFrom && tag?.group_id && (
<p style={{ marginTop: 20, fontSize: 11, color: 'var(--color-text-muted)' }}>
<TagChip tag={tag} size="sm" /> is a group tag — everyone in{' '}
<strong style={{ color: tagColor(tag) }}>{tag.group_name}</strong> sees it.
</p>
)}
</div>
</div>
)
}
/** Ein Umschlag – „von wem". Bewusst nicht dasselbe Zeichen wie beim Etikett:
* Es sind zwei verschiedene Fragen, und die Kopfzeile soll das zeigen. */
function CorrespondentGlyph() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="5" width="20" height="14" rx="2" />
<polyline points="2.5 6.5 12 13 21.5 6.5" />
</svg>
)
}
function Empty({ text }: { text: string }) {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: '60%', color: 'var(--color-text-muted)', fontSize: 13,
textAlign: 'center', padding: 20,
}}>
{text}
</div>
)
}
+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 '📁'
}
+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>
)
}
+1
View File
@@ -0,0 +1 @@
import '@testing-library/react'
+43
View File
@@ -0,0 +1,43 @@
/**
* Anwenden der Farbeinstellungen auf die CSS-Variablen.
*
* Die Werte aus dem Admin-Panel landen in der Datenbank, wirken aber erst,
* wenn sie hier auf das Wurzelelement geschrieben werden. Ein Inline-Wert am
* `documentElement` überschreibt die Vorgaben aus `index.css`, weil er
* spezifischer ist als `:root`.
*
* Neben der Volltonfarbe wird das RGB-Tripel als eigene Variable gesetzt
* (`--color-primary-rgb`). Die Oberfläche braucht die Farbe an vielen Stellen
* halbtransparent – für zarte Flächen wie den aktiven Menüpunkt oder eine
* Auswahl. Mit dem Tripel geht das als `rgba(var(--color-primary-rgb), 0.15)`,
* ohne für jede Deckkraft eine eigene Variable zu pflegen.
*/
const HEX = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/
/** "#6366f1" → "99, 102, 241"; Kurzform (#abc) wird ausgeschrieben. */
function hexToRgb(hex: string): string | null {
if (!HEX.test(hex)) return null
let h = hex.slice(1)
if (h.length === 3) h = h.split('').map(c => c + c).join('')
const n = parseInt(h, 16)
return `${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}`
}
function setColor(name: string, value?: string) {
const root = document.documentElement
const rgb = value ? hexToRgb(value) : null
if (value && rgb) {
root.style.setProperty(`--color-${name}`, value)
root.style.setProperty(`--color-${name}-rgb`, rgb)
} else {
// Ungültig oder leer: Vorgaben aus index.css wieder greifen lassen.
root.style.removeProperty(`--color-${name}`)
root.style.removeProperty(`--color-${name}-rgb`)
}
}
export function applyTheme(primary?: string, accent?: string) {
setColor('primary', primary)
setColor('accent', accent)
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
// Vites Zusaetze zum Import: ?url, ?raw und der Seiteneffekt-Import von CSS.
"types": ["vite/client"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"allowArbitraryExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+104
View File
@@ -0,0 +1,104 @@
import { cpSync, mkdirSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { defineConfig, type Plugin } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { VitePWA } from 'vite-plugin-pwa'
/**
* Die 14 Standardschriften von PDF.js nach public/ kopieren.
*
* Aeltere PDFs betten Helvetica, Times und Courier nicht ein, sondern setzen
* sie voraus. Ohne diese Dateien malt PDF.js mit einer Ersatzschrift – lesbar,
* aber Zeilen brechen an anderer Stelle als im Original.
*
* Kopiert statt eingecheckt: 800 KB fremde Binaerdateien gehoeren nicht in den
* Quelltext, und die Fassung soll der Abhaengigkeit folgen. public/pdfjs steht
* deshalb in .gitignore.
*/
function pdfjsStandardFonts(): Plugin {
return {
name: 'aether-pdfjs-standard-fonts',
// Nicht 'buildStart': Im Entwicklungsbetrieb liest der Server public/
// beim Start ein, das Kopieren muss vorher passiert sein.
config() {
const require = createRequire(import.meta.url)
const from = join(dirname(require.resolve('pdfjs-dist/package.json')), 'standard_fonts')
const to = join(import.meta.dirname, 'public', 'pdfjs', 'standard_fonts')
mkdirSync(to, { recursive: true })
cpSync(from, to, { recursive: true })
},
}
}
export default defineConfig({
plugins: [
pdfjsStandardFonts(),
react(),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
workbox: {
// Der Service Worker beantwortet jede Navigation aus dem Cache mit
// index.html – nötig, damit Adressen wie /admin oder /s/<token> auch
// offline funktionieren. Ohne diese Ausnahmen erwischt er aber auch
// Downloads: Ein Klick auf „Download" öffnet eine Navigation zu
// /api/v1/files/<id>/download, der Worker liefert die Anwendung statt
// der Datei, und es passiert sichtbar nichts.
navigateFallback: 'index.html',
navigateFallbackDenylist: [/^\/api\//, /^\/dav\//, /^\/share\//, /^\/branding\//, /^\/wopi\//],
// PDF.js bleibt aussen vor: Betrachter (435 KB), Arbeiter (1,3 MB)
// und Standardschriften (800 KB) sind zusammen zweieinhalb Megabyte,
// die nur braucht, wer ein PDF oeffnet. Vorab in den Cache zu legen
// hiesse, das jeder Installation aufzuladen.
//
// Offline waere davon ohnehin nichts gewonnen: Die Dokumente selbst
// liegen auf dem Server und werden nicht mitgecacht – ein Betrachter
// ohne Datei nuetzt nichts.
globIgnores: [
'**/pdfjs/standard_fonts/**',
'**/pdf.worker*.mjs',
'**/PdfViewer-*.js',
'**/PdfViewer-*.css',
],
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024,
},
// Das Manifest liefert der Server (/manifest.webmanifest): Name, Farbe
// und Symbol stehen in der Datenbank und sollen dem Admin-Bereich
// folgen. Gebaut waeren sie fest verdrahtet, und ein hochgeladenes
// Symbol erreichte die installierte App nie.
manifest: false,
}),
],
server: {
host: true, // auf allen Interfaces lauschen (IPv4 + IPv6), nötig für VS-Code-Port-Forwarding
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
proxyTimeout: 60 * 60 * 1000,
timeout: 60 * 60 * 1000,
},
'/share': {
target: 'http://localhost:8080',
changeOrigin: true,
},
// Logo, App-Symbol und Manifest kommen ebenfalls vom Backend – ohne
// diese beiden Eintraege fehlen sie im Entwicklungsbetrieb.
'/branding': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/manifest.webmanifest': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test-setup.ts',
},
})