feat(aether): initial commit – self-hosted Cloud, Phasen 1–7

Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA.
Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb,
Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming,
Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office
(Collabora/OnlyOffice).

Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort)
sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 5 committed 2026-08-24 09:25:36 +02:00
commit 31a027c93a
225 files changed
+38042

No files matched your search

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