feat(aether): initial commit – self-hosted Cloud, Phasen 1–7
Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA. Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb, Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming, Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office (Collabora/OnlyOffice). Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort) sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
31a027c93a
225 files changed
+38042
No files matched your search
@@ -0,0 +1,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} />
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)]
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 '📁'
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/react'
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user