feat(aether): initial commit – self-hosted Cloud, Phasen 1–7
Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA. Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb, Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming, Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office (Collabora/OnlyOffice). Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort) sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
31a027c93a
225 files changed
+38042
No files matched your search
@@ -0,0 +1,217 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { appPasswordsApi, type AppPassword } from '../api/client'
|
||||
import ConfirmDialog from './ConfirmDialog'
|
||||
|
||||
/** Zugangsdaten für WebDAV-Clients, die das Hauptpasswort ersetzen: Geht ein
|
||||
* Gerät verloren, widerruft man dieses eine Token statt das Konto neu
|
||||
* abzusichern. */
|
||||
export default function AppPasswords({ username }: { username: string }) {
|
||||
const [list, setList] = useState<AppPassword[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [name, setName] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [revoking, setRevoking] = useState<AppPassword | null>(null)
|
||||
|
||||
// Nur einmal im Klartext vorhanden – danach kennt der Server nur den Hash.
|
||||
const [freshToken, setFreshToken] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setList(await appPasswordsApi.list() ?? [])
|
||||
} catch {
|
||||
setError('Could not load app passwords')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function create() {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
setCreating(true)
|
||||
setError('')
|
||||
try {
|
||||
const created = await appPasswordsApi.create(trimmed)
|
||||
setFreshToken(created.password)
|
||||
setCopied(false)
|
||||
setName('')
|
||||
await load()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Could not create app password')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(item: AppPassword) {
|
||||
setRevoking(null)
|
||||
try {
|
||||
await appPasswordsApi.revoke(item.id)
|
||||
await load()
|
||||
} catch {
|
||||
setError('Could not revoke app password')
|
||||
}
|
||||
}
|
||||
|
||||
function copyToken() {
|
||||
if (!freshToken) return
|
||||
navigator.clipboard?.writeText(freshToken).then(
|
||||
() => { setCopied(true); setTimeout(() => setCopied(false), 2000) },
|
||||
() => setError('Copying failed – please select the password manually'),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p style={{ margin: '-8px 0 16px', fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
||||
WebDAV and sync clients need an app password — your account password does
|
||||
not work there. Each one can be revoked on its own, so a lost device costs
|
||||
you one password, not your account.
|
||||
</p>
|
||||
|
||||
{/* Der Klartext erscheint genau einmal; deshalb prominent und mit
|
||||
deutlicher Ansage statt als beiläufige Zeile in der Liste. */}
|
||||
{freshToken && (
|
||||
<div style={{
|
||||
padding: 14, borderRadius: 10, marginBottom: 16,
|
||||
border: '1px solid var(--color-primary)',
|
||||
background: 'rgba(var(--color-primary-rgb), 0.08)',
|
||||
}}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--color-text)', marginBottom: 8 }}>
|
||||
Copy this password now — it is shown only once
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
|
||||
<code style={{
|
||||
flex: 1, padding: '9px 11px', borderRadius: 8,
|
||||
background: 'var(--color-surface-2)', color: 'var(--color-text)',
|
||||
fontSize: 14, fontFamily: 'monospace', letterSpacing: 0.5,
|
||||
userSelect: 'all', overflowX: 'auto', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{freshToken}
|
||||
</code>
|
||||
<button
|
||||
onClick={copyToken}
|
||||
style={{
|
||||
padding: '9px 14px', borderRadius: 8, border: 'none', flexShrink: 0,
|
||||
background: 'var(--color-primary)', color: '#fff',
|
||||
fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.6 }}>
|
||||
In your WebDAV client use username <strong style={{ color: 'var(--color-text)' }}>{username}</strong> and
|
||||
this password — not your account password.
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setFreshToken(null)}
|
||||
style={{
|
||||
marginTop: 10, padding: '5px 12px', borderRadius: 7,
|
||||
border: '1px solid var(--color-border)', background: 'transparent',
|
||||
color: 'var(--color-text-muted)', fontSize: 11, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
I've saved it
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') create() }}
|
||||
placeholder="Device name, e.g. Phone"
|
||||
style={{
|
||||
flex: 1, padding: '8px 10px', borderRadius: 8,
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-surface-2)', color: 'var(--color-text)',
|
||||
fontSize: 13, outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={create}
|
||||
disabled={creating || !name.trim()}
|
||||
style={{
|
||||
padding: '8px 18px', borderRadius: 8, border: 'none',
|
||||
background: 'var(--color-primary)', color: '#fff',
|
||||
fontSize: 13, fontWeight: 600,
|
||||
cursor: creating || !name.trim() ? 'default' : 'pointer',
|
||||
opacity: creating || !name.trim() ? 0.6 : 1, flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{creating ? 'Creating…' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p style={{ margin: '0 0 12px', fontSize: 12, color: 'var(--color-danger)' }}>{error}</p>
|
||||
)}
|
||||
|
||||
{/* List */}
|
||||
{loading ? (
|
||||
<p style={{ margin: 0, fontSize: 13, color: 'var(--color-text-muted)' }}>Loading…</p>
|
||||
) : list.length === 0 ? (
|
||||
<p style={{ margin: 0, fontSize: 13, color: 'var(--color-text-muted)' }}>
|
||||
No app passwords yet — you need one before a WebDAV client can connect.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{list.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '10px 12px', borderRadius: 9,
|
||||
border: '1px solid var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: 13, fontWeight: 600, color: 'var(--color-text)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{item.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2 }}>
|
||||
{item.last_used_at
|
||||
? `Last used ${new Date(item.last_used_at).toLocaleDateString()}`
|
||||
: 'Never used'}
|
||||
{' · '}
|
||||
Created {new Date(item.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setRevoking(item)}
|
||||
style={{
|
||||
padding: '5px 12px', borderRadius: 7, flexShrink: 0,
|
||||
border: '1px solid var(--color-danger)', background: 'transparent',
|
||||
color: 'var(--color-danger)', fontSize: 12, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{revoking && (
|
||||
<ConfirmDialog
|
||||
title="Revoke app password"
|
||||
message={`"${revoking.name}" will stop working immediately. Any device using it has to be set up again with a new password.`}
|
||||
confirmLabel="Revoke"
|
||||
danger
|
||||
onConfirm={() => revoke(revoking)}
|
||||
onCancel={() => setRevoking(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { publicApi, type ExternalApp } from '../api/client'
|
||||
|
||||
/**
|
||||
* DERZEIT NICHT EINGEHÄNGT (Stand 2026-07-30).
|
||||
*
|
||||
* Auf Wunsch wieder aus der Seitenleiste entfernt – erst soll Aether für sich
|
||||
* fertig werden. Die Komponente bleibt liegen, weil eine gemeinsame Leiste für
|
||||
* alle Apps noch überlegt wird. Wird sie nirgends importiert, landet sie auch
|
||||
* nicht im Bundle. Zum Reaktivieren: in `Sidebar.tsx` neben dem Markenschriftzug
|
||||
* einsetzen und EXTERNAL_APPS in `deploy/.env` füllen.
|
||||
*
|
||||
* Umschalter zu den anderen selbst gehosteten Apps.
|
||||
*
|
||||
* Die Einträge kommen aus EXTERNAL_APPS in der .env des Servers – so lassen
|
||||
* sich Apps ergänzen, ohne das Frontend neu zu bauen. Ist nichts gesetzt,
|
||||
* rendert die Komponente nichts.
|
||||
*
|
||||
* Die Ziele liegen auf anderen Ursprüngen (eigene Subdomain je App). Deshalb
|
||||
* `target="_blank"`: Ist die andere App als PWA installiert, übernimmt das
|
||||
* Betriebssystem sie als eigene App; sonst öffnet sie in einem neuen Tab.
|
||||
* Eine Navigation im selben Fenster würde die installierte Aether-App
|
||||
* verlassen und in einer Browserleiste landen.
|
||||
*/
|
||||
export default function AppSwitcher() {
|
||||
const [apps, setApps] = useState<ExternalApp[]>([])
|
||||
const [open, setOpen] = useState(false)
|
||||
const boxRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
publicApi.config()
|
||||
.then(c => setApps(c.external_apps ?? []))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function onDown(e: MouseEvent) {
|
||||
if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
function onEsc(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onEsc)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onEsc)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (apps.length === 0) return null
|
||||
|
||||
return (
|
||||
<div ref={boxRef} style={{ position: 'relative', marginLeft: 'auto' }}>
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-label="Other apps"
|
||||
aria-expanded={open}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 28, height: 28, borderRadius: 8, cursor: 'pointer',
|
||||
background: open ? 'var(--color-surface-2)' : 'transparent',
|
||||
border: 'none', color: 'var(--color-text-muted)', padding: 0,
|
||||
}}
|
||||
>
|
||||
{/* Raster aus neun Punkten – das übliche Zeichen für einen App-Wechsel */}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
{[5, 12, 19].map(y => [5, 12, 19].map(x => (
|
||||
<circle key={`${x}-${y}`} cx={x} cy={y} r="1.8" />
|
||||
)))}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 34, right: 0, zIndex: 60, minWidth: 190,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius)',
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.4)',
|
||||
padding: 4,
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '6px 10px 4px', fontSize: 11, fontWeight: 600,
|
||||
color: 'var(--color-text-muted)', textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
}}>
|
||||
My apps
|
||||
</div>
|
||||
{apps.map(app => (
|
||||
<a
|
||||
key={app.url}
|
||||
href={app.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => setOpen(false)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '8px 10px', borderRadius: 8,
|
||||
fontSize: 13, color: 'var(--color-text)', textDecoration: 'none',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = 'var(--color-surface-2)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<span style={{ fontSize: 16, width: 20, textAlign: 'center' }}>{app.icon || '🔗'}</span>
|
||||
{app.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { FolderItem } from '../api/client'
|
||||
|
||||
interface Props {
|
||||
path: FolderItem[]
|
||||
onNavigate: (folderId: string | undefined) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Pfad in der Kopfleiste.
|
||||
*
|
||||
* Schriftgröße und -stärke sind dieselben wie bei den Titeln der anderen
|
||||
* Ansichten („Trash", „Shared links"): 15 px, 600 für den aktuellen Ort. Bis
|
||||
* zum 23.08.2026 lief das über Tailwind-Klassen (`text-sm`, `font-medium`) und
|
||||
* war damit 14 px bei Stärke 500 – nebeneinander sieht man den Unterschied
|
||||
* sofort, auch ohne ihn benennen zu können.
|
||||
*
|
||||
* Auch das Symbol war kleiner (15 statt 18). Es ist jetzt Zeichen für Zeichen
|
||||
* dasselbe wie in der Seitenleiste.
|
||||
*/
|
||||
const rootLabel = 'Home'
|
||||
|
||||
export default function Breadcrumb({ path, onNavigate }: Props) {
|
||||
const atRoot = path.length === 0
|
||||
|
||||
const segment = (active: boolean) => ({
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
// 10 wie in PageHeader zwischen Symbol und Titel – bei 7 stand „Home"
|
||||
// drei Pixel weiter links als „Trash". Gemessen: 265 statt 268.
|
||||
gap: 10,
|
||||
padding: '2px 6px',
|
||||
borderRadius: 6,
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
cursor: 'pointer',
|
||||
// Gleiche Schrift wie die Seitentitel – sonst wirkt die Dateiansicht wie
|
||||
// eine andere Anwendung.
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.3,
|
||||
color: active ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
})
|
||||
|
||||
return (
|
||||
<nav style={{
|
||||
display: 'flex', alignItems: 'center', gap: 2,
|
||||
minWidth: 0, overflowX: 'auto',
|
||||
// Der Wurzel-Knopf hat innen 6 px Rand, damit sein Hover-Feld nicht am
|
||||
// Text klebt. Ohne diesen Ausgleich begänne das Haus-Symbol 6 px weiter
|
||||
// rechts als das Symbol in Papierkorb und Co. – beim Umschalten sieht man
|
||||
// genau diesen Sprung. Gemessen: 246 statt 240.
|
||||
marginLeft: -6,
|
||||
}}>
|
||||
<button onClick={() => onNavigate(undefined)} style={segment(atRoot)} title={rootLabel}>
|
||||
{/* Das Symbol bleibt gedämpft, auch wenn der Titel hell ist – genau so
|
||||
steht es im Papierkorb und bei den Freigabe-Links. Vorher erbte es
|
||||
die Textfarbe und leuchtete auf der Startebene heller als dort. */}
|
||||
<span style={{ display: 'flex', color: 'var(--color-text-muted)' }}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 10.5L12 3l9 7.5" />
|
||||
<path d="M5.5 9.5V20a1 1 0 001 1h11a1 1 0 001-1V9.5" />
|
||||
</svg>
|
||||
</span>
|
||||
{rootLabel}
|
||||
</button>
|
||||
|
||||
{path.map((folder, i) => (
|
||||
<span key={folder.id} style={{ display: 'flex', alignItems: 'center', gap: 2, minWidth: 0 }}>
|
||||
<span style={{ color: 'var(--color-text-muted)', fontSize: 15, opacity: 0.6 }}>/</span>
|
||||
<button
|
||||
onClick={() => onNavigate(folder.id)}
|
||||
style={{
|
||||
...segment(i === path.length - 1),
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
display: 'block', maxWidth: 220,
|
||||
}}
|
||||
title={folder.name}
|
||||
>
|
||||
{folder.name}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
message: string
|
||||
confirmLabel?: string
|
||||
danger?: boolean
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export default function ConfirmDialog({ title, message, confirmLabel = 'Confirm', danger = false, onConfirm, onCancel }: Props) {
|
||||
const overlay = useOverlayDismiss(onCancel)
|
||||
return (
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 200, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.6)' }}
|
||||
{...overlay}
|
||||
>
|
||||
<div style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 14,
|
||||
padding: 24,
|
||||
width: 360,
|
||||
maxWidth: '90vw',
|
||||
}}>
|
||||
<h2 style={{ margin: '0 0 8px', fontSize: 16, fontWeight: 600, color: 'var(--color-text)' }}>{title}</h2>
|
||||
<p style={{ margin: '0 0 24px', fontSize: 14, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>{message}</p>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8,
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'transparent', color: 'var(--color-text-muted)',
|
||||
fontSize: 13, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, border: 'none',
|
||||
background: danger ? 'var(--color-danger)' : 'var(--color-primary)',
|
||||
color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react'
|
||||
import { CheckIcon } from './icons'
|
||||
|
||||
export interface MenuAction {
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
danger?: boolean
|
||||
/** Setzt einen Haken rechts – für Einträge, die einen Zustand umschalten. */
|
||||
checked?: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
/** Eine Trennlinie gruppiert zusammengehörende Einträge. */
|
||||
export type MenuItem = MenuAction | { separator: true }
|
||||
|
||||
function isSeparator(item: MenuItem): item is { separator: true } {
|
||||
return 'separator' in item
|
||||
}
|
||||
|
||||
interface Props {
|
||||
x: number
|
||||
y: number
|
||||
items: MenuItem[]
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const ITEM_HEIGHT = 32
|
||||
const SEPARATOR_HEIGHT = 9
|
||||
const MENU_WIDTH = 190
|
||||
|
||||
export default function ContextMenu({ x, y, items, onClose }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose()
|
||||
}
|
||||
const esc = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||
document.addEventListener('mousedown', handler)
|
||||
document.addEventListener('keydown', esc)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handler)
|
||||
document.removeEventListener('keydown', esc)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
// Höhe aus dem Inhalt schätzen, statt eine feste Zahl anzunehmen: sonst
|
||||
// klappt ein langes Menü am unteren Rand aus dem Bild heraus.
|
||||
const height =
|
||||
items.reduce((h, i) => h + (isSeparator(i) ? SEPARATOR_HEIGHT : ITEM_HEIGHT), 0) + 8
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
top: Math.max(8, Math.min(y, window.innerHeight - height - 8)),
|
||||
left: Math.max(8, Math.min(x, window.innerWidth - MENU_WIDTH - 8)),
|
||||
zIndex: 50,
|
||||
minWidth: MENU_WIDTH,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius)',
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.4)',
|
||||
padding: '4px',
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} style={style}>
|
||||
{items.map((item, i) =>
|
||||
isSeparator(item) ? (
|
||||
<div
|
||||
key={`sep-${i}`}
|
||||
style={{ height: 1, background: 'var(--color-border)', margin: '4px 6px' }}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
key={item.label}
|
||||
onClick={() => { item.onClick(); onClose() }}
|
||||
style={{
|
||||
width: '100%', height: ITEM_HEIGHT,
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '0 10px', borderRadius: 6,
|
||||
border: 'none', background: 'transparent', cursor: 'pointer',
|
||||
fontSize: 13, textAlign: 'left',
|
||||
color: item.danger ? 'var(--color-danger)' : 'var(--color-text)',
|
||||
transition: 'background 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = item.danger
|
||||
? 'rgba(239,68,68,0.12)'
|
||||
: 'var(--color-surface-2)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
{/* Feste Spalte für das Icon – so stehen alle Beschriftungen bündig,
|
||||
auch bei Einträgen ohne Icon. */}
|
||||
<span style={{
|
||||
width: 15, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: item.danger ? 'var(--color-danger)' : 'var(--color-text-muted)',
|
||||
}}>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span style={{ flex: 1 }}>{item.label}</span>
|
||||
{item.checked && (
|
||||
<span style={{ color: 'var(--color-primary)', display: 'flex' }}>
|
||||
<CheckIcon size={14} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { type FormEvent, useEffect, useRef } from 'react'
|
||||
import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
defaultValue?: string
|
||||
placeholder?: string
|
||||
confirmLabel?: string
|
||||
onConfirm: (value: string) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export default function InputDialog({ title, defaultValue = '', placeholder, confirmLabel = 'OK', onConfirm, onCancel }: Props) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const overlay = useOverlayDismiss(onCancel)
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
inputRef.current?.select()
|
||||
}, [])
|
||||
|
||||
function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
const val = inputRef.current?.value.trim()
|
||||
if (val) onConfirm(val)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
style={{ background: 'rgba(0,0,0,0.6)' }}
|
||||
{...overlay}>
|
||||
<div className="w-full max-w-sm rounded-xl p-5 shadow-2xl border"
|
||||
style={{ background: 'var(--color-surface)', borderColor: 'var(--color-border)' }}>
|
||||
<h2 className="font-semibold mb-4" style={{ color: 'var(--color-text)' }}>{title}</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
className="w-full rounded-lg px-3 py-2.5 text-sm outline-none"
|
||||
style={{
|
||||
background: 'var(--color-surface-2)',
|
||||
border: '1px solid var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
onFocus={e => e.currentTarget.style.borderColor = 'var(--color-primary)'}
|
||||
onBlur={e => e.currentTarget.style.borderColor = 'var(--color-border)'}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={onCancel}
|
||||
className="px-4 py-2 text-sm rounded-lg transition-opacity hover:opacity-70"
|
||||
style={{ color: 'var(--color-text-muted)' }}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit"
|
||||
className="px-4 py-2 text-sm rounded-lg font-medium"
|
||||
style={{ background: 'var(--color-primary)', color: 'var(--color-primary-fg)' }}>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useState } from 'react'
|
||||
import { filesApi, type FileItem, type FolderItem } from '../api/client'
|
||||
import { FileTypeIcon, FolderGlyph } from './icons'
|
||||
import { fileKind, kindColors, extensionOf } from './filetypes'
|
||||
|
||||
/**
|
||||
* Das Bild auf einer Kachel.
|
||||
*
|
||||
* Bilder zeigen ihr Vorschaubild. Alles andere bekam vorher dieselbe blasse
|
||||
* Fläche mit einem kleinen Symbol – eine MP3, ein Archiv und eine Tabelle sahen
|
||||
* damit fast gleich aus. Jetzt trägt jede Art ihre eigene Farbe, ein
|
||||
* passendes Symbol und die Endung als Aufkleber: Man erkennt den Typ, bevor man
|
||||
* den Namen gelesen hat.
|
||||
*/
|
||||
export default function FileCover({ file }: { file: FileItem }) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
const kind = fileKind(file.mime_type, file.name)
|
||||
const color = kindColors[kind]
|
||||
const ext = extensionOf(file.name).toUpperCase()
|
||||
|
||||
if (file.has_thumbnail && !failed) {
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={filesApi.thumbnail(file.id)}
|
||||
alt=""
|
||||
onError={() => setFailed(true)}
|
||||
style={{
|
||||
width: '100%', height: '100%',
|
||||
objectFit: 'cover',
|
||||
position: 'absolute', inset: 0,
|
||||
}}
|
||||
/>
|
||||
{/* Auch über dem Vorschaubild: Sonst sieht man einem Bild nicht an, ob
|
||||
es ein PNG mit Transparenz, ein JPG oder ein GIF ist – und beim
|
||||
Weitergeben macht das einen Unterschied.
|
||||
In der Farbe der Art wie überall sonst; ein dunkler Kasten hätte die
|
||||
eine Kachel aus der Reihe fallen lassen. Damit er auf einem hellen
|
||||
Motiv nicht verschwimmt, bekommt er einen dunklen Saum. */}
|
||||
{ext && <Badge text={ext} background={color} ring />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
// Verlauf statt einer flachen Fläche: Der Unterschied zwischen zwei
|
||||
// Arten ist bei 12 % Deckkraft sonst kaum zu sehen.
|
||||
background: `linear-gradient(135deg, ${color}2e 0%, ${color}12 55%, transparent 100%)`,
|
||||
}}>
|
||||
<FileTypeIcon mime={file.mime_type} name={file.name} size={34} />
|
||||
|
||||
{ext && <Badge text={ext} background={color} />}
|
||||
|
||||
{/* Bei Ton und Video verrät ein Abspielzeichen sofort, dass hier ein
|
||||
Doppelklick etwas abspielt. */}
|
||||
{(kind === 'video' || kind === 'audio') && (
|
||||
<span style={{
|
||||
position: 'absolute', right: 7, bottom: 6,
|
||||
width: 18, height: 18, borderRadius: '50%',
|
||||
background: `${color}33`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill={color}>
|
||||
<polygon points="6 4 20 12 6 20" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Der Aufkleber unten links. Eine Stelle, damit er über einem Vorschaubild
|
||||
* und über einer Farbfläche gleich sitzt. */
|
||||
function Badge({ text, background, ring }: { text: string; background: string; ring?: boolean }) {
|
||||
return (
|
||||
<span style={{
|
||||
position: 'absolute', left: 8, bottom: 7,
|
||||
padding: '1px 5px', borderRadius: 4,
|
||||
background, color: '#fff',
|
||||
// Nur über einem Vorschaubild nötig – auf der ruhigen Farbfläche wäre
|
||||
// der Saum bloß Unruhe.
|
||||
boxShadow: ring ? '0 0 0 1px rgba(0,0,0,0.45), 0 1px 3px rgba(0,0,0,0.5)' : 'none',
|
||||
fontSize: 9, fontWeight: 700, letterSpacing: 0.4,
|
||||
textTransform: 'uppercase',
|
||||
maxWidth: 'calc(100% - 16px)', overflow: 'hidden',
|
||||
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Gegenstück für Ordner.
|
||||
*
|
||||
* Vorher ein flaches Symbol auf blasser Fläche – neben den neuen Dateikacheln
|
||||
* sah der Ordner damit aus wie vergessen. Jetzt zwei Lagen mit Reiter, die
|
||||
* Tiefe andeuten, dazu derselbe Verlauf wie bei den Dateien.
|
||||
*
|
||||
* Ein Gruppenordner trägt die Primärfarbe statt Bernstein und ein
|
||||
* Personen-Zeichen: Er gehört nicht dir allein, und das soll man sehen, bevor
|
||||
* man etwas hineinlegt.
|
||||
*/
|
||||
export function FolderCover({ folder, highlight }: { folder: FolderItem; highlight?: boolean }) {
|
||||
const isGroup = folder.group_id !== null
|
||||
// Der Gruppenfall über die Token-Variable, damit er der eingestellten
|
||||
// Palette folgt; Bernstein ist fest, weil es die Ordnerfarbe der App ist.
|
||||
const tint = isGroup ? 'rgba(var(--color-primary-rgb), ALPHA)' : 'rgba(245, 158, 11, ALPHA)'
|
||||
const at = (a: number) => tint.replace('ALPHA', String(a))
|
||||
const solid = isGroup ? 'rgb(var(--color-primary-rgb))' : '#f59e0b'
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: highlight
|
||||
? 'rgba(var(--color-primary-rgb), 0.16)'
|
||||
: `linear-gradient(135deg, ${at(0.3)} 0%, ${at(0.12)} 55%, transparent 100%)`,
|
||||
transition: 'background 0.12s',
|
||||
}}>
|
||||
<FolderGlyph size={48} color={solid} />
|
||||
|
||||
{isGroup && (
|
||||
<span style={{
|
||||
position: 'absolute', left: 8, bottom: 7,
|
||||
display: 'flex', alignItems: 'center', gap: 3,
|
||||
padding: '1px 5px', borderRadius: 4,
|
||||
background: solid, color: '#fff',
|
||||
fontSize: 9, fontWeight: 700, letterSpacing: 0.4,
|
||||
}}>
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4">
|
||||
<circle cx="9" cy="8" r="3.5" />
|
||||
<path d="M2 21v-1a7 7 0 0114 0v1" />
|
||||
<path d="M17 11a3 3 0 100-6M19 21v-1a6 6 0 00-3-5.2" />
|
||||
</svg>
|
||||
GROUP
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { filesApi, type Correspondent, type FileItem, type Tag } from '../api/client'
|
||||
import { formatBytes } from '../pages/FileBrowser'
|
||||
import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
|
||||
import TagChip from './TagChip'
|
||||
|
||||
/**
|
||||
* „Properties" einer Datei – das, was Dateimanager unter Windows und Linux
|
||||
* im Rechtsklickmenü anbieten.
|
||||
*
|
||||
* Anlass war eine Lücke, die man erst beim Benutzen merkt: Bei einem Bild
|
||||
* stand nirgends, **wie groß** es ist. Nicht in Byte – in Pixeln. Die Kachel
|
||||
* zeigt eine Vorschau, die Liste zeigt Kilobyte, und ob das Foto 800 oder
|
||||
* 4000 Punkte breit ist, war der Anwendung schlicht nicht zu entlocken.
|
||||
*
|
||||
* Die Maße kommen **hier im Browser** zustande, nicht vom Server: Er
|
||||
* speichert sie nicht, und für eine Angabe in einem Dialog lohnt weder eine
|
||||
* Spalte in der Datenbank noch ein Durchlauf über alle bestehenden Dateien.
|
||||
* Der Browser kann jedes Bild, das er anzeigen kann, auch ausmessen.
|
||||
*/
|
||||
interface Props {
|
||||
file: FileItem
|
||||
tags: Tag[]
|
||||
correspondent?: Correspondent
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
day: '2-digit', month: 'short', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
/** Der Teil hinter dem letzten Punkt, in Großbuchstaben. Leer, wenn keiner. */
|
||||
function extensionOf(name: string): string {
|
||||
const at = name.lastIndexOf('.')
|
||||
return at > 0 && at < name.length - 1 ? name.slice(at + 1).toUpperCase() : ''
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 12, padding: '5px 0', alignItems: 'baseline' }}>
|
||||
<div style={{
|
||||
width: 104, flexShrink: 0, fontSize: 12,
|
||||
color: 'var(--color-text-muted)',
|
||||
}}>{label}</div>
|
||||
<div style={{ flex: 1, minWidth: 0, fontSize: 13, color: 'var(--color-text)' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FileInfoDialog({ file, tags, correspondent, onClose }: Props) {
|
||||
const overlay = useOverlayDismiss(onClose)
|
||||
const [dims, setDims] = useState<{ w: number; h: number } | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const isImage = file.mime_type.startsWith('image/')
|
||||
|
||||
useEffect(() => {
|
||||
if (!isImage) return
|
||||
let alive = true
|
||||
const img = new Image()
|
||||
// Dieselbe Adresse, die auch die Vorschau nimmt – das Bild liegt danach
|
||||
// ohnehin im Zwischenspeicher des Browsers.
|
||||
img.src = filesApi.inline(file.id)
|
||||
img.onload = () => {
|
||||
if (alive) setDims({ w: img.naturalWidth, h: img.naturalHeight })
|
||||
}
|
||||
// Kein onerror-Zweig: Kann der Browser das Bild nicht lesen (etwa ein
|
||||
// Format, das er nicht kennt), bleibt die Zeile einfach weg. Eine
|
||||
// Fehlermeldung über ein Maß, das niemand angefordert hat, wäre nur Lärm.
|
||||
return () => { alive = false; img.src = '' }
|
||||
}, [file.id, isImage])
|
||||
|
||||
async function copyChecksum() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(file.checksum)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
/* Ohne sicheren Kontext gibt es keine Zwischenablage – dann bleibt der
|
||||
Wert zum Markieren stehen, er ist ja vollständig zu sehen. */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
style={{ background: 'rgba(0,0,0,0.6)' }}
|
||||
{...overlay}>
|
||||
<div className="w-full max-w-md rounded-xl p-5 shadow-2xl border"
|
||||
style={{ background: 'var(--color-surface)', borderColor: 'var(--color-border)' }}>
|
||||
<h2 className="font-semibold mb-1" style={{ color: 'var(--color-text)' }}>Properties</h2>
|
||||
<p className="mb-3" style={{
|
||||
fontSize: 13, color: 'var(--color-text-muted)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}} title={file.name}>{file.name}</p>
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--color-border)', paddingTop: 6 }}>
|
||||
<Row label="Type">
|
||||
{extensionOf(file.name) && <strong style={{ fontWeight: 600 }}>{extensionOf(file.name)} </strong>}
|
||||
<span style={{ color: 'var(--color-text-muted)' }}>{file.mime_type}</span>
|
||||
</Row>
|
||||
|
||||
<Row label="Size">
|
||||
{formatBytes(file.size)}
|
||||
{/* Die genaue Zahl daneben: Beim Vergleich zweier Fassungen sind
|
||||
„1,2 MB" und „1,2 MB" dasselbe, 1 261 «und» 1 264 Kilobyte
|
||||
nicht. */}
|
||||
<span style={{ color: 'var(--color-text-muted)' }}>
|
||||
{' '}({file.size.toLocaleString()} bytes)
|
||||
</span>
|
||||
</Row>
|
||||
|
||||
{isImage && (
|
||||
<Row label="Dimensions">
|
||||
{dims
|
||||
? <>{dims.w} × {dims.h} px
|
||||
<span style={{ color: 'var(--color-text-muted)' }}>
|
||||
{' '}({(dims.w * dims.h / 1e6).toFixed(1)} MP)
|
||||
</span>
|
||||
</>
|
||||
: <span style={{ color: 'var(--color-text-muted)' }}>measuring…</span>}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{file.document_date && <Row label="Document date">{file.document_date}</Row>}
|
||||
|
||||
{correspondent && (
|
||||
<Row label="From">
|
||||
{correspondent.icon ? `${correspondent.icon} ${correspondent.name}` : correspondent.name}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{tags.length > 0 && (
|
||||
<Row label="Tags">
|
||||
<span style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{tags.map(t => <TagChip key={t.id} tag={t} size="sm" />)}
|
||||
</span>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Row label="Uploaded">{formatDate(file.created_at)}</Row>
|
||||
<Row label="Modified">{formatDate(file.updated_at)}</Row>
|
||||
|
||||
<Row label="Version">
|
||||
{file.version}
|
||||
<span style={{ color: 'var(--color-text-muted)' }}>
|
||||
{' '}({file.versioning_enabled ? 'history kept' : 'history off'})
|
||||
</span>
|
||||
</Row>
|
||||
|
||||
{/* Die Prüfsumme steht schon in der Datenbank und wird bei jedem
|
||||
Download nachgerechnet. Sie hier zu zeigen kostet nichts und
|
||||
beantwortet die eine Frage, für die es sonst keinen Weg gibt:
|
||||
„Ist das dieselbe Datei wie die auf meiner Platte?" */}
|
||||
<Row label="SHA-256">
|
||||
<button
|
||||
onClick={copyChecksum}
|
||||
title="Copy"
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, monospace', fontSize: 11, lineHeight: 1.4,
|
||||
wordBreak: 'break-all', textAlign: 'left', cursor: 'pointer',
|
||||
background: 'none', border: 'none', padding: 0,
|
||||
color: copied ? 'var(--color-primary)' : 'var(--color-text-muted)',
|
||||
}}
|
||||
>
|
||||
{copied ? 'copied' : file.checksum}
|
||||
</button>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-4">
|
||||
<button onClick={onClose}
|
||||
className="px-4 py-2 text-sm rounded-lg font-medium"
|
||||
style={{ background: 'var(--color-primary)', color: 'var(--color-primary-fg)' }}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
import { lazy, Suspense, useEffect, useState, useSyncExternalStore } from 'react'
|
||||
import { filesApi, APIError, type FileItem } from '../api/client'
|
||||
import { fileKind } from './filetypes'
|
||||
|
||||
/**
|
||||
* Nachgeladen, nicht mitgeliefert: PDF.js wiegt rund eine halbe Megabyte im
|
||||
* Hauptbündel und noch einmal 1,3 MB im Arbeiter. Wer nur Bilder ansieht,
|
||||
* soll das nicht bei jedem Besuch herunterladen – geholt wird es erst beim
|
||||
* ersten PDF und liegt danach im Cache des Browsers.
|
||||
*/
|
||||
const PdfViewer = lazy(() => import('./PdfViewer'))
|
||||
|
||||
interface Props {
|
||||
file: FileItem
|
||||
/** Blättern innerhalb des Ordners – null, wenn es nichts zu blättern gibt. */
|
||||
onPrev?: () => void
|
||||
onNext?: () => void
|
||||
onClose: () => void
|
||||
/** Nach dem Speichern: Liste neu holen, damit Größe und Datum stimmen. */
|
||||
onSaved?: () => void
|
||||
/**
|
||||
* Suchwort, mit dem die Datei gefunden wurde. Der PDF-Betrachter springt
|
||||
* damit zur Fundstelle, statt auf Seite 1 zu beginnen – der Grund, aus dem
|
||||
* es überhaupt ein eigener Betrachter geworden ist.
|
||||
*/
|
||||
searchTerm?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Was der Browser selbst anzeigen kann, zeigt er: Bilder, PDF, Text, Video,
|
||||
* Ton. Der Server liefert diese Typen mit `inline`, alles andere bleibt ein
|
||||
* Download – die Liste steht in internal/files/handler.go und ist eine
|
||||
* Positivliste, damit eine hochgeladene HTML-Datei nie im Ursprung der
|
||||
* Anwendung läuft.
|
||||
*/
|
||||
type Kind = 'image' | 'pdf' | 'text' | 'video' | 'audio' | 'none'
|
||||
|
||||
/**
|
||||
* Der Name zählt mit, nicht nur der MIME-Typ: Ältere Dateien liegen als
|
||||
* application/octet-stream in der Datenbank, weil der Server bis zum
|
||||
* 23.08.2026 die Endungen .mp3 und .mp4 nicht kannte. Ohne den Namen ließe
|
||||
* sich eine vorhandene MP3 also nie abspielen.
|
||||
*/
|
||||
function kindOf(mime: string, name = ''): Kind {
|
||||
const m = (mime || '').toLowerCase()
|
||||
// SVG bleibt draußen: Der Server liefert sie nie inline aus, weil eine
|
||||
// SVG-Datei Skripte enthalten darf.
|
||||
if (m.includes('svg')) return 'none'
|
||||
|
||||
switch (fileKind(mime, name)) {
|
||||
case 'image': return 'image'
|
||||
case 'pdf': return 'pdf'
|
||||
case 'video': return 'video'
|
||||
case 'audio': return 'audio'
|
||||
case 'text':
|
||||
case 'code': return 'text'
|
||||
case 'doc':
|
||||
// Nur echte Textdateien, kein .docx – das ist ein ZIP-Archiv.
|
||||
return m.startsWith('text/') || /\.(txt|md|log)$/i.test(name) ? 'text' : 'none'
|
||||
default: return 'none'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unterhalb dieser Breite ist der Bildschirm ein Telefon.
|
||||
*
|
||||
* Dort zählt jeder Pixel: Ein PDF, das nach Rand und Blätterpfeilen nur noch
|
||||
* die halbe Breite hat, wird bei 51 % dargestellt – lesbar ist das nicht mehr.
|
||||
*/
|
||||
const narrowQuery = '(max-width: 640px)'
|
||||
|
||||
function useNarrow(): boolean {
|
||||
return useSyncExternalStore(
|
||||
cb => {
|
||||
const m = window.matchMedia(narrowQuery)
|
||||
m.addEventListener('change', cb)
|
||||
return () => m.removeEventListener('change', cb)
|
||||
},
|
||||
() => window.matchMedia(narrowQuery).matches,
|
||||
() => false,
|
||||
)
|
||||
}
|
||||
|
||||
/** Grenze fürs Nachladen von Text. Darüber wird nicht angezeigt, sondern
|
||||
* heruntergeladen – ein 200-MB-Protokoll bringt jeden Browser ins Stocken. */
|
||||
const maxTextBytes = 2 * 1024 * 1024
|
||||
|
||||
export function canPreview(file: FileItem): boolean {
|
||||
return kindOf(file.mime_type, file.name) !== 'none'
|
||||
}
|
||||
|
||||
export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, searchTerm }: Props) {
|
||||
const kind = kindOf(file.mime_type, file.name)
|
||||
const narrow = useNarrow()
|
||||
const src = filesApi.inline(file.id)
|
||||
const [text, setText] = useState<string | null>(null)
|
||||
const [tooBig, setTooBig] = useState(false)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState('')
|
||||
const dirty = editing && draft !== (text ?? '')
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
// Im Textfeld gehören die Pfeiltasten dem Schreiben, nicht dem Blättern.
|
||||
if (editing) {
|
||||
if (e.key === 'Escape') tryClose()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') { onClose(); return }
|
||||
// Die Pfeiltasten blättern zwischen Dateien – aber nicht, während
|
||||
// jemand in einem Feld schreibt. Der PDF-Betrachter hat eine eigene
|
||||
// Suche; dort setzte ein Pfeil sonst mitten im Wort die nächste Datei
|
||||
// vor.
|
||||
const el = document.activeElement
|
||||
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) return
|
||||
if (e.key === 'ArrowLeft') onPrev?.()
|
||||
else if (e.key === 'ArrowRight') onNext?.()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [onClose, onPrev, onNext, editing, dirty])
|
||||
|
||||
useEffect(() => {
|
||||
if (kind !== 'text') return
|
||||
setText(null); setTooBig(false)
|
||||
if (file.size > maxTextBytes) { setTooBig(true); return }
|
||||
setEditing(false); setSaveError('')
|
||||
let alive = true
|
||||
fetch(src, { credentials: 'include' })
|
||||
.then(r => r.text())
|
||||
.then(t => { if (alive) { setText(t); setDraft(t) } })
|
||||
.catch(() => { if (alive) setText('(could not load this file)') })
|
||||
return () => { alive = false }
|
||||
}, [src, kind, file.size])
|
||||
|
||||
/**
|
||||
* Gespeichert wird über denselben Weg wie ein Upload: gleicher Name, gleicher
|
||||
* Ordner. Der Server ersetzt die Datei damit und legt – wenn Versionierung
|
||||
* für sie eingeschaltet ist – die alte Fassung in die Historie. Ein eigener
|
||||
* Endpunkt zum Schreiben bräuchte dieselben Prüfungen noch einmal.
|
||||
*/
|
||||
async function save() {
|
||||
setSaving(true); setSaveError('')
|
||||
try {
|
||||
const blob = new File([draft], file.name, { type: file.mime_type || 'text/plain' })
|
||||
await filesApi.upload(blob, file.folder_id ?? undefined)
|
||||
setText(draft)
|
||||
setEditing(false)
|
||||
onSaved?.()
|
||||
} catch (err) {
|
||||
// Nur-Lese-Zugriff auf eine geliehene Datei ist der häufigste Fall –
|
||||
// deshalb hier ausgeschrieben statt "403".
|
||||
setSaveError(
|
||||
err instanceof APIError && err.status === 403
|
||||
? 'You only have read access to this file.'
|
||||
: err instanceof Error ? err.message : 'Could not save',
|
||||
)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function tryClose() {
|
||||
if (dirty && !confirm('Discard your changes?')) return
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={tryClose}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 100,
|
||||
background: 'rgba(0,0,0,0.82)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Kopfzeile */}
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '10px 14px', flexShrink: 0,
|
||||
background: 'var(--color-surface)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
flex: 1, minWidth: 0, fontSize: 14, color: 'var(--color-text)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{file.name}
|
||||
</span>
|
||||
|
||||
{kind === 'text' && !tooBig && text !== null && (
|
||||
editing ? (
|
||||
<>
|
||||
{saveError && (
|
||||
<span style={{ fontSize: 12, color: 'var(--color-danger)' }}>{saveError}</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setDraft(text ?? ''); setEditing(false); setSaveError('') }}
|
||||
disabled={saving}
|
||||
style={{
|
||||
padding: '6px 12px', borderRadius: 8, fontSize: 12,
|
||||
border: '1px solid var(--color-border)', background: 'transparent',
|
||||
color: 'var(--color-text-muted)', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={saving || !dirty}
|
||||
style={{
|
||||
padding: '6px 12px', borderRadius: 8, fontSize: 12, fontWeight: 600,
|
||||
border: 'none', background: 'var(--color-primary)', color: '#fff',
|
||||
cursor: saving || !dirty ? 'default' : 'pointer',
|
||||
opacity: saving || !dirty ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
style={{
|
||||
padding: '6px 12px', borderRadius: 8, fontSize: 12,
|
||||
border: '1px solid var(--color-border)', background: 'transparent',
|
||||
color: 'var(--color-text-muted)', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
<a
|
||||
href={filesApi.download(file.id)}
|
||||
download={file.name}
|
||||
style={{
|
||||
padding: '6px 12px', borderRadius: 8, fontSize: 12, fontWeight: 600,
|
||||
background: 'var(--color-primary)', color: '#fff', textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
<button
|
||||
onClick={tryClose}
|
||||
title="Close (Esc)"
|
||||
style={{
|
||||
padding: 6, borderRadius: 8, border: 'none', background: 'none',
|
||||
color: 'var(--color-text-muted)', cursor: 'pointer', display: 'flex',
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Inhalt */}
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
flex: 1, minHeight: 0, display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
// Ein PDF bekommt fast den ganzen Platz: Es bringt seinen eigenen
|
||||
// weißen Rand mit, ein zweiter darum herum verschenkt nur Breite.
|
||||
padding: kind === 'pdf' ? (narrow ? 2 : 8) : 16,
|
||||
}}
|
||||
>
|
||||
{/* Am Telefon blättert man nicht per Pfeil zur nächsten Datei, während
|
||||
man ein Dokument liest – dort lägen die Knöpfe mitten auf dem Text
|
||||
und würden beim Wischen getroffen. */}
|
||||
{onPrev && !(kind === 'pdf' && narrow) && <PagerButton dir="prev" onClick={onPrev} />}
|
||||
|
||||
{kind === 'image' && (
|
||||
<img src={src} alt={file.name}
|
||||
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} />
|
||||
)}
|
||||
|
||||
{kind === 'pdf' && (
|
||||
// Eigener Betrachter statt des eingebauten im <iframe>: Chrome auf
|
||||
// Android zeigte darin gar nichts an, und ein Suchtreffer ließ sich
|
||||
// ihm nicht mitteilen. Siehe PdfViewer.tsx.
|
||||
<Suspense fallback={
|
||||
<span style={{ color: 'var(--color-text-muted)', fontSize: 13 }}>Loading…</span>
|
||||
}>
|
||||
<PdfViewer url={src} fileId={file.id} searchTerm={searchTerm} />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{kind === 'video' && (
|
||||
<video src={src} controls autoPlay
|
||||
style={{ maxWidth: '100%', maxHeight: '100%', borderRadius: 8 }} />
|
||||
)}
|
||||
|
||||
{kind === 'audio' && (
|
||||
<audio src={src} controls style={{ width: 'min(520px, 90%)' }} />
|
||||
)}
|
||||
|
||||
{/* Text bekommt die Form eines Blattes statt der ganzen Fläche: über
|
||||
die volle Breite gelesen springt das Auge beim Zeilenwechsel, und
|
||||
eine kurze Notiz auf einem riesigen Feld wirkt erschlagend. */}
|
||||
{kind === 'text' && (
|
||||
<div style={{
|
||||
width: '100%', maxWidth: 820, height: '100%', maxHeight: 680,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
borderRadius: 10, overflow: 'hidden',
|
||||
border: `1px solid ${editing ? 'var(--color-primary)' : 'var(--color-border)'}`,
|
||||
background: 'var(--color-surface)',
|
||||
boxShadow: '0 12px 40px rgba(0,0,0,0.35)',
|
||||
}}>
|
||||
{editing ? (
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
style={{
|
||||
flex: 1, resize: 'none', width: '100%',
|
||||
padding: '18px 20px', border: 'none',
|
||||
background: 'transparent', color: 'var(--color-text)',
|
||||
fontFamily: 'ui-monospace, monospace',
|
||||
fontSize: 13, lineHeight: 1.65, outline: 'none',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<pre style={{
|
||||
flex: 1, margin: 0, overflow: 'auto',
|
||||
padding: '18px 20px',
|
||||
color: 'var(--color-text)',
|
||||
fontFamily: 'ui-monospace, monospace',
|
||||
fontSize: 13, lineHeight: 1.65,
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||
}}>
|
||||
{tooBig
|
||||
? 'This file is too large to show here – use Download.'
|
||||
: text ?? 'Loading…'}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onNext && !(kind === 'pdf' && narrow) && <PagerButton dir="next" onClick={onNext} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PagerButton({ dir, onClick }: { dir: 'prev' | 'next'; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={dir === 'prev' ? 'Previous (←)' : 'Next (→)'}
|
||||
style={{
|
||||
position: 'absolute', [dir === 'prev' ? 'left' : 'right']: 12,
|
||||
top: '50%', transform: 'translateY(-50%)',
|
||||
width: 40, height: 40, borderRadius: '50%',
|
||||
border: 'none', background: 'rgba(0,0,0,0.5)', color: '#fff',
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points={dir === 'prev' ? '15 18 9 12 15 6' : '9 18 15 12 9 6'} />
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { type Correspondent, type FileItem, type FolderItem, type Tag } from '../api/client'
|
||||
import { TagRow } from './TagDialog'
|
||||
import TagChip from './TagChip'
|
||||
import { FileTypeIcon, FolderGlyph } from './icons'
|
||||
import FileCover, { FolderCover } from './FileCover'
|
||||
import { formatBytes } from '../pages/FileBrowser'
|
||||
|
||||
/**
|
||||
* Die beiden Darstellungen einer Ordnerliste: Kacheln und Zeilen.
|
||||
*
|
||||
* Lagen bis zum 23.08.2026 unten in FileBrowser.tsx und machten die Seite über
|
||||
* 1100 Zeilen lang. Sie hängen nur an ihren Eigenschaften, nicht am Zustand der
|
||||
* Seite – hier stehen sie für sich.
|
||||
*/
|
||||
|
||||
/** Klein in der Liste – dieselbe Form wie auf der Kachel. 24 statt 22 Pixel,
|
||||
* weil 48/24 glatt aufgeht und die Kanten damit scharf bleiben. */
|
||||
function FolderIcon({ size = 24, group = false }: { size?: number; group?: boolean }) {
|
||||
return <FolderGlyph size={size} color={group ? 'rgb(var(--color-primary-rgb))' : '#f59e0b'} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt der Nutzer mit dem Finger oder mit einer Maus?
|
||||
*
|
||||
* `pointer: coarse` ist die Frage nach der Genauigkeit des Zeigegeräts, nicht
|
||||
* nach der Bildschirmbreite – ein schmales Fenster auf dem Schreibtisch bleibt
|
||||
* damit richtig eine Maus, ein Tablet im Querformat richtig ein Finger.
|
||||
*
|
||||
* Der Anlass: **Auf dem Handy öffnete gar nichts.** Ordner und Dateien hingen
|
||||
* an `onDoubleClick`, und den doppelten Klick gibt es auf einem Touchscreen
|
||||
* praktisch nicht – der Browser meldet zwei einzelne Berührungen, weil man
|
||||
* nicht zweimal auf denselben Pixel tippt. Der Doppelklick ist eine
|
||||
* Mausgeste; am Finger ist er nie angekommen.
|
||||
*/
|
||||
function useCoarsePointer(): boolean {
|
||||
const [coarse, setCoarse] = useState(
|
||||
() => typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches,
|
||||
)
|
||||
useEffect(() => {
|
||||
if (typeof matchMedia !== 'function') return
|
||||
const mq = matchMedia('(pointer: coarse)')
|
||||
const on = () => setCoarse(mq.matches)
|
||||
mq.addEventListener('change', on)
|
||||
return () => mq.removeEventListener('change', on)
|
||||
}, [])
|
||||
return coarse
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Öffnen-Geste, je nach Zeigegerät.
|
||||
*
|
||||
* Maus: Doppelklick – der einfache Klick bleibt zum Auswählen frei.
|
||||
* Finger: **einfacher Tipp**. So macht es jede Dateiverwaltung am Telefon,
|
||||
* und etwas anderes wäre dort nicht zu erraten.
|
||||
*
|
||||
* Nur für **Ordner**: Die haben keine Auswahl und keinen zweiten Klick-
|
||||
* Handler, deshalb genügt hier das Umschalten der Eigenschaft. Dateien
|
||||
* schreiben ihren Handler selbst aus – warum, steht dort.
|
||||
*/
|
||||
function openGesture(coarse: boolean, open: () => void) {
|
||||
return coarse ? { onClick: open } : { onDoubleClick: open }
|
||||
}
|
||||
|
||||
interface ViewProps {
|
||||
folders: FolderItem[]
|
||||
files: FileItem[]
|
||||
onFolderOpen: (f: FolderItem) => void
|
||||
onFolderMenu: (e: React.MouseEvent, f: FolderItem) => void
|
||||
onFileMenu: (e: React.MouseEvent, f: FileItem) => void
|
||||
/** Öffnen (Doppelklick bzw. Tipp): Vorschau, wenn möglich – sonst Download. */
|
||||
onFileOpen: (file: FileItem) => void
|
||||
onFileDownload: (file: FileItem) => void
|
||||
onFolderDrop: (e: React.DragEvent, folder: FolderItem) => void
|
||||
dragOverFolder: string | null
|
||||
onFolderDragOver: (id: string | null) => void
|
||||
/**
|
||||
* Datei, zu der gerade aus der Suche gesprungen wurde. Sie wird kurz
|
||||
* umrandet und in den sichtbaren Bereich geholt.
|
||||
*
|
||||
* Ohne das wäre der Sprung in einen Ordner mit fünfzig Dateien wertlos: Man
|
||||
* landet am richtigen Ort und sucht dann von vorn.
|
||||
*/
|
||||
highlightId?: string | null
|
||||
/** Etiketten je Datei-ID – kommen mit der Ordnerliste in einer Abfrage mit. */
|
||||
fileTags?: Record<string, Tag[]>
|
||||
/** Absender je Datei-ID. Anders als die Etiketten höchstens einer. */
|
||||
fileCorrespondents?: Record<string, Correspondent>
|
||||
/** Ausgewählte Dateien für den Sammel-Download. */
|
||||
selected?: Set<string>
|
||||
/** Umschalten; `range` bei gedrückter Umschalttaste. */
|
||||
onToggleSelect?: (id: string, range: boolean) => void
|
||||
}
|
||||
|
||||
/** Umrandung der eben angesprungenen Datei. */
|
||||
const highlightRing = '0 0 0 2px var(--color-primary)'
|
||||
|
||||
/**
|
||||
* Holt die markierte Datei in den sichtbaren Bereich.
|
||||
*
|
||||
* In einem eigenen Haken, weil Kachel- und Listenansicht ihn beide brauchen –
|
||||
* und weil es in einem Callback-Ref bei jedem Neuzeichnen erneut scrollen
|
||||
* würde, auch wenn sich gar nichts geändert hat.
|
||||
*/
|
||||
function useScrollToHighlight(highlightId?: string | null) {
|
||||
const ref = useRef<HTMLDivElement | null>(null)
|
||||
useEffect(() => {
|
||||
if (!highlightId || !ref.current) return
|
||||
ref.current.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
}, [highlightId])
|
||||
return ref
|
||||
}
|
||||
|
||||
export default function GridView({ folders, files, onFolderOpen, onFolderMenu, onFileMenu, onFileOpen, onFolderDrop, dragOverFolder, onFolderDragOver, highlightId, fileTags, fileCorrespondents, selected, onToggleSelect }: ViewProps) {
|
||||
const highlightRef = useScrollToHighlight(highlightId)
|
||||
const coarse = useCoarsePointer()
|
||||
return (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))',
|
||||
gap: 12,
|
||||
}}>
|
||||
{folders.map(folder => {
|
||||
const isOver = dragOverFolder === folder.id
|
||||
// Ein Gruppenordner lässt sich nicht verschieben – ihn ziehbar zu
|
||||
// machen, würde eine Bewegung anbieten, die der Server ablehnt.
|
||||
const isGroupFolder = folder.group_id !== null
|
||||
return (
|
||||
<div
|
||||
key={folder.id}
|
||||
draggable={!isGroupFolder}
|
||||
onDragStart={e => {
|
||||
if (isGroupFolder) { e.preventDefault(); return }
|
||||
e.dataTransfer.setData('aether-folder-id', folder.id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
}}
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: `1px solid ${isOver ? 'var(--color-primary)' : 'var(--color-border)'}`,
|
||||
borderRadius: 12, overflow: 'hidden', cursor: 'grab',
|
||||
transition: 'border-color 0.15s, transform 0.1s, background 0.1s',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
transform: isOver ? 'scale(1.03)' : 'none',
|
||||
boxShadow: isOver ? '0 0 0 3px rgba(var(--color-primary-rgb), 0.2)' : 'none',
|
||||
}}
|
||||
{...openGesture(coarse, () => onFolderOpen(folder))}
|
||||
onContextMenu={e => onFolderMenu(e, folder)}
|
||||
onMouseEnter={e => { if (!isOver) { e.currentTarget.style.borderColor = 'var(--color-primary)'; e.currentTarget.style.transform = 'translateY(-1px)' } }}
|
||||
onMouseLeave={e => { if (!isOver) { e.currentTarget.style.borderColor = 'var(--color-border)'; e.currentTarget.style.transform = 'none' } }}
|
||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); onFolderDragOver(folder.id) }}
|
||||
onDragLeave={e => { e.stopPropagation(); onFolderDragOver(null) }}
|
||||
onDrop={e => onFolderDrop(e, folder)}
|
||||
>
|
||||
<div style={{ height: 100, position: 'relative', overflow: 'hidden' }}>
|
||||
<FolderCover folder={folder} highlight={isOver} />
|
||||
</div>
|
||||
<div style={{ padding: '8px 10px 10px' }}>
|
||||
<p style={{ margin: 0, fontSize: 12, fontWeight: 500, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{folder.name}
|
||||
</p>
|
||||
<p style={{
|
||||
margin: '2px 0 0', fontSize: 11,
|
||||
color: isGroupFolder ? 'var(--color-primary)' : 'var(--color-text-muted)',
|
||||
display: 'flex', alignItems: 'center', gap: 4,
|
||||
}}>
|
||||
{isGroupFolder && (
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
|
||||
<circle cx="9" cy="8" r="3.5" />
|
||||
<path d="M2 21v-1a7 7 0 0114 0v1" />
|
||||
<path d="M17 11a3 3 0 100-6M19 21v-1a6 6 0 00-3-5.2" />
|
||||
</svg>
|
||||
)}
|
||||
{isGroupFolder ? 'Group folder' : 'Folder'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{files.map(file => (
|
||||
<div
|
||||
key={file.id}
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.setData('aether-file-id', file.id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
}}
|
||||
ref={file.id === highlightId ? highlightRef : undefined}
|
||||
onDoubleClick={() => { if (!coarse) onFileOpen(file) }}
|
||||
onClick={e => {
|
||||
// Am Finger öffnet der Tipp – bzw. schaltet die Auswahl um,
|
||||
// solange eine läuft. Ohne Strg- und Umschalttaste wäre eine
|
||||
// Mehrfachauswahl am Telefon sonst gar nicht zu bedienen.
|
||||
//
|
||||
// Das steht **hier** und nicht als eigener Handler daneben: In
|
||||
// JSX gewinnt das zuletzt notierte `onClick`. Ein zweites, per
|
||||
// Spread eingesetztes wurde von diesem hier stillschweigend
|
||||
// überschrieben – gemessen: der Tipp auf ein PDF tat nichts,
|
||||
// während der auf einen Ordner funktionierte.
|
||||
if (coarse) {
|
||||
if ((selected?.size ?? 0) > 0) { onToggleSelect?.(file.id, false); return }
|
||||
onFileOpen(file)
|
||||
return
|
||||
}
|
||||
// **Nur** mit Zusatztaste – der einfache Klick bleibt frei.
|
||||
//
|
||||
// Der erste Entwurf wählte auch einfach aus, sobald schon etwas
|
||||
// markiert war. Das kollidiert mit dem Doppelklick: Dessen erster
|
||||
// Klick löst dann eine Auswahl aus, und statt die Vorschau zu
|
||||
// öffnen ändert sich die Markierung. Beim Testen gemessen – aus
|
||||
// drei Ausgewählten wurden vier, und die Vorschau blieb zu.
|
||||
//
|
||||
// Zum Auswählen bleiben das Kästchen und Strg/Umschalt. Das ist
|
||||
// eindeutig, und der Doppelklick tut wieder genau eine Sache.
|
||||
if (!onToggleSelect) return
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
||||
e.preventDefault()
|
||||
onToggleSelect(file.id, e.shiftKey)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 12, overflow: 'hidden', cursor: 'grab',
|
||||
// boxShadow statt border: Ein dickerer Rahmen würde die Kachel um
|
||||
// einen Pixel wachsen lassen und die ganze Reihe verschieben.
|
||||
boxShadow: selected?.has(file.id)
|
||||
? '0 0 0 2px var(--color-primary)'
|
||||
: file.id === highlightId ? highlightRing : undefined,
|
||||
transition: 'border-color 0.15s, transform 0.1s, box-shadow 0.3s',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
}}
|
||||
onContextMenu={e => onFileMenu(e, file)}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.borderColor = 'var(--color-primary)'
|
||||
e.currentTarget.style.transform = 'translateY(-1px)'
|
||||
const pick = e.currentTarget.querySelector<HTMLElement>('.aether-pick')
|
||||
if (pick) pick.style.opacity = '1'
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.borderColor = 'var(--color-border)'
|
||||
e.currentTarget.style.transform = 'none'
|
||||
const pick = e.currentTarget.querySelector<HTMLElement>('.aether-pick')
|
||||
if (pick && !selected?.has(file.id) && (selected?.size ?? 0) === 0) pick.style.opacity = '0'
|
||||
}}
|
||||
>
|
||||
<div style={{ height: 100, position: 'relative', background: 'var(--color-surface-2)' }}>
|
||||
<FileCover file={file} />
|
||||
{/* Kästchen oben links. Erscheint beim Überfahren oder sobald
|
||||
irgendetwas ausgewählt ist – dauerhaft sichtbar wäre es an
|
||||
fünfzig Kacheln nur Unruhe, und die meisten Besuche wählen
|
||||
nichts aus. */}
|
||||
{onToggleSelect && (
|
||||
<button
|
||||
className="aether-pick"
|
||||
onClick={e => { e.stopPropagation(); onToggleSelect(file.id, e.shiftKey) }}
|
||||
title="Select"
|
||||
style={{
|
||||
position: 'absolute', top: 6, left: 6,
|
||||
width: 20, height: 20, borderRadius: 6, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: selected?.has(file.id) ? 'var(--color-primary)' : 'rgba(15,23,42,0.65)',
|
||||
border: `1px solid ${selected?.has(file.id) ? 'var(--color-primary)' : 'rgba(255,255,255,0.35)'}`,
|
||||
opacity: selected?.has(file.id) || (selected?.size ?? 0) > 0 ? 1 : 0,
|
||||
transition: 'opacity 0.12s',
|
||||
}}
|
||||
>
|
||||
{selected?.has(file.id) && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#fff"
|
||||
strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="4 12 10 18 20 6" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: '8px 10px 10px' }}>
|
||||
<p style={{ margin: 0, fontSize: 12, fontWeight: 500, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={file.name}>
|
||||
{file.name}
|
||||
</p>
|
||||
{/* Eine Zeile unter dem Namen, zwei Zonen: links **wer**, rechts
|
||||
**was**. Vorher standen Absender, Größe und Aufkleber
|
||||
nebeneinander in derselben Reihe – drei Angaben in drei
|
||||
Schriftgraden auf 130 Pixeln, und jede Kachel brach an einer
|
||||
anderen Stelle um. Genau das machte das Raster unruhig.
|
||||
|
||||
Die Größe weicht dabei nach hinten: Sie steht nur noch da, wo
|
||||
kein Absender steht (Bilder, Downloads), und sonst in der
|
||||
Listenansicht und unter „Properties". Beim Überfliegen eines
|
||||
Rasters sucht man den Absender, nicht die Kilobyte. */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
margin: '3px 0 0', minWidth: 0, height: 18,
|
||||
}}>
|
||||
{fileCorrespondents?.[file.id] ? (
|
||||
<span
|
||||
title={fileCorrespondents[file.id].name}
|
||||
style={{
|
||||
fontSize: 11, color: 'var(--color-text-muted)', minWidth: 0,
|
||||
display: 'flex', alignItems: 'center', gap: 4,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{fileCorrespondents[file.id].icon && (
|
||||
<span style={{ flexShrink: 0 }}>{fileCorrespondents[file.id].icon}</span>
|
||||
)}
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{fileCorrespondents[file.id].name}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', flexShrink: 0 }}>
|
||||
{formatBytes(file.size)}
|
||||
</span>
|
||||
)}
|
||||
{(fileTags?.[file.id]?.length ?? 0) > 0 && (
|
||||
<span style={{
|
||||
marginLeft: 'auto', minWidth: 0, display: 'flex',
|
||||
justifyContent: 'flex-end', flexShrink: 0,
|
||||
}}>
|
||||
{/* Nur eins, der Rest als „+n": Bei zweien schrumpfen beide
|
||||
auf „gar…" und „steue…" – zwei unlesbare Aufkleber sagen
|
||||
weniger als einer lesbarer. Gemessen im Bildschirmfoto. */}
|
||||
<TagRow tags={fileTags?.[file.id] ?? []} max={1} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── List View ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ListView({ folders, files, onFolderOpen, onFolderMenu, onFileMenu, onFileOpen, onFileDownload, onFolderDrop, dragOverFolder, onFolderDragOver, highlightId, fileTags, fileCorrespondents, selected, onToggleSelect }: ViewProps) {
|
||||
const highlightRef = useScrollToHighlight(highlightId)
|
||||
const coarse = useCoarsePointer()
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '4px 12px', marginBottom: 4 }}>
|
||||
<div style={{ width: 32 }} />
|
||||
<div style={{ flex: 1, fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Name</div>
|
||||
<div style={{ width: 80, fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', textAlign: 'right' }}>Size</div>
|
||||
<div style={{ width: 100, fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', textAlign: 'right' }}>Modified</div>
|
||||
<div style={{ width: 32 }} />
|
||||
</div>
|
||||
|
||||
{folders.map(folder => {
|
||||
const isOver = dragOverFolder === folder.id
|
||||
return (
|
||||
<div
|
||||
key={folder.id}
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.setData('aether-folder-id', folder.id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '8px 12px', borderRadius: 8, cursor: 'grab',
|
||||
background: isOver ? 'rgba(var(--color-primary-rgb), 0.1)' : 'transparent',
|
||||
outline: isOver ? '2px solid var(--color-primary)' : 'none',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
{...openGesture(coarse, () => onFolderOpen(folder))}
|
||||
onContextMenu={e => onFolderMenu(e, folder)}
|
||||
onMouseEnter={e => { if (!isOver) e.currentTarget.style.background = 'var(--color-surface)' }}
|
||||
onMouseLeave={e => { if (!isOver) e.currentTarget.style.background = 'transparent' }}
|
||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); onFolderDragOver(folder.id) }}
|
||||
onDragLeave={e => { e.stopPropagation(); onFolderDragOver(null) }}
|
||||
onDrop={e => onFolderDrop(e, folder)}
|
||||
>
|
||||
<div style={{ width: 32, display: 'flex', justifyContent: 'center' }}>
|
||||
<FolderIcon size={24} group={folder.group_id !== null} />
|
||||
</div>
|
||||
<div style={{ flex: 1, fontSize: 13, fontWeight: 500, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{folder.name}
|
||||
</div>
|
||||
<div style={{ width: 80, fontSize: 12, color: 'var(--color-text-muted)', textAlign: 'right' }}>—</div>
|
||||
<div style={{ width: 100, fontSize: 12, color: 'var(--color-text-muted)', textAlign: 'right' }}>
|
||||
{new Date(folder.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
<div style={{ width: 32 }} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{files.map(file => (
|
||||
<div
|
||||
key={file.id}
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.setData('aether-file-id', file.id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
}}
|
||||
ref={file.id === highlightId ? highlightRef : undefined}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '8px 12px', borderRadius: 8,
|
||||
cursor: 'grab', transition: 'background 0.1s, box-shadow 0.3s',
|
||||
boxShadow: selected?.has(file.id)
|
||||
? '0 0 0 2px var(--color-primary)'
|
||||
: file.id === highlightId ? highlightRing : undefined,
|
||||
}}
|
||||
onDoubleClick={() => { if (!coarse) onFileOpen(file) }}
|
||||
onClick={e => {
|
||||
// Siehe Kachelansicht – ein einziger Handler für beide Geräte.
|
||||
if (coarse) {
|
||||
if ((selected?.size ?? 0) > 0) { onToggleSelect?.(file.id, false); return }
|
||||
onFileOpen(file)
|
||||
return
|
||||
}
|
||||
if (!onToggleSelect) return
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
||||
e.preventDefault()
|
||||
onToggleSelect(file.id, e.shiftKey)
|
||||
}
|
||||
}}
|
||||
onContextMenu={e => onFileMenu(e, file)}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--color-surface)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<div
|
||||
style={{ width: 32, display: 'flex', justifyContent: 'center', flexShrink: 0 }}
|
||||
onClick={e => {
|
||||
if (!onToggleSelect) return
|
||||
e.stopPropagation()
|
||||
onToggleSelect(file.id, e.shiftKey)
|
||||
}}
|
||||
title={onToggleSelect ? 'Select' : undefined}
|
||||
>
|
||||
{/* In der Liste ersetzt das Kästchen das Symbol, sobald etwas
|
||||
ausgewählt ist – in einer Zeile ist kein Platz für beides, und
|
||||
zwei Spalten nur für den seltenen Fall wären Verschwendung. */}
|
||||
{onToggleSelect && (selected?.has(file.id) || (selected?.size ?? 0) > 0) ? (
|
||||
<span style={{
|
||||
width: 18, height: 18, borderRadius: 5,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: selected?.has(file.id) ? 'var(--color-primary)' : 'transparent',
|
||||
border: `1px solid ${selected?.has(file.id) ? 'var(--color-primary)' : 'var(--color-border)'}`,
|
||||
cursor: 'pointer',
|
||||
}}>
|
||||
{selected?.has(file.id) && (
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#fff"
|
||||
strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="4 12 10 18 20 6" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<FileTypeIcon mime={file.mime_type} name={file.name} size={22} />
|
||||
)}
|
||||
</div>
|
||||
{/* Name und Aufkleber teilen sich die Spalte: In einer Zeile ist
|
||||
kein Platz für eine zweite Zeile darunter, und eine eigene Spalte
|
||||
stünde bei den meisten Dateien leer. */}
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{
|
||||
fontSize: 13, color: 'var(--color-text)', minWidth: 0,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}} title={file.name}>
|
||||
{file.name}
|
||||
</span>
|
||||
{(fileTags?.[file.id]?.length ?? 0) > 0 && (
|
||||
<span style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
|
||||
{(fileTags?.[file.id] ?? []).slice(0, 2).map(t => (
|
||||
<TagChip key={t.id} tag={t} size="sm" />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Eigene Spalte statt einer zweiten Zeile: In einer Tabelle gibt
|
||||
es die Spalte ohnehin schon, und eine leere Zelle ändert die
|
||||
Zeilenhöhe nicht. */}
|
||||
<div
|
||||
className="aether-from-col"
|
||||
title={fileCorrespondents?.[file.id]?.name}
|
||||
style={{
|
||||
width: 130, fontSize: 12, color: 'var(--color-text-muted)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{fileCorrespondents?.[file.id]
|
||||
? `${fileCorrespondents[file.id].icon} ${fileCorrespondents[file.id].name}`.trim()
|
||||
: ''}
|
||||
</div>
|
||||
<div style={{ width: 80, fontSize: 12, color: 'var(--color-text-muted)', textAlign: 'right' }}>
|
||||
{formatBytes(file.size)}
|
||||
</div>
|
||||
<div style={{ width: 100, fontSize: 12, color: 'var(--color-text-muted)', textAlign: 'right' }}>
|
||||
{new Date(file.updated_at).toLocaleDateString()}
|
||||
</div>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onFileDownload(file) }}
|
||||
style={{ width: 32, height: 28, border: 'none', background: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
title="Download"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { foldersApi, type FolderItem } from '../api/client'
|
||||
import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
|
||||
import { FolderGlyph } from './icons'
|
||||
|
||||
interface Props {
|
||||
excludeId?: string
|
||||
onConfirm: (targetFolderId: string | null) => void
|
||||
onCancel: () => void
|
||||
title?: string
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
folder: FolderItem
|
||||
children: TreeNode[] | null
|
||||
expanded: boolean
|
||||
}
|
||||
|
||||
function buildNode(folder: FolderItem): TreeNode {
|
||||
return { folder, children: null, expanded: false }
|
||||
}
|
||||
|
||||
function deepClone(nodes: TreeNode[]): TreeNode[] {
|
||||
return nodes.map(n => ({
|
||||
...n,
|
||||
children: n.children ? deepClone(n.children) : n.children,
|
||||
}))
|
||||
}
|
||||
|
||||
function getNode(nodes: TreeNode[], path: number[]): TreeNode | null {
|
||||
let current: TreeNode | null = null
|
||||
let list = nodes
|
||||
for (const idx of path) {
|
||||
current = list[idx] ?? null
|
||||
if (!current) return null
|
||||
list = current.children ?? []
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
function findLabel(nodes: TreeNode[], id: string): string {
|
||||
for (const n of nodes) {
|
||||
if (n.folder.id === id) return n.folder.name
|
||||
if (n.children) {
|
||||
const found = findLabel(n.children, id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export default function MoveTo({ excludeId, onConfirm, onCancel, title = 'Move to…' }: Props) {
|
||||
const overlay = useOverlayDismiss(onCancel)
|
||||
const [roots, setRoots] = useState<TreeNode[]>([])
|
||||
const [selected, setSelected] = useState<string | null | undefined>(undefined)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
foldersApi.list(undefined)
|
||||
.then(folders => setRoots(folders.filter(f => f.id !== excludeId).map(buildNode)))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [excludeId])
|
||||
|
||||
async function toggleExpand(path: number[]) {
|
||||
const node = getNode(roots, path)
|
||||
if (!node) return
|
||||
|
||||
// Load children on first expand
|
||||
if (node.children === null) {
|
||||
const children = await foldersApi.list(node.folder.id)
|
||||
setRoots(prev => {
|
||||
const next = deepClone(prev)
|
||||
const n = getNode(next, path)
|
||||
if (!n) return prev
|
||||
n.children = children.filter(f => f.id !== excludeId).map(buildNode)
|
||||
n.expanded = true
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
setRoots(prev => {
|
||||
const next = deepClone(prev)
|
||||
const n = getNode(next, path)
|
||||
if (!n) return prev
|
||||
n.expanded = !n.expanded
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const selectedLabel = selected === undefined ? null
|
||||
: selected === null ? 'Home'
|
||||
: findLabel(roots, selected)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 300,
|
||||
background: 'rgba(0,0,0,0.55)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 24,
|
||||
}}
|
||||
{...overlay}
|
||||
>
|
||||
<div style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 16,
|
||||
width: '100%', maxWidth: 520,
|
||||
boxShadow: '0 12px 40px rgba(0,0,0,0.3)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
maxHeight: '82vh',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{ padding: '18px 22px 14px', borderBottom: '1px solid var(--color-border)', flexShrink: 0 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 17, fontWeight: 700, color: 'var(--color-text)' }}>{title}</h3>
|
||||
<p style={{ margin: '4px 0 0', fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
Choose destination
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tree */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 0' }}>
|
||||
{/* Home row */}
|
||||
<LocationRow
|
||||
label="Home"
|
||||
depth={0}
|
||||
selected={selected === null}
|
||||
onSelect={() => setSelected(null)}
|
||||
icon={
|
||||
// Dasselbe Haus wie in der Brotkrume und der Seitenleiste.
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 10.5L12 3l9 7.5" />
|
||||
<path d="M5.5 9.5V20a1 1 0 001 1h11a1 1 0 001-1V9.5" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
|
||||
{loading && (
|
||||
<p style={{ margin: '10px 22px', fontSize: 13, color: 'var(--color-text-muted)' }}>Loading…</p>
|
||||
)}
|
||||
|
||||
{!loading && roots.length === 0 && (
|
||||
<p style={{ margin: '10px 22px', fontSize: 13, color: 'var(--color-text-muted)' }}>No folders yet</p>
|
||||
)}
|
||||
|
||||
{!loading && roots.map((node, i) => (
|
||||
<TreeRow
|
||||
key={node.folder.id}
|
||||
node={node}
|
||||
path={[i]}
|
||||
depth={1}
|
||||
selected={selected}
|
||||
onSelect={setSelected}
|
||||
onToggle={toggleExpand}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
padding: '14px 22px',
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
display: 'flex', gap: 10, alignItems: 'center', flexShrink: 0,
|
||||
}}>
|
||||
{selectedLabel !== null ? (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" strokeWidth="2.5">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
<span style={{ fontSize: 13, color: 'var(--color-text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{selectedLabel}
|
||||
</span>
|
||||
</div>
|
||||
) : <div style={{ flex: 1 }} />}
|
||||
|
||||
<button
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
padding: '8px 18px', borderRadius: 9, border: '1px solid var(--color-border)',
|
||||
background: 'transparent', color: 'var(--color-text-muted)', fontSize: 13, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
disabled={selected === undefined}
|
||||
onClick={() => selected !== undefined && onConfirm(selected)}
|
||||
style={{
|
||||
padding: '8px 20px', borderRadius: 9, border: 'none',
|
||||
background: selected !== undefined ? 'var(--color-primary)' : 'var(--color-surface-2)',
|
||||
color: selected !== undefined ? '#fff' : 'var(--color-text-muted)',
|
||||
fontSize: 13, fontWeight: 600,
|
||||
cursor: selected !== undefined ? 'pointer' : 'default',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
>
|
||||
Move here
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Row components ────────────────────────────────────────────────────────────
|
||||
|
||||
interface LocationRowProps {
|
||||
label: string
|
||||
depth: number
|
||||
selected: boolean
|
||||
onSelect: () => void
|
||||
icon: React.ReactNode
|
||||
}
|
||||
|
||||
function LocationRow({ label, depth, selected, onSelect, icon }: LocationRowProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: `9px 22px 9px ${22 + depth * 20}px`,
|
||||
border: 'none', cursor: 'pointer', textAlign: 'left',
|
||||
background: selected ? 'rgba(var(--color-primary-rgb), 0.12)' : 'transparent',
|
||||
color: selected ? 'var(--color-primary)' : 'var(--color-text)',
|
||||
fontSize: 14, fontWeight: selected ? 600 : 400,
|
||||
borderLeft: selected ? '3px solid var(--color-primary)' : '3px solid transparent',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!selected) e.currentTarget.style.background = 'var(--color-surface-2)' }}
|
||||
onMouseLeave={e => { if (!selected) e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<span style={{ color: selected ? 'var(--color-primary)' : 'var(--color-text-muted)', flexShrink: 0, display: 'flex' }}>
|
||||
{icon}
|
||||
</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
interface TreeRowProps {
|
||||
node: TreeNode
|
||||
path: number[]
|
||||
depth: number
|
||||
selected: string | null | undefined
|
||||
onSelect: (id: string) => void
|
||||
onToggle: (path: number[]) => void
|
||||
}
|
||||
|
||||
function TreeRow({ node, path, depth, selected, onSelect, onToggle }: TreeRowProps) {
|
||||
const isSelected = selected === node.folder.id
|
||||
const hasChildren = node.children === null || node.children.length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
{/* Chevron toggle */}
|
||||
<button
|
||||
onClick={() => onToggle(path)}
|
||||
style={{
|
||||
width: 28, height: 36, flexShrink: 0,
|
||||
marginLeft: 22 + (depth - 1) * 20,
|
||||
background: 'none', border: 'none',
|
||||
cursor: hasChildren ? 'pointer' : 'default',
|
||||
color: 'var(--color-text-muted)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
opacity: hasChildren ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="11" height="11" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" strokeWidth="2.5"
|
||||
style={{ transform: node.expanded ? 'rotate(90deg)' : 'none', transition: 'transform 0.15s' }}
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Folder name */}
|
||||
<button
|
||||
onClick={() => onSelect(node.folder.id)}
|
||||
style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '9px 22px 9px 6px',
|
||||
border: 'none', cursor: 'pointer', textAlign: 'left',
|
||||
background: isSelected ? 'rgba(var(--color-primary-rgb), 0.12)' : 'transparent',
|
||||
color: isSelected ? 'var(--color-primary)' : 'var(--color-text)',
|
||||
fontSize: 14, fontWeight: isSelected ? 600 : 400,
|
||||
borderLeft: isSelected ? '3px solid var(--color-primary)' : '3px solid transparent',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--color-surface-2)' }}
|
||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<span style={{ flexShrink: 0, display: 'flex' }}>
|
||||
<FolderGlyph
|
||||
size={20}
|
||||
color={node.folder.group_id !== null ? 'rgb(var(--color-primary-rgb))' : '#f59e0b'}
|
||||
/>
|
||||
</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{node.folder.name}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{node.expanded && node.children && node.children.map((child, i) => (
|
||||
<TreeRow
|
||||
key={child.folder.id}
|
||||
node={child}
|
||||
path={[...path, i]}
|
||||
depth={depth + 1}
|
||||
selected={selected}
|
||||
onSelect={onSelect}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { officeApi, type FileItem, type OfficeSession } from '../api/client'
|
||||
|
||||
/**
|
||||
* Ein Dokument im Office-Server bearbeiten.
|
||||
*
|
||||
* Die Bedienoberfläche kommt vom Office-Server und läuft in einem Rahmen. Wir
|
||||
* schicken sie **per Formular** dorthin, nicht über die Adresszeile: Das
|
||||
* Zugriffstoken gehört in den Rumpf einer POST-Anfrage und nicht in eine URL,
|
||||
* die im Verlauf des Browsers, im Log des Proxys und im Referrer landet.
|
||||
*
|
||||
* Gespeichert wird nicht hier. Der Office-Server holt die Datei selbst beim
|
||||
* Backend ab und schreibt sie ebenso zurück (siehe internal/office). Dieser
|
||||
* Rahmen weiß davon nichts – er zeigt nur an.
|
||||
*/
|
||||
export default function OfficeEditor({ file, onClose }: {
|
||||
file: FileItem
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [session, setSession] = useState<OfficeSession | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
officeApi.session(file.id)
|
||||
.then(s => {
|
||||
if (!alive) return
|
||||
if (!s.enabled) { setError('Office editing is switched off on this server.'); return }
|
||||
setSession(s)
|
||||
})
|
||||
.catch(err => {
|
||||
if (!alive) return
|
||||
setError(err instanceof Error ? err.message : 'Could not open the editor')
|
||||
})
|
||||
return () => { alive = false }
|
||||
}, [file.id])
|
||||
|
||||
// Das Formular abschicken, sobald es steht. Ein useEffect statt eines
|
||||
// autoSubmit-Attributs: HTML kennt keines, und ein Klick des Nutzers wäre
|
||||
// ein Schritt, der nichts entscheidet.
|
||||
useEffect(() => { if (session) formRef.current?.submit() }, [session])
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
// Escape schließt nur, solange der Rahmen nicht den Tastendruck hat –
|
||||
// sonst nähme man dem Editor seine eigene Escape-Taste weg.
|
||||
if (e.key === 'Escape' && document.activeElement?.tagName !== 'IFRAME') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 120,
|
||||
background: 'var(--color-background)', display: 'flex', flexDirection: 'column',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '10px 14px', flexShrink: 0,
|
||||
background: 'var(--color-surface)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
}}>
|
||||
<span style={{
|
||||
flex: 1, minWidth: 0, fontSize: 14, color: 'var(--color-text)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{file.name}
|
||||
</span>
|
||||
{session && !session.can_write && (
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>read only</span>
|
||||
)}
|
||||
{/* Kein eigener Speichern-Knopf: Der Office-Server speichert selbst,
|
||||
und zwei Knöpfe für dieselbe Sache – von denen einer nichts weiß –
|
||||
wären schlimmer als keiner. */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
title="Close (Esc)"
|
||||
style={{
|
||||
padding: 6, borderRadius: 8, border: 'none', background: 'none',
|
||||
color: 'var(--color-text-muted)', cursor: 'pointer', display: 'flex',
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
{error && (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', padding: 24,
|
||||
color: 'var(--color-text-muted)', fontSize: 13, textAlign: 'center',
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!error && !session && (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--color-text-muted)', fontSize: 13,
|
||||
}}>
|
||||
Starting the editor…
|
||||
</div>
|
||||
)}
|
||||
{session && (
|
||||
<>
|
||||
<form
|
||||
ref={formRef}
|
||||
action={session.url}
|
||||
method="post"
|
||||
target="aether-office"
|
||||
style={{ display: 'none' }}
|
||||
>
|
||||
<input type="hidden" name="access_token" value={session.token} />
|
||||
<input type="hidden" name="access_token_ttl" value={String(session.token_ttl)} />
|
||||
</form>
|
||||
<iframe
|
||||
name="aether-office"
|
||||
title={file.name}
|
||||
style={{ width: '100%', height: '100%', border: 'none', background: '#fff' }}
|
||||
// Der Office-Server braucht Skripte, Formulare und eigene
|
||||
// Fenster (Druckvorschau). „allow-same-origin" ist nötig, damit
|
||||
// er seine eigene Sitzung halten kann – er läuft ohnehin in
|
||||
// einem fremden Ursprung, also nicht in unserem.
|
||||
sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-downloads allow-modals"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import UserMenu, { useSidebarToggle } from './UserMenu'
|
||||
|
||||
/**
|
||||
* Die Kopfleiste jeder Ansicht.
|
||||
*
|
||||
* Vorher gab es zwei Muster nebeneinander: Dateien, Papierkorb und
|
||||
* Freigabe-Links hatten eine feste Leiste, „Geteilt mit mir", Admin und Profil
|
||||
* dagegen eine große Überschrift im Scrollbereich. Beim Umschalten sprang
|
||||
* dadurch der Inhalt – und schon zwischen den drei Leisten war die Höhe
|
||||
* unterschiedlich, weil sie sich aus dem jeweils höchsten Bedienelement ergab.
|
||||
*
|
||||
* Deshalb hier eine feste Höhe statt einer gewachsenen: Die Leiste ist überall
|
||||
* gleich hoch, egal was darin steht.
|
||||
*/
|
||||
export const headerHeight = 57
|
||||
|
||||
/**
|
||||
* Ab dieser Breite passt alles in eine Zeile.
|
||||
*
|
||||
* Darunter wird umgebrochen statt gequetscht: Auf 390 px stand die Brotkrume
|
||||
* als einzelner Strich da, das Suchfeld schob sich über die Sortierauswahl, der
|
||||
* Ansichts-Umschalter war ganz verschwunden und der Nutzerbereich am rechten
|
||||
* Rand abgeschnitten. Eine Zeile, die nicht umbrechen kann, quetscht – und
|
||||
* ausgerechnet auf dem Gerät, mit dem man unterwegs einen Beleg sucht.
|
||||
*/
|
||||
export const wideEnough = '(min-width: 720px)'
|
||||
|
||||
interface Props {
|
||||
/** Strichsymbol, 18 × 18 – dasselbe wie in der Seitenleiste. */
|
||||
icon?: ReactNode
|
||||
title?: string
|
||||
/** Nebenangabe in gedämpfter Schrift, etwa „12 items". */
|
||||
meta?: ReactNode
|
||||
/** Statt Symbol und Titel: eigener Inhalt links, z. B. die Brotkrumen. */
|
||||
left?: ReactNode
|
||||
/** Bedienelemente – sitzen rechts. */
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export default function PageHeader({ icon, title, meta, left, children }: Props) {
|
||||
const openSidebar = useSidebarToggle()
|
||||
return (
|
||||
<div className="aether-header" style={{
|
||||
// Feste Höhe nur auf breiten Schirmen (siehe CSS unten). Auf dem Handy
|
||||
// wächst die Leiste auf zwei Zeilen, statt ihren Inhalt zu verstecken.
|
||||
padding: '0 14px',
|
||||
// Statusleiste der installierten App. Die Kopfleiste ist jetzt die
|
||||
// oberste Zeile der Anwendung – vorher lag darüber noch eine zweite.
|
||||
paddingTop: 'var(--safe-top)',
|
||||
background: 'var(--color-surface)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
flexShrink: 0,
|
||||
minWidth: 0,
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
{/* Die Seitenleiste wird auf dem Handy von hier geöffnet.
|
||||
Vorher stand dafür eine **zweite** Leiste darüber, die nichts weiter
|
||||
enthielt als dieses Symbol und den Namen der Cloud. Gemessen kostete
|
||||
sie 57 der 915 Pixel Höhe – und der Name stand ohnehin schon in der
|
||||
Seitenleiste, die sie öffnete. */}
|
||||
{openSidebar && (
|
||||
<button
|
||||
className="aether-header-menu"
|
||||
onClick={openSidebar}
|
||||
title="Menu"
|
||||
aria-label="Menu"
|
||||
style={{
|
||||
// Kein `display` hier: Das schaltet die Breite unten um, und ein
|
||||
// Inline-Stil schlägt jede Klasse. Genau daran war der Knopf schon
|
||||
// auf dem Desktop zu sehen, obwohl die Regel ihn ausblendet.
|
||||
padding: 6, marginLeft: -6, borderRadius: 8, flexShrink: 0,
|
||||
color: 'var(--color-text-muted)', background: 'none',
|
||||
border: 'none', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{left ?? (
|
||||
<>
|
||||
{icon && (
|
||||
// strokeLinecap/-join hier statt an jedem Symbol: Beide Eigenschaften
|
||||
// vererben sich, und ein Symbol ohne eigene Angabe übernimmt sie
|
||||
// damit. Vorher hatten Papierkorb und Freigabe-Links eckige Enden,
|
||||
// „Geteilt mit mir" runde – nebeneinander wirkt das unruhig.
|
||||
<span style={{
|
||||
display: 'flex', color: 'var(--color-text-muted)', flexShrink: 0,
|
||||
strokeLinecap: 'round', strokeLinejoin: 'round',
|
||||
}}>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
{title && (
|
||||
<span style={{ fontSize: 15, fontWeight: 600, color: 'var(--color-text)', flexShrink: 0 }}>
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{meta && (
|
||||
<span style={{
|
||||
fontSize: 12, color: 'var(--color-text-muted)', minWidth: 0,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{meta}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Rechte Gruppe: erst die Bedienelemente der Ansicht, dann – durch eine
|
||||
Trennlinie abgesetzt – der Nutzerbereich. Er steht bewusst in *jeder*
|
||||
Ansicht an derselben Stelle; ein Zugang, den man je nach Seite
|
||||
woanders sucht, ist keiner. */}
|
||||
<div className="aether-header-actions" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, minWidth: 0,
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Der Nutzerbereich steht **außerhalb** der Bedienelemente.
|
||||
Auf dem Handy gehört er in die Titelzeile nach rechts – dorthin, wo
|
||||
man den eigenen Zugang sucht. Vorher stand er in der Reihe mit
|
||||
„+ Folder" und „Upload" und sah dort aus wie ein weiterer Knopf für
|
||||
eine Handlung, die er nicht ist.
|
||||
|
||||
Umgestellt wird über `order`, nicht über einen zweiten Baum: Zwei
|
||||
Nutzermenüs nebeneinander hießen zwei Klappmenüs, von denen eines
|
||||
immer versteckt ist. */}
|
||||
<div className="aether-header-user" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0,
|
||||
}}>
|
||||
{children && (
|
||||
<span className="aether-header-divider" style={{
|
||||
width: 1, height: 22, background: 'var(--color-border)',
|
||||
margin: '0 2px', flexShrink: 0,
|
||||
}} />
|
||||
)}
|
||||
<UserMenu />
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.aether-header { min-height: ${headerHeight - 1}px; }
|
||||
|
||||
/* Breit: erst die Bedienelemente der Ansicht, dann – durch eine
|
||||
Trennlinie abgesetzt – der Nutzerbereich, ganz rechts. */
|
||||
@media ${wideEnough} {
|
||||
.aether-header-menu { display: none; }
|
||||
.aether-header-actions { order: 2; margin-left: auto; }
|
||||
.aether-header-user { order: 3; }
|
||||
}
|
||||
|
||||
@media not all and ${wideEnough} {
|
||||
.aether-header {
|
||||
padding-top: calc(8px + var(--safe-top));
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
/* Schmal: Nutzerbereich noch in der Titelzeile ganz rechts, die
|
||||
Bedienelemente darunter über die volle Breite.
|
||||
|
||||
Die Umstellung per order ist hier nicht Zierde: Im Baum steht der Nutzerbereich
|
||||
**hinter** den Bedienelementen, und die nehmen sich schmal die
|
||||
volle Breite. Ohne die Umstellung landete er dahinter – also in
|
||||
einer dritten Zeile, allein und rechts. Genau so sah es aus. */
|
||||
.aether-header-menu { display: flex; }
|
||||
.aether-header-user { order: 1; margin-left: auto; }
|
||||
.aether-header-divider { display: none; }
|
||||
/* Nichts wird versteckt – man kommt an alles heran, es steht nur
|
||||
untereinander. */
|
||||
.aether-header-actions {
|
||||
order: 2;
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
/* Das Suchfeld nimmt die Zeile für sich – 220px feste Breite waren
|
||||
der Grund, warum daneben nichts mehr passte. */
|
||||
.aether-header-actions input[type="text"],
|
||||
.aether-header-actions input:not([type]) { width: 100% !important; }
|
||||
}
|
||||
|
||||
@media ${wideEnough} {
|
||||
.aether-header { height: ${headerHeight - 1}px; padding-left: 20px; padding-right: 20px; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,957 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { filesApi } from '../api/client'
|
||||
import * as pdfjs from 'pdfjs-dist'
|
||||
import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist'
|
||||
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
|
||||
import './pdf.css'
|
||||
|
||||
/**
|
||||
* Eigener PDF-Betrachter.
|
||||
*
|
||||
* Vorher stand hier ein <iframe> mit dem eingebauten Betrachter des Browsers.
|
||||
* Der ist gut, aber er gehört uns nicht – wir können ihm nichts sagen und
|
||||
* nichts von ihm erfahren. Zwei Gründe haben den Wechsel getragen:
|
||||
*
|
||||
* 1. **Am Handy.** Chrome auf Android zeigt ein PDF in einem <iframe> nicht
|
||||
* an, sondern bietet es zum Herunterladen an. Für ein Archiv, in das man
|
||||
* beim Reklamieren im Laden hineinsieht, war die Vorschau dort also
|
||||
* schlicht nicht vorhanden.
|
||||
* 2. **Der Suchtreffer.** Die Volltextsuche findet ein Wort auf Seite 7 eines
|
||||
* Scans – der fremde Betrachter beginnt trotzdem auf Seite 1, und man
|
||||
* sucht von Hand weiter. Ihm lässt sich kein Suchwort vorgeben.
|
||||
*
|
||||
* Was er absichtlich *nicht* kann: Anmerkungen, Formulare, Unterschriften.
|
||||
* Das Original wird in dieser Anwendung nie verändert – ein Betrachter, der
|
||||
* schreiben kann, wäre ein Widerspruch dazu.
|
||||
*/
|
||||
|
||||
// Der Arbeiter kommt gebündelt aus node_modules und wird vom eigenen Server
|
||||
// ausgeliefert. Kein CDN: Die Sicherheitsrichtlinie erlaubt nur 'self', und
|
||||
// eine Cloud für private Unterlagen soll beim Öffnen eines Dokuments keine
|
||||
// fremde Adresse anfragen.
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl
|
||||
|
||||
/**
|
||||
* Die 14 Standardschriften (Helvetica, Times, Courier …). Ältere PDFs betten
|
||||
* sie nicht ein, sondern setzen sie voraus. Fehlen sie, malt PDF.js mit einer
|
||||
* Ersatzschrift – die Seite ist lesbar, aber Zeilen brechen an anderer Stelle.
|
||||
* Kopiert wird der Ordner beim Bauen, siehe vite.config.ts.
|
||||
*/
|
||||
const standardFontDataUrl = '/pdfjs/standard_fonts/'
|
||||
|
||||
/** Zoomstufen des Knopfes. „Fit" liegt außerhalb und wird gerechnet. */
|
||||
const zoomSteps = [0.5, 0.75, 1, 1.25, 1.5, 2, 3]
|
||||
|
||||
/**
|
||||
* Innenabstand der Seitenliste. Am Telefon fast keiner: Dort ist die Breite
|
||||
* das knappe Gut, und ein 16-Pixel-Rand links und rechts kostet zusammen mehr
|
||||
* als ein Zehntel der Darstellungsgröße.
|
||||
*/
|
||||
function padOf(width: number): number {
|
||||
return width < 560 ? 4 : 16
|
||||
}
|
||||
|
||||
/**
|
||||
* Ab welcher Breite die Leiste als Dreispaltenraster taugt.
|
||||
*
|
||||
* Gerechnet, nicht geschätzt: Die Steuerung links braucht 275 Pixel
|
||||
* (zwei Knöpfe, Seitenzahl, Trenner, Zoom), die Suche in der Mitte 492 – und
|
||||
* die rechte Spalte ist genauso breit wie die linke, sonst säße die Suche
|
||||
* nicht mittig. Macht 2 × 275 + 492.
|
||||
*
|
||||
* **Vorher stand hier 720, und dazwischen war die Leiste kaputt:** Das Raster
|
||||
* gab der linken Spalte weniger, als ihr Inhalt braucht, und die Knöpfe
|
||||
* schrumpften mit. Gemessen bei 980 Pixeln Fensterbreite – erster Knopf 16
|
||||
* statt 28 Pixel, bei 760 die ganze Gruppe auf 108 statt 275. Am Ende war von
|
||||
* der Steuerung nichts mehr zu sehen.
|
||||
*/
|
||||
const minGridWidth = 2 * 275 + 492
|
||||
|
||||
/**
|
||||
* Breiten rechts vom Suchfeld – und damit das Maß für das Gegengewicht links.
|
||||
* Beide Seiten gleich breit heißt: Das Feld sitzt in der Mitte der Leiste.
|
||||
*/
|
||||
const counterWidth = 54
|
||||
const hitButtonsWidth = 60
|
||||
/** Zähler + Knöpfe + die beiden Abstände dazwischen. */
|
||||
const hitsWidth = counterWidth + hitButtonsWidth + 6
|
||||
|
||||
/** Abstand zwischen zwei Seiten, in Pixeln. Auch der Wert, mit dem gerechnet
|
||||
* wird, wenn die sichtbare Seite bestimmt wird. */
|
||||
const pageGap = 12
|
||||
|
||||
interface Props {
|
||||
/** Adresse der Datei – dieselbe, die auch das <iframe> bekam. */
|
||||
url: string
|
||||
/** ID derselben Datei – für den erkannten Text vom Server. */
|
||||
fileId: string
|
||||
/**
|
||||
* Suchwort aus der Volltextsuche. Ist es gesetzt, springt der Betrachter
|
||||
* beim Öffnen zur ersten Fundstelle, statt auf Seite 1 zu beginnen.
|
||||
*/
|
||||
searchTerm?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Fundstelle: Seite und die wievielte auf dieser Seite.
|
||||
*
|
||||
* `exact` unterscheidet die beiden Quellen. Bringt das PDF eigenen Text mit,
|
||||
* steht die Stelle im Dokument und lässt sich einfärben. Bei einem **Scan**
|
||||
* steht dort nur ein Bild; der Text kommt dann aus der Erkennung auf dem
|
||||
* Server, und der weiß zwar die Seite, aber nicht den Ort auf dem Blatt –
|
||||
* dafür müssten die Koordinaten der Erkennung aufbewahrt werden.
|
||||
*/
|
||||
interface Hit { page: number; nth: number; exact: boolean }
|
||||
|
||||
export default function PdfViewer({ url, fileId, searchTerm }: Props) {
|
||||
const [doc, setDoc] = useState<PDFDocumentProxy | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [sizes, setSizes] = useState<{ w: number; h: number }[]>([])
|
||||
const [scale, setScale] = useState<number | 'fit'>('fit')
|
||||
const [fitScale, setFitScale] = useState(1)
|
||||
const [current, setCurrent] = useState(1)
|
||||
|
||||
const [query, setQuery] = useState(searchTerm ?? '')
|
||||
const [hits, setHits] = useState<Hit[]>([])
|
||||
const [hitIndex, setHitIndex] = useState(0)
|
||||
const [searching, setSearching] = useState(false)
|
||||
|
||||
/** Erkannter Text vom Server, einmal je Datei geholt. Ohne den Zwischen-
|
||||
* speicher fragte jeder Tastendruck im Suchfeld erneut an. */
|
||||
const serverText = useRef<string[] | null>(null)
|
||||
useEffect(() => { serverText.current = null }, [fileId])
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const pageRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
|
||||
const effScale = scale === 'fit' ? fitScale : scale
|
||||
|
||||
// ---------------------------------------------------------------- Laden
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
let task: ReturnType<typeof pdfjs.getDocument> | null = null
|
||||
setDoc(null); setSizes([]); setError(''); setCurrent(1)
|
||||
|
||||
task = pdfjs.getDocument({
|
||||
url,
|
||||
// Same-Origin, die Sitzung steckt im Cookie – ohne das käme 401.
|
||||
withCredentials: true,
|
||||
standardFontDataUrl,
|
||||
})
|
||||
task.promise.then(
|
||||
async d => {
|
||||
// Beim Verlassen raeumt die Aufraeumfunktion die Ladeaufgabe ab; die
|
||||
// nimmt Arbeiter und Dokument mit. Hier genuegt es, nichts mehr zu
|
||||
// setzen.
|
||||
if (!alive) return
|
||||
// Die Maße *aller* Seiten vorab: Sonst müsste die Liste mit einer
|
||||
// geschätzten Höhe beginnen und beim Zeichnen springen. Ein Dokument
|
||||
// mit gemischtem Hoch- und Querformat ist bei Scans der Normalfall.
|
||||
const s: { w: number; h: number }[] = []
|
||||
for (let i = 1; i <= d.numPages; i++) {
|
||||
const v = (await d.getPage(i)).getViewport({ scale: 1 })
|
||||
s.push({ w: v.width, h: v.height })
|
||||
}
|
||||
if (!alive) return
|
||||
setSizes(s)
|
||||
setDoc(d)
|
||||
},
|
||||
(err: unknown) => {
|
||||
if (!alive) return
|
||||
setError(
|
||||
err instanceof Error && /password/i.test(err.message)
|
||||
? 'This PDF is password protected.'
|
||||
: 'Could not open this PDF.',
|
||||
)
|
||||
},
|
||||
)
|
||||
return () => {
|
||||
alive = false
|
||||
task?.destroy()
|
||||
}
|
||||
}, [url])
|
||||
|
||||
// ------------------------------------------------------- Breite einpassen
|
||||
|
||||
/**
|
||||
* „Fit" heißt: **auf die Breite, aber nie größer als das Original.**
|
||||
*
|
||||
* Zwei Anläufe, beide falsch, beide erst am echten Dokument sichtbar:
|
||||
*
|
||||
* 1. *Nur auf die Breite.* Die Vorschau ist fast bildschirmbreit, ein
|
||||
* A4-Blatt füllte sie bei 199 % – man sah das obere Drittel.
|
||||
* 2. *Auf die ganze Seite, also gegen beide Kanten.* Klingt richtig und
|
||||
* ist es für A4 auch. Bei einem **langen** Blatt – ein Merkzettel, eine
|
||||
* Rechnung mit vierzig Posten auf einer Seite – kam dabei **10 %**
|
||||
* heraus: Schriftgröße 1,4 Pixel. Gemessen an einem echten Dokument.
|
||||
*
|
||||
* Die Höhe taugt also nicht als Grenze, weil eine Seite beliebig lang sein
|
||||
* darf. Die Breite taugt, denn sie ist bei jedem Papier ähnlich. Bleibt das
|
||||
* Aufblähen zu verhindern: Über 100 % wird nichts vergrößert – das Original
|
||||
* ist so gesetzt, dass man es lesen kann.
|
||||
*
|
||||
* Am Telefon greift die Grenze von selbst nicht: Dort ist der Bildschirm
|
||||
* schmaler als das Blatt, die Breite also unter 100 %.
|
||||
*
|
||||
* Gerechnet wird gegen die **häufigste** Seitenbreite, nicht gegen die
|
||||
* breiteste – warum, steht bei `commonWidth`.
|
||||
*/
|
||||
const recomputeFit = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el || sizes.length === 0) return
|
||||
// Eine Messung von (fast) null ist keine Messung, sondern ein Zeitpunkt:
|
||||
// Der Beobachter meldet sich auch, während die Vorschau noch aufgeht und
|
||||
// der Bereich keine Breite hat. Früher lieferte das die Untergrenze von
|
||||
// 10 % – und weil danach keine Größenänderung mehr kam, blieb das Dokument
|
||||
// dabei stehen. Gemessen an einem Dokument, das mal mit 96 %, mal mit
|
||||
// 10 % aufging, je nachdem wie schnell der Rechner gerade war.
|
||||
if (el.clientWidth < 50) return
|
||||
// 16 für die Bildlaufleiste, die je nach Browser Breite wegnimmt.
|
||||
const byWidth = (el.clientWidth - 2 * padOf(el.clientWidth) - 16) / commonWidth(sizes)
|
||||
setFitScale(Math.max(0.1, Math.min(1, byWidth)))
|
||||
}, [sizes])
|
||||
|
||||
useEffect(() => {
|
||||
recomputeFit()
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver(recomputeFit)
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [recomputeFit])
|
||||
|
||||
// ------------------------------------------------------------- Zeichnen
|
||||
|
||||
/**
|
||||
* Gezeichnet wird nur, was in die Nähe des Sichtfelds kommt.
|
||||
*
|
||||
* Ein 200-seitiger Scan hätte sonst 200 Leinwände im Speicher – bei A4 und
|
||||
* doppelter Pixeldichte sind das je rund 8 MB. Der Beobachter meldet
|
||||
* Seiten eine Bildschirmhöhe im Voraus an; wieder verlassene Seiten geben
|
||||
* ihre Leinwand frei.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!doc || sizes.length === 0) return
|
||||
const root = scrollRef.current
|
||||
if (!root) return
|
||||
|
||||
// Läuft eine Zeichnung noch, wenn sich der Maßstab ändert, muss sie
|
||||
// abgebrochen werden – sonst malt sie in der alten Größe fertig.
|
||||
const running = new Map<number, ReturnType<PDFPageProxy['render']>>()
|
||||
const drawn = new Set<number>()
|
||||
|
||||
async function draw(n: number) {
|
||||
const host = pageRefs.current[n - 1]
|
||||
if (!doc || !host || drawn.has(n)) return
|
||||
drawn.add(n)
|
||||
const page = await doc.getPage(n)
|
||||
const viewport = page.getViewport({ scale: effScale })
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = Math.floor(viewport.width * dpr)
|
||||
canvas.height = Math.floor(viewport.height * dpr)
|
||||
canvas.style.width = '100%'
|
||||
canvas.style.height = '100%'
|
||||
canvas.style.display = 'block'
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
ctx.scale(dpr, dpr)
|
||||
|
||||
const task = page.render({ canvas, canvasContext: ctx, viewport })
|
||||
running.set(n, task)
|
||||
try {
|
||||
await task.promise
|
||||
} catch {
|
||||
drawn.delete(n) // abgebrochen – beim nächsten Mal neu
|
||||
return
|
||||
} finally {
|
||||
running.delete(n)
|
||||
}
|
||||
host.querySelector('canvas')?.remove()
|
||||
host.prepend(canvas)
|
||||
|
||||
// Textebene darüber: unsichtbare, aber markierbare Schrift. Sie ist es
|
||||
// auch, in der die Suche ihre Fundstellen färbt.
|
||||
const layer = host.querySelector<HTMLDivElement>('.pdf-textLayer')
|
||||
if (layer) {
|
||||
layer.replaceChildren()
|
||||
const tl = new pdfjs.TextLayer({
|
||||
textContentSource: await page.getTextContent(),
|
||||
container: layer,
|
||||
viewport,
|
||||
})
|
||||
await tl.render()
|
||||
host.dataset.text = 'ready'
|
||||
host.dispatchEvent(new CustomEvent('pdf-text-ready', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
|
||||
function release(n: number) {
|
||||
running.get(n)?.cancel()
|
||||
running.delete(n)
|
||||
drawn.delete(n)
|
||||
const host = pageRefs.current[n - 1]
|
||||
if (!host) return
|
||||
host.querySelector('canvas')?.remove()
|
||||
host.querySelector('.pdf-textLayer')?.replaceChildren()
|
||||
host.querySelector('.pdf-hitLayer')?.replaceChildren()
|
||||
delete host.dataset.text
|
||||
}
|
||||
|
||||
const io = new IntersectionObserver(entries => {
|
||||
for (const e of entries) {
|
||||
const n = Number((e.target as HTMLElement).dataset.page)
|
||||
if (e.isIntersecting) void draw(n)
|
||||
else release(n)
|
||||
}
|
||||
}, { root, rootMargin: '100% 0px' })
|
||||
|
||||
for (const host of pageRefs.current) if (host) io.observe(host)
|
||||
return () => {
|
||||
io.disconnect()
|
||||
for (const t of running.values()) t.cancel()
|
||||
}
|
||||
}, [doc, sizes, effScale])
|
||||
|
||||
// --------------------------------------------------- Sichtbare Seitenzahl
|
||||
|
||||
useEffect(() => {
|
||||
const root = scrollRef.current
|
||||
if (!root || sizes.length === 0) return
|
||||
function onScroll() {
|
||||
const mid = root!.scrollTop + root!.clientHeight / 2
|
||||
let acc = 0
|
||||
for (let i = 0; i < sizes.length; i++) {
|
||||
const h = sizes[i].h * effScale + pageGap
|
||||
if (mid < acc + h) { setCurrent(i + 1); return }
|
||||
acc += h
|
||||
}
|
||||
setCurrent(sizes.length)
|
||||
}
|
||||
root.addEventListener('scroll', onScroll, { passive: true })
|
||||
onScroll()
|
||||
return () => root.removeEventListener('scroll', onScroll)
|
||||
}, [sizes, effScale])
|
||||
|
||||
/**
|
||||
* Zu einer Seite fahren – **mit** dem Rand darüber.
|
||||
*
|
||||
* Vorher stand hier `scrollIntoView({ block: 'start' })`. Das legt die
|
||||
* Oberkante der Seite genau auf die Oberkante des Rollbereichs und frisst
|
||||
* dabei den Innenabstand auf. Sichtbar wurde das beim Suchen: Man tippt,
|
||||
* und das Blatt rutscht um 16 Pixel nach oben, klebt am Fensterrand – ein
|
||||
* Ruck, für den es keinen Grund gibt.
|
||||
*
|
||||
* Jetzt wird von Hand gerollt und der Rand abgezogen. Die Seite steht dann
|
||||
* dort, wo sie auch beim Öffnen steht.
|
||||
*/
|
||||
const goToPage = useCallback((n: number) => {
|
||||
const root = scrollRef.current
|
||||
const host = pageRefs.current[n - 1]
|
||||
if (!root || !host) return
|
||||
const r = root.getBoundingClientRect()
|
||||
const h = host.getBoundingClientRect()
|
||||
root.scrollTo({ top: Math.max(0, root.scrollTop + (h.top - r.top) - padOf(root.clientWidth)) })
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Zu einer Fundstelle rollen – **nur wenn nötig**.
|
||||
*
|
||||
* Der zweite Teil desselben Rucks: Jeder Tastendruck im Suchfeld ließ die
|
||||
* Anzeige zur Fundstelle springen, auch wenn die längst zu sehen war. Wer
|
||||
* „la" tippt, hat nach dem ersten Buchstaben schon eine andere Trefferliste,
|
||||
* und das Blatt zuckte bei jedem Zeichen.
|
||||
*
|
||||
* Steht der Treffer im mittleren Bereich, passiert jetzt gar nichts.
|
||||
*/
|
||||
const revealHit = useCallback((el: Element) => {
|
||||
const root = scrollRef.current
|
||||
if (!root) return
|
||||
const r = root.getBoundingClientRect()
|
||||
const e = el.getBoundingClientRect()
|
||||
const margin = 60
|
||||
if (e.top >= r.top + margin && e.bottom <= r.bottom - margin) return
|
||||
root.scrollTo({ top: Math.max(0, root.scrollTop + (e.top - r.top) - root.clientHeight / 3) })
|
||||
}, [])
|
||||
|
||||
// ----------------------------------------------------------------- Suche
|
||||
|
||||
/**
|
||||
* Gesucht wird im *ganzen* Dokument auf einmal, nicht nur auf der
|
||||
* gezeichneten Seite: Nur so lässt sich „3 von 7" anzeigen und gezielt
|
||||
* weiterspringen. Der Text kommt dabei nicht aus der Datenbank, sondern
|
||||
* aus dem PDF selbst – nur dort steht, an welcher *Stelle* er steht.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!doc || q.length < 2) { setHits([]); setHitIndex(0); return }
|
||||
let alive = true
|
||||
setSearching(true)
|
||||
;(async () => {
|
||||
const found: Hit[] = []
|
||||
for (let n = 1; n <= doc.numPages; n++) {
|
||||
if (!alive) return
|
||||
const tc = await doc.getPage(n).then(p => p.getTextContent())
|
||||
// Zusammengesetzt wie in der Anzeige, damit ein über zwei Stücke
|
||||
// getrenntes Wort trotzdem zählt.
|
||||
const text = tc.items
|
||||
.map(it => ('str' in it ? it.str : ''))
|
||||
.join('')
|
||||
.toLowerCase()
|
||||
found.push(...countIn(text, q, n, true))
|
||||
}
|
||||
if (!alive) return
|
||||
|
||||
// Nichts gefunden? Dann liegt hier womöglich ein **Scan**, in dem gar
|
||||
// kein Text steht. Der erkannte Text vom Server ist die einzige Quelle,
|
||||
// die dann etwas weiß – ausgerechnet für den Dateityp, für den die
|
||||
// Texterkennung überhaupt gebaut wurde.
|
||||
//
|
||||
// Nur als Rückfall, nicht immer: Steht der Text im PDF, ist er dort
|
||||
// genauer verortet, und die Fundstelle lässt sich einfärben.
|
||||
if (found.length === 0) {
|
||||
try {
|
||||
if (!serverText.current) {
|
||||
serverText.current = (await filesApi.textPages(fileId)).pages
|
||||
}
|
||||
if (!alive) return
|
||||
serverText.current.forEach((page, i) => {
|
||||
found.push(...countIn(page.toLowerCase(), q, i + 1, false))
|
||||
})
|
||||
} catch {
|
||||
// Kein Text vorhanden oder Erkennung abgeschaltet: dann eben keine
|
||||
// Treffer. Eine Fehlermeldung wäre hier nur im Weg.
|
||||
}
|
||||
}
|
||||
|
||||
if (!alive) return
|
||||
setHits(found)
|
||||
setHitIndex(0)
|
||||
setSearching(false)
|
||||
})()
|
||||
return () => { alive = false; setSearching(false) }
|
||||
}, [doc, query, fileId])
|
||||
|
||||
/**
|
||||
* Färbt die Fundstellen einer Seite.
|
||||
*
|
||||
* **Nicht** im Text selbst, sondern als eigene Ebene aus Kästchen darüber.
|
||||
* Der erste Anlauf setzte ein <mark> mitten in die unsichtbare Schrift –
|
||||
* und genau das war der Grund für die schiefen, oben angeschnittenen
|
||||
* Streifen:
|
||||
*
|
||||
* Ein <mark> ist ein Textstück. Sein Grund ist so hoch, wie die Schrift
|
||||
* ihn vorgibt (Oberlänge + Unterlänge der **Ersatz**schrift), nicht so
|
||||
* hoch wie die Zeile. Gemessen: 23 Pixel Kasten auf 17 Pixel Zeile. Der
|
||||
* Überstand ragte nach oben aus der Textebene heraus – und die schneidet
|
||||
* ab (`overflow: clip`). Daher „abgeschnitten und nicht gerade".
|
||||
*
|
||||
* Jetzt kommt die Geometrie **vollständig** aus der Auswahl – genau die
|
||||
* Kästen, die der Browser auch beim Markieren mit der Maus malt. Das darf
|
||||
* er jetzt auch: Unsere Ebene schneidet nichts ab, das war ja allein die
|
||||
* Textebene.
|
||||
*
|
||||
* Und markiert wird das **ganze Wort**, nicht der Buchstabenschnipsel –
|
||||
* warum, steht bei `wordAround` unten.
|
||||
*
|
||||
* Nebenbei bleibt die Schrift unberührt: Markieren und Kopieren gehen
|
||||
* währenddessen weiter, was beim Umbauen des Textes nicht mehr sicher war.
|
||||
*
|
||||
* Grenze, die ich in Kauf nehme: Ein Wort, das PDF.js über zwei Stücke
|
||||
* verteilt, wird gezählt, aber nicht eingefärbt. Bei erkanntem Text (OCR)
|
||||
* ist ein Wort praktisch immer ein Stück.
|
||||
*/
|
||||
const paint = useCallback((n: number, activeNth: number) => {
|
||||
const host = pageRefs.current[n - 1]
|
||||
const layer = host?.querySelector<HTMLDivElement>('.pdf-hitLayer')
|
||||
if (!host || !layer) return
|
||||
layer.replaceChildren()
|
||||
const q = query.trim().toLowerCase()
|
||||
if (q.length < 2) return
|
||||
|
||||
const base = host.getBoundingClientRect()
|
||||
let nth = 0
|
||||
/** Zuletzt gezeichnetes Wort – gegen doppelte Kästen (siehe unten). */
|
||||
let last: { at: number; to: number; boxes: HTMLElement[] } | null = null
|
||||
|
||||
for (const span of host.querySelectorAll<HTMLElement>('.pdf-textLayer span')) {
|
||||
const text = span.textContent ?? ''
|
||||
const low = text.toLowerCase()
|
||||
if (!low.includes(q)) continue
|
||||
const node = span.firstChild
|
||||
if (!node || node.nodeType !== Node.TEXT_NODE) continue
|
||||
|
||||
last = null
|
||||
let from = 0
|
||||
for (;;) {
|
||||
const at = low.indexOf(q, from)
|
||||
if (at < 0) break
|
||||
const [ws, we] = wordAround(text, at, at + q.length)
|
||||
const cur = nth === activeNth
|
||||
from = at + q.length
|
||||
nth++
|
||||
|
||||
// „la" findet in einem Wort wie „Salatlöffel" zweimal dieselbe Stelle
|
||||
// zum Malen. Statt zwei deckungsgleicher Kästen: den vorhandenen
|
||||
// behalten und höchstens zum aktuellen Treffer aufwerten.
|
||||
if (last && last.at === ws && last.to === we) {
|
||||
if (cur) last.boxes.forEach(b => b.classList.add('pdf-hit-current'))
|
||||
continue
|
||||
}
|
||||
|
||||
const range = document.createRange()
|
||||
range.setStart(node, ws)
|
||||
range.setEnd(node, we)
|
||||
const boxes: HTMLElement[] = []
|
||||
for (const r of range.getClientRects()) {
|
||||
const box = document.createElement('div')
|
||||
box.className = 'pdf-hit' + (cur ? ' pdf-hit-current' : '')
|
||||
box.style.left = `${r.left - base.left}px`
|
||||
box.style.top = `${r.top - base.top}px`
|
||||
box.style.width = `${r.width}px`
|
||||
box.style.height = `${r.height}px`
|
||||
layer.append(box)
|
||||
boxes.push(box)
|
||||
}
|
||||
last = { at: ws, to: we, boxes }
|
||||
}
|
||||
}
|
||||
}, [query])
|
||||
|
||||
/**
|
||||
* Leerräumen, sobald das Suchfeld leer ist.
|
||||
*
|
||||
* Ohne das blieb die letzte Färbung stehen: `paint` stieg bei kurzem
|
||||
* Suchwort vorne aus und kam nie zum Aufräumen, und ohne Treffer läuft es
|
||||
* gar nicht erst. Auf dem Blatt lagen dann Streifen zu einem Wort, das im
|
||||
* Feld längst nicht mehr stand.
|
||||
*
|
||||
* Steht vor den beiden Effekten, die färben – innerhalb einer Änderung
|
||||
* laufen Effekte in der Reihenfolge ihrer Notierung ab, also erst räumen,
|
||||
* dann neu färben.
|
||||
*/
|
||||
useEffect(() => {
|
||||
for (const host of pageRefs.current) {
|
||||
host?.querySelector('.pdf-hitLayer')?.replaceChildren()
|
||||
}
|
||||
}, [query, hits])
|
||||
|
||||
// Zur aktuellen Fundstelle springen und sie färben. Ist die Seite noch
|
||||
// nicht gezeichnet, wartet das auf ihre Textebene – deshalb das Ereignis.
|
||||
const target = hits[hitIndex]
|
||||
useEffect(() => {
|
||||
if (!target) return
|
||||
const host = pageRefs.current[target.page - 1]
|
||||
if (!host) return
|
||||
// Nur zur Seite fahren, wenn sie **noch nicht gezeichnet** ist. Ist sie da,
|
||||
// übernimmt revealHit weiter unten – und das rollt nur, wenn der Treffer
|
||||
// wirklich außerhalb liegt. Ein `goToPage` an dieser Stelle sprang bei
|
||||
// jedem Tastendruck an den Seitenanfang, obwohl der Treffer schon zu
|
||||
// sehen war.
|
||||
if (host.dataset.text !== 'ready' || !target.exact) goToPage(target.page)
|
||||
// Gescrollt wird **hier**, nicht in paint(): Die Färbung läuft auch dann,
|
||||
// wenn eine Seite bloß neu gezeichnet wurde – etwa nach dem Zoomen. Stand
|
||||
// das Scrollen darin, sprang die Anzeige bei jedem Zoomschritt zurück zur
|
||||
// Fundstelle, obwohl man längst woanders las. (Gemessen: von Seite 4
|
||||
// zurück auf Seite 3.)
|
||||
const show = () => {
|
||||
// Ein Treffer aus dem erkannten Text kennt nur die Seite. Dann bleibt
|
||||
// es beim Sprung an den Seitenanfang – ein Wort einzufärben, das wir
|
||||
// nicht verorten können, hieße raten.
|
||||
if (!target.exact) return
|
||||
paint(target.page, target.nth)
|
||||
const el = host.querySelector('.pdf-hit-current')
|
||||
if (el) revealHit(el)
|
||||
}
|
||||
if (host.dataset.text === 'ready') { show(); return }
|
||||
host.addEventListener('pdf-text-ready', show, { once: true })
|
||||
return () => host.removeEventListener('pdf-text-ready', show)
|
||||
}, [target, goToPage, revealHit, paint])
|
||||
|
||||
// Frisch gezeichnete Seiten bekommen ihre Färbung nach – sonst wären beim
|
||||
// Blättern nur die Treffer der Seite markiert, auf der man gelandet ist.
|
||||
useEffect(() => {
|
||||
const root = scrollRef.current
|
||||
if (!root || hits.length === 0) return
|
||||
function onReady(e: Event) {
|
||||
const host = (e.target as HTMLElement)
|
||||
const n = Number(host.dataset.page)
|
||||
paint(n, target && target.page === n ? target.nth : -1)
|
||||
}
|
||||
root.addEventListener('pdf-text-ready', onReady)
|
||||
return () => root.removeEventListener('pdf-text-ready', onReady)
|
||||
}, [hits.length, target, paint])
|
||||
|
||||
// Kommt der Betrachter aus einem Suchtreffer, steht das Wort schon da.
|
||||
useEffect(() => { setQuery(searchTerm ?? '') }, [searchTerm, url])
|
||||
|
||||
// ------------------------------------------------------------------ Zoom
|
||||
|
||||
/**
|
||||
* Beim Zoomen ändern sich alle Seitenhöhen – die Bildlaufposition zeigt
|
||||
* danach irgendwohin. Gemerkt wird deshalb die Seite, auf der man gerade
|
||||
* war, und nach dem Umbau wieder angesteuert. Ohne das landet man nach
|
||||
* einem Zoomschritt am Anfang eines fremden Blattes.
|
||||
*/
|
||||
const keepPage = useRef<number | null>(null)
|
||||
useEffect(() => {
|
||||
const n = keepPage.current
|
||||
if (n === null) return
|
||||
keepPage.current = null
|
||||
goToPage(n)
|
||||
}, [effScale, goToPage])
|
||||
|
||||
function zoom(dir: 1 | -1) {
|
||||
const now = effScale
|
||||
const next = dir > 0
|
||||
? zoomSteps.find(s => s > now + 0.001)
|
||||
: [...zoomSteps].reverse().find(s => s < now - 0.001)
|
||||
if (next) { keepPage.current = current; setScale(next) }
|
||||
}
|
||||
|
||||
const pages = useMemo(() => sizes.map((_, i) => i + 1), [sizes])
|
||||
/**
|
||||
* Reicht die Breite für drei Spalten? Unterhalb bricht die Leiste um, und
|
||||
* ein Raster ergäbe eine gequetschte Suche neben gequetschten Knöpfen.
|
||||
*/
|
||||
const barRef = useRef<HTMLDivElement>(null)
|
||||
const [barNarrow, setBarNarrow] = useState(false)
|
||||
useEffect(() => {
|
||||
const el = barRef.current
|
||||
if (!el) return
|
||||
// Dieselbe Vorsicht wie bei der Einpassung: Breite null heißt „noch nicht
|
||||
// sichtbar", nicht „schmal". Ohne die Prüfung stand die Leiste beim
|
||||
// Aufgehen der Vorschau im Umbruch-Zustand und blieb dort.
|
||||
const set = () => { if (el.clientWidth >= 50) setBarNarrow(el.clientWidth < minGridWidth) }
|
||||
set()
|
||||
const ro = new ResizeObserver(set)
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
const [pad, setPad] = useState(16)
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
const set = () => { if (el.clientWidth >= 50) setPad(padOf(el.clientWidth)) }
|
||||
set()
|
||||
const ro = new ResizeObserver(set)
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [doc])
|
||||
|
||||
// ----------------------------------------------------------------- Anzeige
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ color: 'var(--color-text-muted)', fontSize: 14, textAlign: 'center' }}>
|
||||
{error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%', height: '100%', display: 'flex', flexDirection: 'column',
|
||||
borderRadius: 8, overflow: 'hidden', background: 'var(--color-surface)',
|
||||
}}>
|
||||
{/*
|
||||
Werkzeugleiste.
|
||||
|
||||
Am Schreibtisch ein **Dreispaltenraster**: Steuerung links, Suche in
|
||||
der Mitte, rechts eine leere Spalte gleicher Breite. Erst dadurch sitzt
|
||||
die Suche wirklich mittig – mit zwei Abstandhaltern in einer Reihe
|
||||
teilen sich diese nur den *Rest* neben der Steuerung, und das Feld
|
||||
landete gemessen 79 bzw. 142 Pixel daneben.
|
||||
|
||||
Am Telefon reicht die Breite für drei Spalten nicht; dort bleibt es
|
||||
beim Umbruch, und die Suche steht in der zweiten Zeile.
|
||||
*/}
|
||||
<div
|
||||
ref={barRef}
|
||||
style={{
|
||||
display: barNarrow ? 'flex' : 'grid',
|
||||
gridTemplateColumns: barNarrow ? undefined : 'minmax(0, 1fr) auto minmax(0, 1fr)',
|
||||
alignItems: 'center', gap: 8, flexWrap: 'wrap',
|
||||
padding: '6px 10px', flexShrink: 0,
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
background: 'var(--color-surface)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||
<ToolButton label="Previous page" disabled={current <= 1}
|
||||
onClick={() => goToPage(current - 1)} glyph="up" />
|
||||
<span style={{
|
||||
fontSize: 12, color: 'var(--color-text-muted)',
|
||||
minWidth: 62, flexShrink: 0,
|
||||
textAlign: 'center', fontVariantNumeric: 'tabular-nums',
|
||||
}}>
|
||||
{doc ? `${current} / ${doc.numPages}` : '…'}
|
||||
</span>
|
||||
<ToolButton label="Next page" disabled={!doc || current >= doc.numPages}
|
||||
onClick={() => goToPage(current + 1)} glyph="down" />
|
||||
|
||||
<span style={{ width: 1, height: 20, flexShrink: 0, background: 'var(--color-border)' }} />
|
||||
|
||||
<ToolButton label="Zoom out" onClick={() => zoom(-1)} glyph="minus" />
|
||||
{/* Zurück zur eingepassten Größe. Bewusst kein Umschalter zwischen
|
||||
„Breite" und „100 %": Seit die Einpassung bei 100 % gedeckelt ist,
|
||||
sind das am Schreibtisch oft dieselbe Zahl – ein Knopf, der nichts
|
||||
tut, ist schlimmer als keiner. */}
|
||||
<button
|
||||
onClick={() => { keepPage.current = current; setScale('fit') }}
|
||||
disabled={scale === 'fit'}
|
||||
title="Fit width"
|
||||
style={{
|
||||
fontSize: 12, color: 'var(--color-text-muted)', minWidth: 52, flexShrink: 0,
|
||||
border: 'none', background: 'none',
|
||||
cursor: scale === 'fit' ? 'default' : 'pointer',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
{Math.round(effScale * 100)}%
|
||||
</button>
|
||||
<ToolButton label="Zoom in" onClick={() => zoom(1)} glyph="plus" />
|
||||
</div>
|
||||
|
||||
{/* Suche im Dokument – die mittlere Spalte. Sie ist der Grund, aus dem
|
||||
es diesen Betrachter gibt; rechts an den Rand gedrängt sah sie aus
|
||||
wie ein Zusatz, der nicht recht dazugehört. */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{/* Gegengewicht. Rechts vom Feld stehen Zähler und Sprungknöpfe;
|
||||
ohne diesen Ausgleich säße die Gruppe zwar mittig, das **Feld**
|
||||
aber 79 Pixel daneben – gemessen. Mittig soll das sein, was man
|
||||
ansieht, nicht der Kasten drumherum.
|
||||
|
||||
Am Telefon fällt es weg: Dort gibt es nichts zu zentrieren, und
|
||||
120 Pixel Leere hätten ein Drittel der Zeile gekostet – während
|
||||
rechts der zweite Sprungknopf aus dem Bild fiel. */}
|
||||
{!barNarrow && <span style={{ width: hitsWidth, flexShrink: 0 }} />}
|
||||
<input
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (hits.length) setHitIndex(i => (i + (e.shiftKey ? -1 : 1) + hits.length) % hits.length)
|
||||
}
|
||||
// Escape gehört hier dem Feld, nicht dem Schließen der Vorschau.
|
||||
if (e.key === 'Escape' && query) { e.stopPropagation(); setQuery('') }
|
||||
}}
|
||||
placeholder="Find in document"
|
||||
style={{
|
||||
// Breiter als die 150 vom Anfang: In der Mitte einer 1440 Pixel
|
||||
// breiten Leiste sah ein Feld in Knopfbreite aus wie ein
|
||||
// Restposten. Ein Suchbegriff wie „Zahlungsbedingungen" passt
|
||||
// jetzt auch hinein, ohne zu wandern.
|
||||
width: 240, minWidth: 0,
|
||||
padding: '5px 8px', borderRadius: 8, fontSize: 12,
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-background)', color: 'var(--color-text)',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<span style={{
|
||||
fontSize: 11, color: 'var(--color-text-muted)',
|
||||
// Feste Breite, nicht Mindestbreite: Sonst wüchse die rechte Seite
|
||||
// mit „Searching…" und das Feld rutschte beim Tippen hin und her.
|
||||
width: counterWidth, flexShrink: 0,
|
||||
overflow: 'hidden', whiteSpace: 'nowrap',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}>
|
||||
{searching ? 'Searching…'
|
||||
: query.trim().length < 2 ? ''
|
||||
: hits.length === 0 ? 'No hits'
|
||||
: `${hitIndex + 1} / ${hits.length}${hits[0] && !hits[0].exact ? ' · page' : ''}`}
|
||||
</span>
|
||||
{/* Erst zeigen, wenn es etwas zu springen gibt. Ohne Suche stand
|
||||
hier ein zweites Paar Winkel neben dem zum Blättern – zwei
|
||||
gleich aussehende Knopfpaare für zwei ganz verschiedene Dinge. */}
|
||||
{/* Der Platz für die beiden Knöpfe steht immer bereit, auch wenn
|
||||
sie fehlen. Sonst rückte das Suchfeld beim ersten Treffer um
|
||||
knapp dreißig Pixel zur Seite – ausgerechnet während man darin
|
||||
tippt. Leerer Raum fällt niemandem auf, ein wanderndes Feld
|
||||
schon. */}
|
||||
<span style={{ width: hitButtonsWidth, flexShrink: 0, display: 'flex', gap: 4 }}>
|
||||
{hits.length > 0 && (
|
||||
<>
|
||||
<ToolButton label="Previous hit" glyph="hitUp"
|
||||
onClick={() => setHitIndex(i => (i - 1 + hits.length) % hits.length)} />
|
||||
<ToolButton label="Next hit" glyph="hitDown"
|
||||
onClick={() => setHitIndex(i => (i + 1) % hits.length)} />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Die leere dritte Spalte. Sie trägt nichts und ist trotzdem nötig:
|
||||
Ohne sie hätte das Raster nur zwei Spalten, und „Mitte" wäre
|
||||
wieder die Mitte des Restes. */}
|
||||
{!barNarrow && <div />}
|
||||
</div>
|
||||
|
||||
{/* Seiten */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
style={{
|
||||
flex: 1, minHeight: 0, overflow: 'auto',
|
||||
padding: pad, background: 'var(--color-background)',
|
||||
// Auf dem Handy zieht man das Dokument mit dem Finger; die
|
||||
// Vorschau darunter darf dabei nicht mitwandern.
|
||||
overscrollBehavior: 'contain',
|
||||
}}
|
||||
>
|
||||
{!doc && (
|
||||
<div style={{ color: 'var(--color-text-muted)', fontSize: 13, textAlign: 'center' }}>
|
||||
Loading…
|
||||
</div>
|
||||
)}
|
||||
{pages.map(n => (
|
||||
<div
|
||||
key={n}
|
||||
data-page={n}
|
||||
ref={el => { pageRefs.current[n - 1] = el }}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: sizes[n - 1].w * effScale,
|
||||
height: sizes[n - 1].h * effScale,
|
||||
margin: `0 auto ${pageGap}px`,
|
||||
background: '#fff',
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.4)',
|
||||
// Die Textebene rechnet Schriftgrößen gegen diesen Wert.
|
||||
['--total-scale-factor' as string]: String(effScale),
|
||||
}}
|
||||
>
|
||||
<div className="pdf-textLayer" />
|
||||
{/* Fundstellen der Suche. Eigene Ebene über der Schrift, damit
|
||||
sie nichts am Text ändert und nichts abgeschnitten wird. */}
|
||||
<div className="pdf-hitLayer" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Die häufigste Seitenbreite eines Dokuments.
|
||||
*
|
||||
* Vorher stand hier die **breiteste** Seite, mit dem Gedanken: Unterschiedlich
|
||||
* breite Seiten unterschiedlich zu skalieren sieht nach Fehler aus. Das
|
||||
* stimmt – nur ist der Preis höher als der Gewinn.
|
||||
*
|
||||
* Gemessen an einem zehnseitigen Dokument, in dem **eine** Seite quer liegt:
|
||||
* Die neun hochkanten Seiten standen dadurch auf 46 % statt 65 %. Auf einem
|
||||
* 412 Pixel breiten Telefon ist das der Unterschied zwischen lesbar und nicht.
|
||||
* Eine einzelne Querformat-Seite – ein Anhang, eine breite Tabelle – bestimmt
|
||||
* so die Darstellung des ganzen Dokuments.
|
||||
*
|
||||
* Jetzt entscheidet die Mehrheit. Die abweichende Seite ragt dann über den
|
||||
* Rand und lässt sich seitlich schieben; das ist genau das, was man bei ihr
|
||||
* ohnehin tun will.
|
||||
*
|
||||
* Bei Gleichstand gewinnt die breitere – dann ragt lieber nichts heraus.
|
||||
*/
|
||||
function commonWidth(sizes: { w: number; h: number }[]): number {
|
||||
const count = new Map<number, number>()
|
||||
for (const s of sizes) {
|
||||
const w = Math.round(s.w)
|
||||
count.set(w, (count.get(w) ?? 0) + 1)
|
||||
}
|
||||
let best = 0, bestN = 0
|
||||
for (const [w, n] of count) {
|
||||
if (n > bestN || (n === bestN && w > best)) { best = w; bestN = n }
|
||||
}
|
||||
return best || 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Weitet eine Fundstelle auf das **ganze Wort** aus.
|
||||
*
|
||||
* Der Grund ist gemessen, nicht Geschmack. Über dem gemalten Bild liegt eine
|
||||
* unsichtbare *Ersatz*schrift – hier `sans-serif 13,5px` für eine
|
||||
* Serifenschrift im PDF. PDF.js dehnt sie über `--scale-x` (gemessen 0,937),
|
||||
* damit die **Zeile insgesamt** stimmt. Die Buchstaben *darin* sitzen deshalb
|
||||
* um ein bis drei Pixel anders als die gemalten.
|
||||
*
|
||||
* Bei einem ganzen Wort fällt das nicht auf. Bei „la" mitten in „Display"
|
||||
* schon: Der Kasten ist zehn Pixel breit, und drei Pixel Versatz sehen aus,
|
||||
* als läge er auf den falschen Buchstaben. Genau das war zu sehen.
|
||||
*
|
||||
* Gegenprobe im Browser: Unser Kasten und die Auswahl, die der Browser beim
|
||||
* Markieren mit der Maus malt, liegen auf demselben Pixel (357,08 / 10,38).
|
||||
* Der Versatz ist also nicht unser Fehler – er lässt sich nur nicht
|
||||
* wegrechnen, ohne die Schrift des Dokuments zu besitzen. Ihn zu *umgehen*
|
||||
* geht: ganze Wörter markieren, deren Ränder an Wortgrenzen liegen.
|
||||
*
|
||||
* Sehr lange „Wörter" (Adressen, Prüfsummen) bleiben ungedehnt – sonst wäre
|
||||
* eine halbe Zeile eingefärbt, weil irgendwo darin zwei Buchstaben passten.
|
||||
*/
|
||||
const maxWordSnap = 40
|
||||
|
||||
function wordAround(text: string, at: number, to: number): [number, number] {
|
||||
const isWord = (c: string) => /[\p{L}\p{N}]/u.test(c)
|
||||
let s = at, e = to
|
||||
while (s > 0 && isWord(text[s - 1])) s--
|
||||
while (e < text.length && isWord(text[e])) e++
|
||||
return e - s > maxWordSnap ? [at, to] : [s, e]
|
||||
}
|
||||
|
||||
/** Zählt die Fundstellen eines Suchworts in einem Stück Text. */
|
||||
function countIn(haystack: string, needle: string, page: number, exact: boolean): Hit[] {
|
||||
const out: Hit[] = []
|
||||
let from = 0, nth = 0
|
||||
for (;;) {
|
||||
const at = haystack.indexOf(needle, from)
|
||||
if (at < 0) break
|
||||
out.push({ page, nth: nth++, exact })
|
||||
from = at + needle.length
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Knöpfe der Werkzeugleiste steuern **zwei verschiedene** Dinge: links
|
||||
* die Seite, rechts die Fundstelle. Beide bewegen sich nach oben und unten –
|
||||
* und mit demselben Winkel-Zeichen sahen beide Paare gleich aus. Wer die
|
||||
* Leiste zum ersten Mal sieht, kann nicht wissen, welches welches ist.
|
||||
*
|
||||
* Deshalb zwei Formen: der schlanke Winkel blättert Seiten, der Pfeil mit
|
||||
* Schaft springt zur nächsten Fundstelle. Der Pfeil erscheint ohnehin erst,
|
||||
* wenn es Fundstellen gibt.
|
||||
*/
|
||||
const glyphs: Record<string, string> = {
|
||||
up: '18 15 12 9 6 15',
|
||||
down: '6 9 12 15 18 9',
|
||||
}
|
||||
|
||||
function ToolButton({ label, onClick, disabled, glyph }: {
|
||||
label: string; onClick: () => void; disabled?: boolean
|
||||
glyph: 'up' | 'down' | 'plus' | 'minus' | 'hitUp' | 'hitDown'
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
style={{
|
||||
width: 28, height: 28, flexShrink: 0, borderRadius: 8, border: 'none',
|
||||
background: 'none', color: 'var(--color-text-muted)',
|
||||
cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.35 : 1,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
{glyph === 'plus' && <><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></>}
|
||||
{glyph === 'minus' && <line x1="5" y1="12" x2="19" y2="12" />}
|
||||
{(glyph === 'up' || glyph === 'down') && <polyline points={glyphs[glyph]} />}
|
||||
{glyph === 'hitUp' && <><line x1="12" y1="19" x2="12" y2="6" /><polyline points="6 12 12 6 18 12" /></>}
|
||||
{glyph === 'hitDown' && <><line x1="12" y1="5" x2="12" y2="18" /><polyline points="6 12 12 18 18 12" /></>}
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { searchApi, filesApi, type SearchHit } from '../api/client'
|
||||
import { FileTypeIcon, FolderGlyph } from './icons'
|
||||
|
||||
/**
|
||||
* Zerlegt die Fundstelle an den Marken aus ts_headline.
|
||||
*
|
||||
* Bewusst kein dangerouslySetInnerHTML: Der Text kommt aus einem hochgeladenen
|
||||
* Dokument, das jeder freigegebene Nutzer beisteuern kann. Als HTML eingesetzt
|
||||
* wäre ein <script> darin eine Lücke; als Textknoten ist es einfach Text.
|
||||
* Deshalb markiert die Datenbank mit [[hl]] statt mit <b>.
|
||||
*/
|
||||
function highlight(snippet: string) {
|
||||
return snippet.split(/\[\[\/?hl\]\]/).map((part, i) =>
|
||||
// Jedes zweite Stück lag zwischen den Marken – das ist der Treffer.
|
||||
i % 2 === 1
|
||||
? <mark key={i} style={{
|
||||
background: 'transparent', color: 'var(--color-primary)', fontWeight: 600,
|
||||
}}>{part}</mark>
|
||||
: <span key={i}>{part}</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Treffer geöffnet: Ordner betreten bzw. zum Ordner der Datei springen. */
|
||||
onOpenFolder: (folderId: string | null, folderName: string) => void
|
||||
/** Zweiter Wert ist das Suchwort – der PDF-Betrachter springt damit zur
|
||||
* Fundstelle im Dokument. */
|
||||
onOpenFile: (hit: SearchHit, term: string) => void
|
||||
}
|
||||
|
||||
/** Erst ab zwei Zeichen suchen – ein einzelnes trifft fast alles. Der Server
|
||||
* hält dieselbe Grenze ein, das hier spart nur die Anfrage. */
|
||||
const minChars = 2
|
||||
|
||||
/** Wartezeit nach dem letzten Tastendruck. Kurz genug, dass es beim Tippen
|
||||
* mitläuft; lang genug, dass nicht jeder Buchstabe eine Anfrage auslöst. */
|
||||
const debounceMs = 250
|
||||
|
||||
export default function SearchBox({ onOpenFolder, onOpenFile }: Props) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [hits, setHits] = useState<SearchHit[]>([])
|
||||
const [truncated, setTruncated] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [active, setActive] = useState(0)
|
||||
const boxRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Jede Anfrage bricht die vorige ab. Ohne das kann eine langsame frühere
|
||||
// Antwort eine neuere überschreiben – man tippt weiter und sieht plötzlich
|
||||
// wieder die Treffer von vor zwei Buchstaben.
|
||||
useEffect(() => {
|
||||
const q = query.trim()
|
||||
if (q.length < minChars) {
|
||||
setHits([]); setTruncated(false); setBusy(false)
|
||||
return
|
||||
}
|
||||
const ctrl = new AbortController()
|
||||
setBusy(true)
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await searchApi.query(q, ctrl.signal)
|
||||
setHits(res.hits ?? [])
|
||||
setTruncated(res.truncated ?? false)
|
||||
setActive(0)
|
||||
setOpen(true)
|
||||
} catch {
|
||||
// Abgebrochen oder fehlgeschlagen – ein leeres Feld ist hier die
|
||||
// ehrlichste Anzeige, eine Fehlermeldung beim Tippen nur im Weg.
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, debounceMs)
|
||||
|
||||
return () => { clearTimeout(timer); ctrl.abort() }
|
||||
}, [query])
|
||||
|
||||
// Klick daneben schließt die Liste.
|
||||
useEffect(() => {
|
||||
function onDown(e: MouseEvent) {
|
||||
if (!boxRef.current?.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
return () => document.removeEventListener('mousedown', onDown)
|
||||
}, [])
|
||||
|
||||
function choose(hit: SearchHit) {
|
||||
const term = query.trim()
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
if (hit.kind === 'folder') onOpenFolder(hit.id, hit.name)
|
||||
else onOpenFile(hit, term)
|
||||
}
|
||||
|
||||
function onKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === 'Escape') { setOpen(false); inputRef.current?.blur(); return }
|
||||
if (!open || hits.length === 0) return
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setActive(i => (i + 1) % hits.length) }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setActive(i => (i - 1 + hits.length) % hits.length) }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); choose(hits[active]) }
|
||||
}
|
||||
|
||||
const showPanel = open && query.trim().length >= minChars
|
||||
|
||||
return (
|
||||
<div ref={boxRef} style={{ position: 'relative', flex: '1 1 180px', minWidth: 0, maxWidth: 220 }}>
|
||||
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
||||
style={{ position: 'absolute', left: 9, color: 'var(--color-text-muted)', pointerEvents: 'none' }}>
|
||||
<circle cx="11" cy="11" r="7" /><line x1="16.5" y1="16.5" x2="21" y2="21" />
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onFocus={() => { if (hits.length) setOpen(true) }}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Search files and folders"
|
||||
style={{
|
||||
// Auf breiten Schirmen 220, auf schmalen so breit wie der Platz:
|
||||
// Die feste Breite war der Grund, warum daneben nichts mehr passte.
|
||||
width: 220, maxWidth: '100%', minWidth: 0,
|
||||
padding: '6px 10px 6px 28px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-surface-2)',
|
||||
color: 'var(--color-text)',
|
||||
fontSize: 13, outline: 'none',
|
||||
}}
|
||||
/>
|
||||
{busy && (
|
||||
<div style={{
|
||||
position: 'absolute', right: 8, width: 12, height: 12,
|
||||
border: '2px solid var(--color-primary)', borderTopColor: 'transparent',
|
||||
borderRadius: '50%', animation: 'spin 0.7s linear infinite',
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showPanel && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 'calc(100% + 6px)', left: 0,
|
||||
// Breiter als die 380 von vorher: Eine Fundstelle braucht Platz,
|
||||
// sonst bringen auch zwei Zeilen kaum mehr Wörter unter.
|
||||
// Auf dem Handy passen keine 460px – dort so breit wie der Schirm
|
||||
// hergibt, damit die Fundstelle nicht auf drei Wörter schrumpft.
|
||||
width: 460, maxWidth: 'min(85vw, calc(100vw - 32px))', maxHeight: 420, overflowY: 'auto',
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 10, boxShadow: '0 12px 32px rgba(0,0,0,0.45)',
|
||||
zIndex: 60, padding: 4,
|
||||
}}>
|
||||
{hits.length === 0 && !busy && (
|
||||
<div style={{ padding: '14px 12px', fontSize: 13, color: 'var(--color-text-muted)' }}>
|
||||
Nothing found for “{query.trim()}”
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hits.map((hit, i) => (
|
||||
<button
|
||||
key={hit.kind + hit.id}
|
||||
onClick={() => choose(hit)}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
style={{
|
||||
// Oben ausrichten statt mittig: Ein Treffer mit Fundstelle ist
|
||||
// drei Zeilen hoch, einer ohne zwei – mittig zentriert wandert
|
||||
// das Symbol dann von Zeile zu Zeile.
|
||||
display: 'flex', alignItems: 'flex-start', gap: 10, width: '100%',
|
||||
padding: '8px 9px', borderRadius: 7, border: 'none', textAlign: 'left',
|
||||
background: i === active ? 'var(--color-surface-2)' : 'transparent',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{/* Fester Platz fürs Symbol, egal wie breit es ist. Der Ordner
|
||||
ist 24 Pixel breit, die Dateisymbole 18 – ohne diese Spalte
|
||||
begännen Ordnernamen weiter rechts als Dateinamen, und in
|
||||
einer gemischten Trefferliste steht der Text dann in zwei
|
||||
Spalten. Gemessen: 107 gegen 95. */}
|
||||
<span style={{
|
||||
width: 24, flexShrink: 0, display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
// Auf Höhe der ersten Zeile, nicht der Mitte des Blocks.
|
||||
height: 20, marginTop: 1,
|
||||
}}>
|
||||
{hit.kind === 'folder' ? (
|
||||
<FolderGlyph size={24} />
|
||||
) : hit.has_thumbnail ? (
|
||||
<img src={filesApi.thumbnail(hit.id)} alt="" style={{
|
||||
width: 18, height: 18, borderRadius: 3, objectFit: 'cover',
|
||||
}} />
|
||||
) : (
|
||||
<FileTypeIcon mime={hit.mime_type ?? ''} name={hit.name} size={18} />
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span style={{ minWidth: 0, flex: 1 }}>
|
||||
<span style={{
|
||||
display: 'block', fontSize: 13, color: 'var(--color-text)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{hit.name}
|
||||
</span>
|
||||
{/* Ohne den Ordner darunter sind drei Dateien namens
|
||||
„Rechnung.pdf" nicht auseinanderzuhalten. */}
|
||||
<span style={{
|
||||
display: 'block', fontSize: 11, color: 'var(--color-text-muted)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
in {hit.folder_name || 'Home'}
|
||||
</span>
|
||||
{/* Bei einem Inhaltstreffer passt der Name gerade *nicht* –
|
||||
ohne die Fundstelle sähe „scan_0042.pdf" wie ein Fehler der
|
||||
Suche aus.
|
||||
|
||||
Zwei Zeilen statt einer abgeschnittenen: Eine Fundstelle,
|
||||
die nach zehn Wörtern in „…" endet, zeigt oft gerade noch
|
||||
nicht, worum es geht – und das Suchwort selbst kann darin
|
||||
fehlen. Gekappt wird jetzt am Ende der zweiten Zeile
|
||||
(line-clamp), nicht am Ende der ersten.
|
||||
|
||||
Die Linie links trennt die Fundstelle vom Ordnernamen
|
||||
darüber: Beides ist kleine, gedämpfte Schrift, und ohne
|
||||
Trennung liest es sich als ein Block. */}
|
||||
{hit.matched_content && hit.snippet && (
|
||||
<span style={{
|
||||
display: '-webkit-box',
|
||||
WebkitBoxOrient: 'vertical',
|
||||
WebkitLineClamp: 2,
|
||||
lineClamp: 2,
|
||||
overflow: 'hidden',
|
||||
fontSize: 11, lineHeight: 1.45,
|
||||
color: 'var(--color-text-muted)',
|
||||
marginTop: 3, paddingLeft: 8,
|
||||
// In der Farbe des Treffers, nur blass: --color-border war
|
||||
// gegen die hervorgehobene Zeile praktisch unsichtbar
|
||||
// (gemessen rgb(51,65,85) auf fast demselben Grund). So ist
|
||||
// die Linie auf beiden Untergründen zu sehen und gehört
|
||||
// sichtbar zum hervorgehobenen Wort.
|
||||
borderLeft: '2px solid rgba(var(--color-primary-rgb), 0.4)',
|
||||
}}>
|
||||
{highlight(hit.snippet)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{truncated && (
|
||||
<div style={{ padding: '8px 10px', fontSize: 11, color: 'var(--color-text-muted)' }}>
|
||||
Only the first {hits.length} matches are shown – narrow the search.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useState } from 'react'
|
||||
import { sharesApi, type FileItem, type ShareLink } from '../api/client'
|
||||
import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
|
||||
|
||||
interface Props {
|
||||
file: FileItem
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function ShareDialog({ file, onClose }: Props) {
|
||||
const [password, setPassword] = useState('')
|
||||
const [expiresIn, setExpiresIn] = useState('')
|
||||
const [link, setLink] = useState<ShareLink | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
const overlay = useOverlayDismiss(onClose)
|
||||
|
||||
async function createLink() {
|
||||
setCreating(true)
|
||||
setError('')
|
||||
try {
|
||||
const created = await sharesApi.create(file.id, {
|
||||
password: password || undefined,
|
||||
expires_in_hours: expiresIn ? parseInt(expiresIn) : undefined,
|
||||
})
|
||||
setLink(created)
|
||||
} catch (e: any) {
|
||||
setError(e.message ?? 'Failed to create link')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
function shareURL(token: string) {
|
||||
return `${window.location.origin}/s/${token}`
|
||||
}
|
||||
|
||||
function copyLink() {
|
||||
if (!link) return
|
||||
navigator.clipboard.writeText(shareURL(link.token))
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 200, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.6)' }}
|
||||
{...overlay}
|
||||
>
|
||||
<div style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 14,
|
||||
padding: 24,
|
||||
width: 400,
|
||||
maxWidth: '90vw',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 600, color: 'var(--color-text)' }}>Share "{file.name}"</h2>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', padding: 4, borderRadius: 6 }}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!link ? (
|
||||
<>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, color: 'var(--color-text-muted)', marginBottom: 6 }}>
|
||||
Password (optional)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Leave empty for no password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
style={{
|
||||
width: '100%', padding: '8px 12px', borderRadius: 8,
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-surface-2)',
|
||||
color: 'var(--color-text)', fontSize: 13,
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, color: 'var(--color-text-muted)', marginBottom: 6 }}>
|
||||
Expires in
|
||||
</label>
|
||||
<select
|
||||
value={expiresIn}
|
||||
onChange={e => setExpiresIn(e.target.value)}
|
||||
style={{
|
||||
width: '100%', padding: '8px 12px', borderRadius: 8,
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-surface-2)',
|
||||
color: 'var(--color-text)', fontSize: 13,
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
<option value="">Never</option>
|
||||
<option value="1">1 hour</option>
|
||||
<option value="24">24 hours</option>
|
||||
<option value="168">7 days</option>
|
||||
<option value="720">30 days</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p style={{ color: 'var(--color-danger)', fontSize: 13, marginBottom: 14 }}>{error}</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, border: '1px solid var(--color-border)',
|
||||
background: 'transparent', color: 'var(--color-text-muted)', fontSize: 13, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={createLink}
|
||||
disabled={creating}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, border: 'none',
|
||||
background: 'var(--color-primary)', color: '#fff', fontSize: 13, fontWeight: 600,
|
||||
cursor: creating ? 'not-allowed' : 'pointer', opacity: creating ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{creating ? 'Creating…' : 'Create link'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{
|
||||
background: 'var(--color-surface-2)',
|
||||
borderRadius: 8, padding: '12px 14px', marginBottom: 16,
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
}}>
|
||||
<span style={{ flex: 1, fontSize: 12, color: 'var(--color-text)', wordBreak: 'break-all' }}>
|
||||
{shareURL(link.token)}
|
||||
</span>
|
||||
<button
|
||||
onClick={copyLink}
|
||||
style={{
|
||||
padding: '5px 10px', borderRadius: 6, border: '1px solid var(--color-border)',
|
||||
background: copied ? 'var(--color-success)' : 'transparent',
|
||||
color: copied ? '#fff' : 'var(--color-text)', fontSize: 12, cursor: 'pointer',
|
||||
whiteSpace: 'nowrap', flexShrink: 0,
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
>
|
||||
{copied ? '✓ Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{link.has_password && (
|
||||
<p style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span>🔒</span> Password protected
|
||||
</p>
|
||||
)}
|
||||
{link.expires_at && (
|
||||
<p style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 16, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span>⏰</span> Expires {new Date(link.expires_at).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, border: 'none',
|
||||
background: 'var(--color-primary)', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
aclApi,
|
||||
type AclEntry, type ShareRecipient, type SharePermission,
|
||||
} from '../api/client'
|
||||
import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
|
||||
|
||||
interface Props {
|
||||
/** Was freigegeben wird – Ordner oder einzelne Datei. */
|
||||
resource: { type: 'folder' | 'file'; id: string; name: string }
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** Freigabe an Personen und Gruppen innerhalb der Cloud.
|
||||
*
|
||||
* Bewusst getrennt vom öffentlichen Freigabelink: Der eine gibt einen
|
||||
* anonymen Zugang ins Internet, der andere benennt Personen, die sich
|
||||
* ohnehin anmelden. Ein Dialog für beides würde die Frage „wer kann das
|
||||
* sehen?" verwischen – und genau die muss beim Teilen eindeutig sein. */
|
||||
export default function ShareWithDialog({ resource, onClose }: Props) {
|
||||
const overlay = useOverlayDismiss(onClose)
|
||||
|
||||
const [entries, setEntries] = useState<AclEntry[]>([])
|
||||
const [recipients, setRecipients] = useState<ShareRecipient[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const [subject, setSubject] = useState('')
|
||||
const [permission, setPermission] = useState<SharePermission>('read')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => { load() }, [resource.id])
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [list, people] = await Promise.all([
|
||||
resource.type === 'folder'
|
||||
? aclApi.listForFolder(resource.id)
|
||||
: aclApi.listForFile(resource.id),
|
||||
aclApi.recipients(),
|
||||
])
|
||||
setEntries(list ?? [])
|
||||
setRecipients(people ?? [])
|
||||
} catch {
|
||||
setError('Could not load sharing settings')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function grant() {
|
||||
const chosen = recipients.find(r => r.id === subject)
|
||||
if (!chosen) return
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
if (resource.type === 'folder') {
|
||||
await aclApi.grantFolder(resource.id, chosen.id, chosen.type, permission)
|
||||
} else {
|
||||
await aclApi.grantFile(resource.id, chosen.id, chosen.type, permission)
|
||||
}
|
||||
setSubject('')
|
||||
await load()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Could not share')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(entry: AclEntry) {
|
||||
try {
|
||||
await aclApi.revoke(entry.id)
|
||||
setEntries(list => list.filter(e => e.id !== entry.id))
|
||||
} catch {
|
||||
setError('Could not remove access')
|
||||
}
|
||||
}
|
||||
|
||||
// Wer schon Zugriff hat, gehört nicht noch einmal in die Auswahlliste.
|
||||
const alreadyShared = new Set(entries.map(e => e.subject_id))
|
||||
const available = recipients.filter(r => !alreadyShared.has(r.id))
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 300,
|
||||
background: 'rgba(0,0,0,0.55)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20,
|
||||
}} {...overlay}>
|
||||
<div style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 16, width: 480, maxWidth: '100%', maxHeight: '85vh',
|
||||
display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
||||
boxShadow: '0 12px 40px rgba(0,0,0,0.3)',
|
||||
}}>
|
||||
{/* Kopf */}
|
||||
<div style={{ padding: '18px 22px 14px', borderBottom: '1px solid var(--color-border)' }}>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--color-text)' }}>
|
||||
Share with people
|
||||
</h3>
|
||||
<p style={{
|
||||
margin: '3px 0 0', fontSize: 12, color: 'var(--color-text-muted)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{resource.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Hinzufügen */}
|
||||
<div style={{ padding: '14px 22px', borderBottom: '1px solid var(--color-border)' }}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<select
|
||||
value={subject}
|
||||
onChange={e => setSubject(e.target.value)}
|
||||
style={{
|
||||
flex: 1, minWidth: 0, padding: '8px 10px', borderRadius: 8,
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-surface-2)', color: 'var(--color-text)',
|
||||
fontSize: 13, outline: 'none',
|
||||
}}
|
||||
>
|
||||
<option value="">Choose a person or group…</option>
|
||||
{available.filter(r => r.type === 'user').length > 0 && (
|
||||
<optgroup label="People">
|
||||
{available.filter(r => r.type === 'user').map(r => (
|
||||
<option key={r.id} value={r.id}>{r.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{available.filter(r => r.type === 'group').length > 0 && (
|
||||
<optgroup label="Groups">
|
||||
{available.filter(r => r.type === 'group').map(r => (
|
||||
<option key={r.id} value={r.id}>{r.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={permission}
|
||||
onChange={e => setPermission(e.target.value as SharePermission)}
|
||||
style={{
|
||||
padding: '8px 10px', borderRadius: 8, flexShrink: 0,
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-surface-2)', color: 'var(--color-text)',
|
||||
fontSize: 13, outline: 'none',
|
||||
}}
|
||||
>
|
||||
<option value="read">Can view</option>
|
||||
<option value="write">Can edit</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={grant}
|
||||
disabled={!subject || saving}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, border: 'none', flexShrink: 0,
|
||||
background: 'var(--color-primary)', color: '#fff',
|
||||
fontSize: 13, fontWeight: 600,
|
||||
cursor: !subject || saving ? 'default' : 'pointer',
|
||||
opacity: !subject || saving ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{saving ? '…' : 'Share'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{resource.type === 'folder' && (
|
||||
<p style={{ margin: '10px 0 0', fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
||||
Everything inside this folder is shared too, including anything added later.
|
||||
Files stay in your storage and count towards your quota.
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p style={{ margin: '10px 0 0', fontSize: 12, color: 'var(--color-danger)' }}>{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Wer hat Zugriff */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '14px 22px' }}>
|
||||
<div style={{
|
||||
fontSize: 11, fontWeight: 700, color: 'var(--color-text-muted)',
|
||||
textTransform: 'uppercase', letterSpacing: 0.4, marginBottom: 10,
|
||||
}}>
|
||||
Who has access
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p style={{ margin: 0, fontSize: 13, color: 'var(--color-text-muted)' }}>Loading…</p>
|
||||
) : entries.length === 0 ? (
|
||||
<p style={{ margin: 0, fontSize: 13, color: 'var(--color-text-muted)' }}>
|
||||
Only you. Nobody else can see this.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{entries.map(e => (
|
||||
<div key={e.id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '9px 11px', borderRadius: 9,
|
||||
border: '1px solid var(--color-border)',
|
||||
}}>
|
||||
<span style={{ color: 'var(--color-text-muted)', display: 'flex', flexShrink: 0 }}>
|
||||
{e.subject_type === 'group' ? <GroupIcon /> : <PersonIcon />}
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: 13, color: 'var(--color-text)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{e.subject_name}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
|
||||
{e.subject_type === 'group' ? 'Group' : 'Person'} ·{' '}
|
||||
{e.permission === 'write' ? 'Can edit' : 'Can view'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => revoke(e)}
|
||||
style={{
|
||||
padding: '4px 11px', borderRadius: 7, flexShrink: 0,
|
||||
border: '1px solid var(--color-border)', background: 'transparent',
|
||||
color: 'var(--color-text-muted)', fontSize: 11, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
padding: '12px 22px', borderTop: '1px solid var(--color-border)',
|
||||
display: 'flex', justifyContent: 'flex-end',
|
||||
}}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '8px 18px', borderRadius: 9,
|
||||
border: '1px solid var(--color-border)', background: 'transparent',
|
||||
color: 'var(--color-text)', fontSize: 13, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PersonIcon() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M4 21v-1a7 7 0 0114 0v1" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupIcon() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="9" cy="8" r="3.5" />
|
||||
<path d="M2 21v-1a7 7 0 0114 0v1" />
|
||||
<path d="M17 11a3 3 0 100-6M19 21v-1a6 6 0 00-3-5.2" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useState } from 'react'
|
||||
import type { FolderItem, User } from '../api/client'
|
||||
import { useNavHistory, type View } from '../hooks/useNavHistory'
|
||||
import Sidebar from './Sidebar'
|
||||
import FileBrowser from '../pages/FileBrowser'
|
||||
import TrashPage from '../pages/TrashPage'
|
||||
import SharedPage from '../pages/SharedPage'
|
||||
import SharedWithMePage from '../pages/SharedWithMePage'
|
||||
import AdminPage from '../pages/AdminPage'
|
||||
import ProfilePage from '../pages/ProfilePage'
|
||||
import TagsPage from '../pages/TagsPage'
|
||||
import { UserMenuProvider } from './UserMenu'
|
||||
|
||||
export type { View }
|
||||
|
||||
interface Props {
|
||||
user: User
|
||||
onLogout: () => Promise<void>
|
||||
/** Lädt den Nutzer neu – für Speicherverbrauch und Anzeigename. */
|
||||
onUserChanged: () => Promise<void>
|
||||
}
|
||||
|
||||
export default function Shell({ user, onLogout, onUserChanged }: Props) {
|
||||
const nav = useNavHistory()
|
||||
const { view, folderPath } = nav
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
|
||||
// Aus der Seitenleiste kennen wir nur Kennung und Name – die Kette darueber
|
||||
// holt die Liste nicht mit, also faengt der Pfad hier beim Ordner selbst an.
|
||||
function navigateToFolder(folderId: string, folderName: string) {
|
||||
setSidebarOpen(false)
|
||||
const stub: FolderItem = {
|
||||
id: folderId, owner_id: null, group_id: null, parent_id: null,
|
||||
name: folderName, created_at: '',
|
||||
}
|
||||
nav.setFolderPath([stub])
|
||||
}
|
||||
|
||||
return (
|
||||
<UserMenuProvider value={{
|
||||
user,
|
||||
onNavigate: (v) => { nav.setView(v); setSidebarOpen(false) },
|
||||
onLogout,
|
||||
onOpenSidebar: () => setSidebarOpen(true),
|
||||
}}>
|
||||
<div style={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
|
||||
{/* Mobile overlay */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 40 }}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Sidebar
|
||||
user={user}
|
||||
view={view}
|
||||
activeTagId={nav.tagId}
|
||||
onOpenTag={nav.setTag}
|
||||
activeCorrespondentId={nav.correspondentId}
|
||||
onOpenCorrespondent={nav.setCorrespondent}
|
||||
onNavigate={(v) => { nav.setView(v); setSidebarOpen(false) }}
|
||||
onNavigateToFolder={navigateToFolder}
|
||||
open={sidebarOpen}
|
||||
/>
|
||||
|
||||
{/* Main content */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, overflow: 'hidden' }}>
|
||||
{view === 'files' && (
|
||||
<FileBrowser
|
||||
user={user}
|
||||
folderPath={folderPath}
|
||||
onNavigate={nav.setFolderPath}
|
||||
onUserChanged={onUserChanged}
|
||||
/>
|
||||
)}
|
||||
{view === 'trash' && <TrashPage onUserChanged={onUserChanged} />}
|
||||
{view === 'incoming' && <SharedWithMePage onOpenFolder={navigateToFolder} />}
|
||||
{view === 'shared' && <SharedPage />}
|
||||
{view === 'admin' && <AdminPage />}
|
||||
{view === 'profile' && <ProfilePage user={user} onUserChanged={onUserChanged} />}
|
||||
{/* Etikett und Absender teilen sich die Seite – es ist dieselbe Frage
|
||||
(„alles mit diesem Merkmal"), nur ein anderes Merkmal. */}
|
||||
{(view === 'tags' || view === 'from') && (
|
||||
<TagsPage
|
||||
tagId={view === 'tags' ? nav.tagId : undefined}
|
||||
correspondentId={view === 'from' ? nav.correspondentId : undefined}
|
||||
onOpenFolder={(id, name) => {
|
||||
// Klick auf einen Treffer springt zum Ordner – dieselbe Regel wie
|
||||
// in der Suche: Geöffnet wird per Doppelklick auf die Datei.
|
||||
if (id) navigateToFolder(id, name)
|
||||
else nav.setFolderPath([])
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</UserMenuProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,745 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { favoritesApi, foldersApi, tagsApi, correspondentsApi, tagsChangedEvent, notifyTagsChanged, type Correspondent, type FavoriteItem, type FolderItem, type Tag } from '../api/client'
|
||||
import { tagColor } from './TagChip'
|
||||
import TagEditDialog, { CorrespondentRenameDialog } from './TagEditDialog'
|
||||
import ContextMenu from './ContextMenu'
|
||||
import ConfirmDialog from './ConfirmDialog'
|
||||
import { RenameIcon, TrashIcon } from './icons'
|
||||
import type { User } from '../api/client'
|
||||
import type { View } from './Shell'
|
||||
import { useAppSettings } from '../hooks/useAppSettings'
|
||||
|
||||
interface Props {
|
||||
user: User
|
||||
/** Gerade angezeigtes Etikett – für die Hervorhebung in der Liste. */
|
||||
activeTagId?: string
|
||||
onOpenTag: (tagId: string) => void
|
||||
activeCorrespondentId?: string
|
||||
onOpenCorrespondent: (id: string) => void
|
||||
view: View
|
||||
onNavigate: (v: View) => void
|
||||
onNavigateToFolder: (folderId: string, folderName: string) => void
|
||||
open: boolean
|
||||
}
|
||||
|
||||
const nav: { id: View; label: string; icon: React.ReactNode }[] = [
|
||||
{
|
||||
id: 'files',
|
||||
label: 'Home',
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 10.5L12 3l9 7.5" />
|
||||
<path d="M5.5 9.5V20a1 1 0 001 1h11a1 1 0 001-1V9.5" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'trash',
|
||||
label: 'Trash',
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14H6L5 6" />
|
||||
<path d="M10 11v6M14 11v6M9 6V4h6v2" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'incoming',
|
||||
label: 'Shared with me',
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
|
||||
<circle cx="12" cy="13" r="2" />
|
||||
<path d="M8.5 18a3.5 3.5 0 017 0" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'shared',
|
||||
label: 'Shared links',
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="18" cy="5" r="3" />
|
||||
<circle cx="6" cy="12" r="3" />
|
||||
<circle cx="18" cy="19" r="3" />
|
||||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
|
||||
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
// „Admin" stand hier bis zum 23.08.2026. Der Eintrag ist ins Nutzermenü oben
|
||||
// rechts gewandert: Diese Leiste zeigt *Orte, an denen Dateien liegen* –
|
||||
// Home, Papierkorb, Geteiltes. Die Verwaltung ist keiner davon, und mit einem
|
||||
// Stern-Symbol zwischen Ordnersymbolen sah sie auch nicht danach aus.
|
||||
]
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`
|
||||
if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`
|
||||
return `${(n / 1024 ** 3).toFixed(1)} GB`
|
||||
}
|
||||
|
||||
function ChevronIcon({ expanded }: { expanded: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
width="12" height="12" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" strokeWidth="2.5"
|
||||
style={{ transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 0.15s', flexShrink: 0 }}
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function FolderIcon({ size = 14 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ flexShrink: 0 }}>
|
||||
<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
interface SubfolderRowProps {
|
||||
folder: FolderItem
|
||||
onNavigate: (id: string, name: string) => void
|
||||
}
|
||||
|
||||
function SubfolderRow({ folder, onNavigate }: SubfolderRowProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onNavigate(folder.id, folder.name)}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '5px 10px 5px 28px', borderRadius: 6,
|
||||
border: 'none', cursor: 'pointer', background: 'transparent',
|
||||
color: 'var(--color-text-muted)', fontSize: 12, textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--color-surface-2)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<FolderIcon size={12} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{folder.name}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
interface FavoriteRowProps {
|
||||
fav: FavoriteItem
|
||||
onNavigate: (id: string, name: string) => void
|
||||
onRemove: (fav: FavoriteItem) => void
|
||||
onToggleExpand: (fav: FavoriteItem) => void
|
||||
onDragStart: (e: React.DragEvent, fav: FavoriteItem) => void
|
||||
onDragOver: (e: React.DragEvent, fav: FavoriteItem) => void
|
||||
onDrop: (e: React.DragEvent, fav: FavoriteItem) => void
|
||||
dragOverId: string | null
|
||||
}
|
||||
|
||||
function FavoriteRow({ fav, onNavigate, onRemove, onToggleExpand, onDragStart, onDragOver, onDrop, dragOverId }: FavoriteRowProps) {
|
||||
const [subfolders, setSubfolders] = useState<FolderItem[] | null>(null)
|
||||
const [showCtx, setShowCtx] = useState(false)
|
||||
const loaded = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (fav.is_expanded && !loaded.current) {
|
||||
loaded.current = true
|
||||
foldersApi.list(fav.folder_id)
|
||||
.then(data => setSubfolders(data ?? []))
|
||||
.catch(() => setSubfolders([]))
|
||||
}
|
||||
}, [fav.is_expanded, fav.folder_id])
|
||||
|
||||
function handleToggle() {
|
||||
if (!loaded.current && !fav.is_expanded) {
|
||||
loaded.current = true
|
||||
foldersApi.list(fav.folder_id)
|
||||
.then(data => setSubfolders(data ?? []))
|
||||
.catch(() => setSubfolders([]))
|
||||
}
|
||||
onToggleExpand(fav)
|
||||
}
|
||||
|
||||
const isOver = dragOverId === fav.folder_id
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
draggable
|
||||
onDragStart={e => onDragStart(e, fav)}
|
||||
onDragOver={e => onDragOver(e, fav)}
|
||||
onDrop={e => onDrop(e, fav)}
|
||||
onDragEnd={() => {}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4,
|
||||
padding: '6px 6px 6px 8px', borderRadius: 7,
|
||||
background: isOver ? 'rgba(var(--color-primary-rgb), 0.12)' : 'transparent',
|
||||
cursor: 'grab',
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isOver) e.currentTarget.style.background = 'var(--color-surface-2)' }}
|
||||
onMouseLeave={e => { if (!isOver) e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
{/* Expand toggle */}
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--color-text-muted)', display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<ChevronIcon expanded={fav.is_expanded} />
|
||||
</button>
|
||||
|
||||
<FolderIcon size={14} />
|
||||
|
||||
{/* Name — click navigates */}
|
||||
<button
|
||||
onClick={() => onNavigate(fav.folder_id, fav.folder_name)}
|
||||
style={{
|
||||
flex: 1, background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--color-text)', fontSize: 13, textAlign: 'left',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
padding: '0 4px',
|
||||
}}
|
||||
>
|
||||
{fav.folder_name}
|
||||
</button>
|
||||
|
||||
{/* Context: remove */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setShowCtx(v => !v)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--color-text-muted)', opacity: 0.6, display: 'flex' }}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="5" r="1" fill="currentColor" />
|
||||
<circle cx="12" cy="12" r="1" fill="currentColor" />
|
||||
<circle cx="12" cy="19" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
{showCtx && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute', right: 0, top: '100%', zIndex: 200,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 8, padding: 4, minWidth: 140,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => { setShowCtx(false); onRemove(fav) }}
|
||||
style={{
|
||||
width: '100%', padding: '6px 10px', background: 'none',
|
||||
border: 'none', cursor: 'pointer', textAlign: 'left',
|
||||
fontSize: 12, color: 'var(--color-danger)', borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
Remove from favourites
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subfolders */}
|
||||
{fav.is_expanded && subfolders && subfolders.length > 0 && (
|
||||
<div>
|
||||
{subfolders.map(sf => (
|
||||
<SubfolderRow key={sf.id} folder={sf} onNavigate={onNavigate} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Sidebar({ user, view, activeTagId, onOpenTag,
|
||||
activeCorrespondentId, onOpenCorrespondent,
|
||||
onNavigate, onNavigateToFolder, open }: Props) {
|
||||
const { cloudName, logoUrl } = useAppSettings()
|
||||
// Der Anzeigename ist optional – ohne ihn bleibt der Anmeldename stehen.
|
||||
/**
|
||||
* Etiketten in der Seitenleiste – die zweite Art, an Dateien zu kommen.
|
||||
*
|
||||
* Bewusst neben den Favoriten und nicht in der Hauptnavigation: Favoriten
|
||||
* sind Orte, Etiketten sind Sichten quer über alle Orte. Beides gehört in
|
||||
* den unteren, persönlichen Teil der Leiste, nicht zu Home und Papierkorb.
|
||||
*
|
||||
* Neu geladen wird bei jedem Ansichtswechsel: Die Zählstände ändern sich,
|
||||
* sobald irgendwo etikettiert wird, und eine Zahl, die tagelang falsch
|
||||
* stehenbleibt, ist schlimmer als keine.
|
||||
*/
|
||||
const [tags, setTags] = useState<Tag[]>([])
|
||||
/**
|
||||
* Rechtsklick auf ein Etikett öffnet erst ein kleines Menü, nicht sofort den
|
||||
* Bearbeiten-Dialog. Ein Fenster, das ungefragt aufspringt, überrumpelt –
|
||||
* und bei Dateien und Ordnern verhält sich der Rechtsklick genauso.
|
||||
*/
|
||||
const [tagMenu, setTagMenu] = useState<{ x: number; y: number; tag: Tag } | null>(null)
|
||||
const [editTag, setEditTag] = useState<Tag | null>(null)
|
||||
const [deleteTag, setDeleteTag] = useState<Tag | null>(null)
|
||||
|
||||
/**
|
||||
* Absender als **eigene** Liste, nicht unter den Etiketten.
|
||||
*
|
||||
* Das ist der ganze Punkt des Feldes: „Stadtwerke" und „absetzbar" in einer
|
||||
* Aufzählung wäre Absender und Verwendungszweck durcheinander – bei zwanzig
|
||||
* Einträgen unauffällig, bei achtzig unbenutzbar.
|
||||
*/
|
||||
const [correspondents, setCorrespondents] = useState<Correspondent[]>([])
|
||||
const [corrMenu, setCorrMenu] = useState<{ x: number; y: number; c: Correspondent } | null>(null)
|
||||
const [renameCorr, setRenameCorr] = useState<Correspondent | null>(null)
|
||||
const [deleteCorr, setDeleteCorr] = useState<Correspondent | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const reload = () => {
|
||||
tagsApi.list().then(setTags).catch(() => { /* ohne Etiketten geht es auch */ })
|
||||
correspondentsApi.list().then(setCorrespondents).catch(() => { /* ebenso */ })
|
||||
}
|
||||
reload()
|
||||
// Sofort nachziehen, wenn irgendwo etikettiert wird – nicht erst beim
|
||||
// nächsten Ansichtswechsel. Der Absender hängt am selben Dialog und am
|
||||
// selben Ereignis.
|
||||
window.addEventListener(tagsChangedEvent, reload)
|
||||
return () => window.removeEventListener(tagsChangedEvent, reload)
|
||||
}, [view, activeTagId, activeCorrespondentId])
|
||||
|
||||
const usedBytes = user.used_bytes || 0
|
||||
const totalBytes = user.quota_bytes || 0
|
||||
const quotaPct = totalBytes > 0 ? Math.min((usedBytes / totalBytes) * 100, 100) : 0
|
||||
|
||||
const [favorites, setFavorites] = useState<FavoriteItem[]>([])
|
||||
const [dragOverId, setDragOverId] = useState<string | null>(null)
|
||||
const dragItem = useRef<FavoriteItem | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
favoritesApi.list()
|
||||
.then(data => setFavorites(data ?? []))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
function handleToggleExpand(fav: FavoriteItem) {
|
||||
const next = !fav.is_expanded
|
||||
setFavorites(fs => fs.map(f => f.folder_id === fav.folder_id ? { ...f, is_expanded: next } : f))
|
||||
favoritesApi.setExpanded(fav.folder_id, next).catch(() => {})
|
||||
}
|
||||
|
||||
function handleRemove(fav: FavoriteItem) {
|
||||
setFavorites(fs => fs.filter(f => f.folder_id !== fav.folder_id))
|
||||
favoritesApi.remove(fav.folder_id).catch(() => {})
|
||||
}
|
||||
|
||||
function handleDragStart(e: React.DragEvent, fav: FavoriteItem) {
|
||||
dragItem.current = fav
|
||||
e.dataTransfer.setData('aether-favorite-id', fav.folder_id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
function handleDragOver(e: React.DragEvent, fav: FavoriteItem) {
|
||||
if (!e.dataTransfer.types.includes('aether-favorite-id')) return
|
||||
e.preventDefault()
|
||||
setDragOverId(fav.folder_id)
|
||||
}
|
||||
|
||||
function handleDrop(e: React.DragEvent, target: FavoriteItem) {
|
||||
e.preventDefault()
|
||||
setDragOverId(null)
|
||||
const src = dragItem.current
|
||||
if (!src || src.folder_id === target.folder_id) return
|
||||
dragItem.current = null
|
||||
|
||||
// Reorder locally
|
||||
const next = [...favorites]
|
||||
const srcIdx = next.findIndex(f => f.folder_id === src.folder_id)
|
||||
const tgtIdx = next.findIndex(f => f.folder_id === target.folder_id)
|
||||
next.splice(srcIdx, 1)
|
||||
next.splice(tgtIdx, 0, src)
|
||||
setFavorites(next)
|
||||
favoritesApi.reorder(next.map(f => f.folder_id)).catch(() => {})
|
||||
}
|
||||
|
||||
// Expose addFavorite so FileBrowser context menu can call it
|
||||
;(window as any).__aetherAddFavorite = async (folder: FolderItem) => {
|
||||
try {
|
||||
const fav = await favoritesApi.add(folder.id)
|
||||
setFavorites(fs => [...fs, fav])
|
||||
} catch { /* already favorite */ }
|
||||
}
|
||||
;(window as any).__aetherRemoveFavorite = async (folderId: string) => {
|
||||
setFavorites(fs => fs.filter(f => f.folder_id !== folderId))
|
||||
await favoritesApi.remove(folderId).catch(() => {})
|
||||
}
|
||||
;(window as any).__aetherIsFavorite = (folderId: string) =>
|
||||
favorites.some(f => f.folder_id === folderId)
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className={`aether-sidebar${open ? ' aether-sidebar--open' : ''}`}>
|
||||
{/* Brand */}
|
||||
<div style={{ padding: '20px 16px 16px', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
{logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
style={{ height: 30, maxWidth: 120, objectFit: 'contain', flexShrink: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
width: 30, height: 30, borderRadius: 8,
|
||||
background: 'var(--color-primary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||
}}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="1.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round"
|
||||
d="M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<span style={{ fontWeight: 700, fontSize: 16, color: 'var(--color-text)', letterSpacing: '-0.01em' }}>
|
||||
{cloudName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav style={{ padding: '4px 8px' }}>
|
||||
{nav.map(item => {
|
||||
const active = view === item.id
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '8px 10px', borderRadius: 8,
|
||||
border: 'none', cursor: 'pointer',
|
||||
background: active ? 'rgba(var(--color-primary-rgb), 0.15)' : 'transparent',
|
||||
// Streifen links in der Akzentfarbe – die zweite Farbe braucht
|
||||
// eine Stelle, an der man sie tatsächlich sieht.
|
||||
boxShadow: active ? 'inset 3px 0 0 var(--color-accent)' : 'none',
|
||||
color: active ? 'var(--color-primary)' : 'var(--color-text-muted)',
|
||||
fontWeight: active ? 600 : 400, fontSize: 14, textAlign: 'left',
|
||||
transition: 'background 0.15s, color 0.15s', marginBottom: 2,
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'var(--color-surface-2)' }}
|
||||
onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Favourites */}
|
||||
{favorites.length > 0 && (
|
||||
<div style={{ padding: '8px 8px 0' }}>
|
||||
<div style={{
|
||||
fontSize: 10, fontWeight: 600, letterSpacing: '0.08em',
|
||||
color: 'var(--color-text-muted)', padding: '4px 10px 6px',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
Favourites
|
||||
</div>
|
||||
<div onDragLeave={() => setDragOverId(null)}>
|
||||
{favorites.map(fav => (
|
||||
<FavoriteRow
|
||||
key={fav.folder_id}
|
||||
fav={fav}
|
||||
onNavigate={(id, name) => { onNavigate('files'); onNavigateToFolder(id, name) }}
|
||||
onRemove={handleRemove}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
dragOverId={dragOverId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Etiketten */}
|
||||
{tags.length > 0 && (
|
||||
<div style={{ padding: '8px 8px 0' }}>
|
||||
<div style={{
|
||||
fontSize: 10, fontWeight: 600, letterSpacing: '0.08em',
|
||||
color: 'var(--color-text-muted)', padding: '4px 10px 6px',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
Tags
|
||||
</div>
|
||||
{tags.map(tag => {
|
||||
const active = view === 'tags' && activeTagId === tag.id
|
||||
const color = tagColor(tag)
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => onOpenTag(tag.id)}
|
||||
// Rechtsklick öffnet Umbenennen/Farbe/Löschen – dieselbe
|
||||
// Geste wie bei Dateien und Ordnern.
|
||||
onContextMenu={e => {
|
||||
e.preventDefault()
|
||||
setTagMenu({ x: e.clientX, y: e.clientY, tag })
|
||||
}}
|
||||
title={(tag.group_id ? `${tag.name} — shared with ${tag.group_name}` : tag.name) + ' · right-click to edit'}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 9,
|
||||
padding: '6px 10px', borderRadius: 8, border: 'none', cursor: 'pointer',
|
||||
background: active ? 'rgba(var(--color-primary-rgb), 0.15)' : 'transparent',
|
||||
color: active ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
fontSize: 13, textAlign: 'left', marginBottom: 1,
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'var(--color-surface-2)' }}
|
||||
onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
{/* Symbol, wenn eins gewählt ist – sonst der Farbpunkt.
|
||||
Nicht beides: Zwei Zeichen nebeneinander machen die
|
||||
Zeile unruhig, und das Symbol sagt ohnehin mehr. Ohne
|
||||
Symbol bleibt die Farbe die Wiedererkennung. */}
|
||||
{tag.icon ? (
|
||||
<span style={{
|
||||
width: 14, flexShrink: 0, fontSize: 13, lineHeight: 1,
|
||||
textAlign: 'center',
|
||||
}}>{tag.icon}</span>
|
||||
) : (
|
||||
<span style={{
|
||||
width: 8, height: 8, marginLeft: 3, marginRight: 3,
|
||||
borderRadius: '50%', background: color, flexShrink: 0,
|
||||
}} />
|
||||
)}
|
||||
<span style={{
|
||||
flex: 1, minWidth: 0, overflow: 'hidden',
|
||||
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{tag.name}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', flexShrink: 0 }}>
|
||||
{tag.file_count}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Absender */}
|
||||
{correspondents.length > 0 && (
|
||||
<div style={{ padding: '8px 8px 0' }}>
|
||||
<div style={{
|
||||
fontSize: 10, fontWeight: 600, letterSpacing: '0.08em',
|
||||
color: 'var(--color-text-muted)', padding: '4px 10px 6px',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
From
|
||||
</div>
|
||||
{correspondents.map(c => {
|
||||
const active = view === 'from' && activeCorrespondentId === c.id
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => onOpenCorrespondent(c.id)}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault()
|
||||
setCorrMenu({ x: e.clientX, y: e.clientY, c })
|
||||
}}
|
||||
title={(c.group_id ? `${c.name} — shared with ${c.group_name}` : c.name) + ' · right-click to edit'}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 9,
|
||||
padding: '6px 10px', borderRadius: 8, border: 'none', cursor: 'pointer',
|
||||
background: active ? 'rgba(var(--color-primary-rgb), 0.15)' : 'transparent',
|
||||
color: active ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
fontSize: 13, textAlign: 'left', marginBottom: 1,
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'var(--color-surface-2)' }}
|
||||
onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
{/* Gewähltes Symbol, sonst der Umschlag. Kein Farbpunkt wie
|
||||
beim Etikett: Ein Absender hat keine Farbe, und ein grauer
|
||||
Punkt wäre ein Etikett, das nichts bedeutet. */}
|
||||
{c.icon ? (
|
||||
<span style={{
|
||||
width: 14, flexShrink: 0, fontSize: 13, lineHeight: 1,
|
||||
textAlign: 'center',
|
||||
}}>{c.icon}</span>
|
||||
) : (
|
||||
<span style={{ display: 'flex', flexShrink: 0, opacity: 0.75, width: 14, justifyContent: 'center' }}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="5" width="20" height="14" rx="2" />
|
||||
<polyline points="2.5 6.5 12 13 21.5 6.5" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
<span style={{
|
||||
flex: 1, minWidth: 0, overflow: 'hidden',
|
||||
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{c.name}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', flexShrink: 0 }}>
|
||||
{c.file_count}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* Unten links steht nur noch, was zum *Ort* gehört: wie voll er ist.
|
||||
Der Nutzerbereich sitzt seit dem 23.08.2026 oben rechts in der
|
||||
Kopfleiste (components/UserMenu.tsx) – dort suchen Menschen den
|
||||
eigenen Zugang, und das Abmelden liegt nicht mehr einen Fehlklick
|
||||
neben dem Weg zu den Einstellungen. */}
|
||||
{totalBytes > 0 && (
|
||||
<div style={{
|
||||
// Unterer Abstand plus Wischbalken – sonst läge der Balken in der
|
||||
// installierten App hinter der Systemleiste.
|
||||
padding: '12px 16px calc(16px + var(--safe-bottom))',
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>Storage</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
|
||||
{formatBytes(usedBytes)} / {formatBytes(totalBytes)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ height: 4, background: 'var(--color-surface-2)', borderRadius: 999 }}>
|
||||
<div style={{
|
||||
width: `${quotaPct}%`, height: '100%', borderRadius: 999,
|
||||
background: quotaPct > 90 ? 'var(--color-danger)' : 'var(--color-primary)',
|
||||
transition: 'width 0.3s',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</aside>
|
||||
|
||||
{tagMenu && (
|
||||
<ContextMenu
|
||||
x={tagMenu.x}
|
||||
y={tagMenu.y}
|
||||
onClose={() => setTagMenu(null)}
|
||||
items={[
|
||||
{ label: 'Edit…', icon: <RenameIcon />, onClick: () => setEditTag(tagMenu.tag) },
|
||||
{ separator: true },
|
||||
{ label: 'Delete', icon: <TrashIcon />, danger: true, onClick: () => setDeleteTag(tagMenu.tag) },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{corrMenu && (
|
||||
<ContextMenu
|
||||
x={corrMenu.x}
|
||||
y={corrMenu.y}
|
||||
onClose={() => setCorrMenu(null)}
|
||||
items={[
|
||||
{ label: 'Rename…', icon: <RenameIcon />, onClick: () => setRenameCorr(corrMenu.c) },
|
||||
{ separator: true },
|
||||
{ label: 'Delete', icon: <TrashIcon />, danger: true, onClick: () => setDeleteCorr(corrMenu.c) },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{renameCorr && (
|
||||
<CorrespondentRenameDialog
|
||||
correspondent={renameCorr}
|
||||
onClose={() => setRenameCorr(null)}
|
||||
onChanged={updated => {
|
||||
setCorrespondents(prev => prev.map(c => (c.id === updated.id ? { ...c, ...updated } : c)))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteCorr && (
|
||||
<ConfirmDialog
|
||||
title={`Delete “${deleteCorr.name}”?`}
|
||||
// Die wichtigste Zeile hier: Der Absender verschwindet, die
|
||||
// Rechnungen bleiben. Ohne diesen Satz klingt „Delete" nach dem
|
||||
// Gegenteil, und niemand traut sich, aufzuräumen.
|
||||
message={
|
||||
deleteCorr.file_count > 0
|
||||
? `${deleteCorr.file_count} ${deleteCorr.file_count === 1 ? 'document loses' : 'documents lose'} this sender. The documents themselves stay where they are — nothing is deleted.`
|
||||
: 'Nothing is from this sender yet.'
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
danger
|
||||
onCancel={() => setDeleteCorr(null)}
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await correspondentsApi.remove(deleteCorr.id)
|
||||
setCorrespondents(prev => prev.filter(c => c.id !== deleteCorr.id))
|
||||
notifyTagsChanged()
|
||||
} finally {
|
||||
setDeleteCorr(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editTag && (
|
||||
<TagEditDialog
|
||||
tag={editTag}
|
||||
onClose={() => setEditTag(null)}
|
||||
onChanged={updated => setTags(prev => prev.map(t => t.id === updated.id ? { ...t, ...updated } : t))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteTag && (
|
||||
<ConfirmDialog
|
||||
title={`Delete “${deleteTag.name}”?`}
|
||||
// Ausdrücklich sagen, was *nicht* passiert. Ein Etikett zu löschen
|
||||
// ist eine Aussage über die Ordnung, nicht über den Bestand – aber
|
||||
// das weiß man in dem Moment nicht, und dann klickt man lieber ab.
|
||||
message={
|
||||
deleteTag.file_count > 0
|
||||
? `The tag is removed from ${deleteTag.file_count} ${deleteTag.file_count === 1 ? 'file' : 'files'}. The files themselves stay where they are — nothing is deleted.`
|
||||
: 'No file carries this tag. Nothing else changes.'
|
||||
}
|
||||
confirmLabel="Delete tag"
|
||||
danger
|
||||
onCancel={() => setDeleteTag(null)}
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await tagsApi.remove(deleteTag.id)
|
||||
notifyTagsChanged()
|
||||
setTags(prev => prev.filter(t => t.id !== deleteTag.id))
|
||||
} catch { /* bleibt stehen, dann sieht man es beim nächsten Laden */ }
|
||||
setDeleteTag(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.aether-sidebar {
|
||||
width: 220px;
|
||||
/* Statusleiste oben, Aussparung links (Querformat). */
|
||||
padding-top: var(--safe-top);
|
||||
padding-left: var(--safe-left);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-surface);
|
||||
border-right: 1px solid var(--color-border);
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.aether-sidebar {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
z-index: 50;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.aether-sidebar--open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Die Reiterleiste – eine Definition für Admin-Bereich und Einstellungen.
|
||||
*
|
||||
* Beide hatten sie zunächst eigen im Code stehen. Zwei Kopien derselben Leiste
|
||||
* laufen mit der Zeit auseinander: Die eine bekommt eine andere Schriftstärke,
|
||||
* die andere einen anderen Abstand, und beim Umschalten merkt man den
|
||||
* Unterschied, ohne ihn benennen zu können. Genau das ist bei den Kopfleisten
|
||||
* schon einmal passiert (siehe components/PageHeader.tsx).
|
||||
*/
|
||||
interface Props<T extends string> {
|
||||
tabs: { id: T; label: string }[]
|
||||
active: T
|
||||
onChange: (id: T) => void
|
||||
}
|
||||
|
||||
export default function Tabs<T extends string>({ tabs, active, onChange }: Props<T>) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', gap: 4, marginBottom: 24,
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
// Ein Pixel Innenrand, damit die Unterstreichung des aktiven Reiters
|
||||
// genau auf der Trennlinie sitzt statt darunter.
|
||||
paddingBottom: 1,
|
||||
overflowX: 'auto',
|
||||
}}>
|
||||
{tabs.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => onChange(t.id)}
|
||||
style={{
|
||||
padding: '8px 18px', border: 'none', cursor: 'pointer', fontSize: 14,
|
||||
background: 'transparent', whiteSpace: 'nowrap', flexShrink: 0,
|
||||
color: active === t.id ? 'var(--color-primary)' : 'var(--color-text-muted)',
|
||||
fontWeight: active === t.id ? 700 : 400,
|
||||
borderBottom: active === t.id ? '2px solid var(--color-primary)' : '2px solid transparent',
|
||||
marginBottom: -1,
|
||||
transition: 'color 0.15s',
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { Tag } from '../api/client'
|
||||
|
||||
/**
|
||||
* Die Farbe eines Etiketts.
|
||||
*
|
||||
* Ohne eigene Farbe wird eine aus dem Namen abgeleitet – immer dieselbe für
|
||||
* denselben Namen. Der Grund: Ein frisch angelegtes Etikett soll nicht grau
|
||||
* sein und erst eingefärbt werden müssen, aber zwei verschiedene Etiketten
|
||||
* sollen auch nicht zufällig gleich aussehen.
|
||||
*/
|
||||
export const tagPalette = [
|
||||
'#8b5cf6', '#ec4899', '#06b6d4', '#ef4444', '#f59e0b',
|
||||
'#22c55e', '#3b82f6', '#14b8a6', '#ea580c', '#6366f1',
|
||||
]
|
||||
const palette = tagPalette
|
||||
|
||||
export function tagColor(tag: { name: string; color?: string }): string {
|
||||
if (tag.color) return tag.color
|
||||
// Einfache Summe über die Zeichen. Keine Streuwertfunktion nötig – es geht
|
||||
// nur darum, dass dieselbe Eingabe stets dieselbe Farbe ergibt.
|
||||
let sum = 0
|
||||
for (const ch of tag.name) sum += ch.codePointAt(0) ?? 0
|
||||
return palette[sum % palette.length]
|
||||
}
|
||||
|
||||
/** Aufkleber, wie er an Kacheln und in Listen erscheint. */
|
||||
export default function TagChip({ tag, size = 'md', onRemove }: {
|
||||
tag: Pick<Tag, 'name' | 'color'> & { icon?: string; group_id?: string }
|
||||
size?: 'sm' | 'md'
|
||||
onRemove?: () => void
|
||||
}) {
|
||||
const color = tagColor(tag)
|
||||
const small = size === 'sm'
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||
padding: small ? '1px 6px' : '3px 9px',
|
||||
borderRadius: 999,
|
||||
fontSize: small ? 10 : 12,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.5,
|
||||
color,
|
||||
background: `${color}22`,
|
||||
border: `1px solid ${color}55`,
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Gruppen-Etiketten bekommen ein Zeichen davor: Sonst wundert man sich,
|
||||
warum ein Etikett plötzlich bei jemand anderem auftaucht. */}
|
||||
{tag.group_id && (
|
||||
<svg width={small ? 8 : 10} height={small ? 8 : 10} viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" strokeWidth="2.5" style={{ flexShrink: 0 }}>
|
||||
<circle cx="9" cy="8" r="3" /><path d="M3 20v-1a5 5 0 015-5h2a5 5 0 015 5v1" />
|
||||
<path d="M17 11a3 3 0 100-6" /><path d="M20 20v-1a4 4 0 00-2.5-3.7" />
|
||||
</svg>
|
||||
)}
|
||||
{/* Das Symbol steht vor dem Namen und **behält seine eigenen Farben**:
|
||||
Ein Emoji einzufärben ginge nicht, und es soll auch nicht – gerade
|
||||
die Farbigkeit macht es auf einen Blick unterscheidbar. */}
|
||||
{tag.icon && (
|
||||
<span style={{ flexShrink: 0, fontSize: small ? 10 : 12, lineHeight: 1 }}>
|
||||
{tag.icon}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{tag.name}
|
||||
</span>
|
||||
{onRemove && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onRemove() }}
|
||||
title={`Remove ${tag.name}`}
|
||||
style={{
|
||||
border: 'none', background: 'none', cursor: 'pointer', padding: 0,
|
||||
color, display: 'flex', flexShrink: 0, opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
||||
<line x1="5" y1="5" x2="19" y2="19" /><line x1="19" y1="5" x2="5" y2="19" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { tagsApi, correspondentsApi, notifyTagsChanged, APIError, type Correspondent, type FileItem, type Tag } from '../api/client'
|
||||
import TagChip, { tagColor, tagPalette } from './TagChip'
|
||||
import { ColourPicker, IconPicker } from './TagEditDialog'
|
||||
|
||||
/**
|
||||
* Etiketten, Belegdatum und Absender einer Datei setzen.
|
||||
*
|
||||
* Alles drei in einem Dialog und in einem Aufruf: Wer eine Rechnung
|
||||
* einsortiert, setzt „steuer-2026", das Rechnungsdatum und „Stadtwerke" in
|
||||
* derselben Handlung. Der Server schreibt sie in einer Transaktion – entweder
|
||||
* gilt alles oder nichts.
|
||||
*/
|
||||
interface Props {
|
||||
file: FileItem
|
||||
/** Etiketten, die diese Datei schon trägt. */
|
||||
current: Tag[]
|
||||
/** Absender, den sie schon trägt – oder keiner. */
|
||||
currentCorrespondent?: Correspondent
|
||||
onClose: () => void
|
||||
onSaved: (tags: Tag[]) => void
|
||||
}
|
||||
|
||||
export default function TagDialog({ file, current, currentCorrespondent, onClose, onSaved }: Props) {
|
||||
const [all, setAll] = useState<Tag[]>([])
|
||||
const [chosen, setChosen] = useState<Set<string>>(() => new Set(current.map(t => t.id)))
|
||||
// Der Server liefert einen vollen Zeitstempel („2026-01-15T00:00:00Z"), ein
|
||||
// <input type="date"> versteht aber nur „2026-01-15" und zeigt sonst
|
||||
// kommentarlos ein leeres Feld – der gesetzte Wert sah damit aus wie keiner.
|
||||
const [date, setDate] = useState((file.document_date ?? '').slice(0, 10))
|
||||
const [newName, setNewName] = useState('')
|
||||
/** Farbe für das nächste neue Etikett. Vorgewählt ist eine aus der Palette,
|
||||
* damit man nichts wählen *muss* – aber sichtbar, damit man es kann. */
|
||||
const [newColor, setNewColor] = useState(tagPalette[0])
|
||||
/** Symbol für das nächste neue Etikett. Leer ist die Vorgabe – ein Emoji
|
||||
* ist ein Angebot, keine Pflicht. */
|
||||
const [newIcon, setNewIcon] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
/**
|
||||
* Der Absender steht als **Text** im Feld, nicht als ausgewählte ID.
|
||||
*
|
||||
* Ein Auswahlfeld zwänge dazu, den Absender vorher irgendwo anzulegen –
|
||||
* beim Einsortieren des ersten Belegs von „Stadtwerke" gäbe es ihn aber
|
||||
* noch gar nicht. Ein Textfeld mit Vorschlägen kann beides: das Vorhandene
|
||||
* treffen (Vorschlagsliste) und Neues anlegen (einfach tippen).
|
||||
*
|
||||
* Der Server sucht den Namen und legt ihn nur an, wenn es ihn nicht gibt –
|
||||
* ohne Rücksicht auf Groß-/Kleinschreibung. „stadtwerke" landet also beim
|
||||
* vorhandenen „Stadtwerke" und erzeugt keinen Zwilling.
|
||||
*/
|
||||
const [correspondent, setCorrespondent] = useState(currentCorrespondent?.name ?? '')
|
||||
const [knownCorrespondents, setKnownCorrespondents] = useState<Correspondent[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
tagsApi.list().then(setAll).catch(() => setError('Could not load tags'))
|
||||
correspondentsApi.list().then(setKnownCorrespondents).catch(() => {
|
||||
/* ohne Vorschläge geht es auch – tippen kann man trotzdem */
|
||||
})
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Nur Etiketten desselben Eigentümers sind erlaubt – eine Datei im
|
||||
* Gruppenordner trägt Gruppen-Etiketten, eine eigene die eigenen. Der Server
|
||||
* lehnt alles andere ab; die Liste hier zeigt deshalb gar nicht erst, was
|
||||
* nicht ginge.
|
||||
*/
|
||||
const usable = all.filter(t => (file.group_id ? t.group_id === file.group_id : !t.group_id))
|
||||
|
||||
/**
|
||||
* Etikett anlegen – oder das gleichnamige nehmen, wenn es es schon gibt.
|
||||
*
|
||||
* **Warum kein Fehler bei einem vorhandenen Namen:** Die Liste in diesem
|
||||
* Dialog wird beim Öffnen einmal geholt. Legt man anderswo ein Etikett an –
|
||||
* in einem zweiten Fenster, im vorigen Dialog –, kennt sie es nicht. Man
|
||||
* tippt den Namen, bekommt „That name is already taken", sieht das Etikett
|
||||
* aber nirgends und hält die Anwendung für kaputt. Genau das ist Marc am
|
||||
* 24.08.2026 passiert.
|
||||
*
|
||||
* Richtig ist ohnehin, was der Mensch meint: Er will die Datei mit „Finanz"
|
||||
* versehen. Ob das Etikett dafür neu entsteht oder schon existierte, ist
|
||||
* seine Sache nicht.
|
||||
*/
|
||||
async function createTag() {
|
||||
const name = newName.trim()
|
||||
if (!name) return
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
const tag = await tagsApi.create(name, newColor, file.group_id ?? undefined, newIcon)
|
||||
setAll(prev => [...prev, tag])
|
||||
notifyTagsChanged()
|
||||
setChosen(prev => new Set(prev).add(tag.id))
|
||||
setNewName('')
|
||||
setNewIcon('')
|
||||
// Nächste Farbe vorwählen, damit zwei nacheinander angelegte Etiketten
|
||||
// nicht zufällig gleich aussehen.
|
||||
setNewColor(prev => tagPalette[(tagPalette.indexOf(prev) + 1) % tagPalette.length])
|
||||
} catch (err) {
|
||||
if (err instanceof APIError && err.status === 409) {
|
||||
// Es gibt es schon – Liste neu holen und das gleichnamige anhaken.
|
||||
try {
|
||||
const fresh = await tagsApi.list()
|
||||
setAll(fresh)
|
||||
const hit = fresh.find(t => t.name.toLowerCase() === name.toLowerCase())
|
||||
if (hit) {
|
||||
setChosen(prev => new Set(prev).add(hit.id))
|
||||
setNewName('')
|
||||
return
|
||||
}
|
||||
} catch { /* dann eben die Meldung unten */ }
|
||||
}
|
||||
// Ab hier sagt der Server, was los war – nicht mehr dieser Dialog. Die
|
||||
// frühere feste Meldung „That name is already taken" behauptete eine
|
||||
// Ursache und lag bei jedem anderen Fehler daneben.
|
||||
setError(err instanceof Error ? err.message : 'Could not create the tag')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
// Ein geleertes Feld heißt „kein Absender mehr" – deshalb die leere ID
|
||||
// statt des leeren Namens: Ein leerer Name hieße „nicht mitgeschickt".
|
||||
const name = correspondent.trim()
|
||||
const tags = await tagsApi.setForFile(file.id, [...chosen], date,
|
||||
name ? { name } : { id: '' })
|
||||
notifyTagsChanged()
|
||||
onSaved(tags)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
// Auch hier: die Meldung des Servers statt einer geratenen. „check the
|
||||
// date" stand vorher selbst dann da, wenn gar kein Datum im Spiel war.
|
||||
setError(err instanceof Error ? err.message : 'Could not save')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(id: string) {
|
||||
setChosen(prev => {
|
||||
const next = new Set(prev)
|
||||
next.has(id) ? next.delete(id) : next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 200, padding: 20,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
width: 460, maxWidth: '100%', maxHeight: '85vh', overflowY: 'auto',
|
||||
background: 'var(--color-surface)', border: '1px solid var(--color-border)',
|
||||
borderRadius: 14, padding: 22, boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: '0 0 4px', fontSize: 15, fontWeight: 700, color: 'var(--color-text)' }}>
|
||||
Organise
|
||||
</h3>
|
||||
<p style={{
|
||||
margin: '0 0 18px', fontSize: 12, color: 'var(--color-text-muted)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{file.name}
|
||||
</p>
|
||||
|
||||
{usable.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 7, marginBottom: 18 }}>
|
||||
{usable.map(tag => {
|
||||
const on = chosen.has(tag.id)
|
||||
const color = tagColor(tag)
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => toggle(tag.id)}
|
||||
style={{
|
||||
padding: '4px 11px', borderRadius: 999, cursor: 'pointer',
|
||||
fontSize: 12, fontWeight: 600,
|
||||
// Gewählt heißt gefüllt, ungewählt nur umrandet. Ein
|
||||
// Häkchen daneben wäre bei zwanzig Etiketten unruhig.
|
||||
color: on ? color : 'var(--color-text-muted)',
|
||||
background: on ? `${color}22` : 'transparent',
|
||||
border: `1px solid ${on ? `${color}88` : 'var(--color-border)'}`,
|
||||
transition: 'all 0.12s',
|
||||
}}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Neues Etikett direkt hier anlegen. Ein eigener Verwaltungsdialog
|
||||
wäre ein Umweg: Man merkt beim Einsortieren, dass eins fehlt. */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
|
||||
<input
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void createTag() } }}
|
||||
placeholder={file.group_id ? 'New group tag…' : 'New tag…'}
|
||||
style={{
|
||||
flex: 1, padding: '7px 10px', borderRadius: 8, fontSize: 13,
|
||||
border: '1px solid var(--color-border)', background: 'var(--color-surface-2)',
|
||||
color: 'var(--color-text)', outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => void createTag()}
|
||||
disabled={!newName.trim() || busy}
|
||||
style={{
|
||||
padding: '7px 14px', borderRadius: 8, fontSize: 12, fontWeight: 600,
|
||||
border: '1px solid var(--color-border)', background: 'var(--color-surface-2)',
|
||||
color: 'var(--color-text)', cursor: newName.trim() ? 'pointer' : 'default',
|
||||
opacity: newName.trim() ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Farbwahl erscheint erst beim Tippen: Solange man nichts anlegt, ist
|
||||
sie nur Fläche, die vom Wesentlichen ablenkt. */}
|
||||
{newName.trim() && (
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<ColourPicker value={newColor} onChange={setNewColor} />
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<IconPicker value={newIcon} onChange={setNewIcon} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 4 }}>
|
||||
From
|
||||
</label>
|
||||
<input
|
||||
list="aether-correspondents"
|
||||
value={correspondent}
|
||||
onChange={e => setCorrespondent(e.target.value)}
|
||||
placeholder="Stadtwerke, Amazon, Finanzamt…"
|
||||
maxLength={80}
|
||||
style={{
|
||||
width: '100%', padding: '7px 10px', borderRadius: 8, fontSize: 13,
|
||||
border: '1px solid var(--color-border)', background: 'var(--color-surface-2)',
|
||||
color: 'var(--color-text)', outline: 'none',
|
||||
}}
|
||||
/>
|
||||
{/* Vorschläge aus dem Bestand. Ein <datalist> statt einer eigenen
|
||||
Auswahlliste: Der Browser bringt Filtern, Tastaturbedienung und die
|
||||
Darstellung am Telefon schon mit, und tippen bleibt trotzdem
|
||||
möglich – genau das braucht ein Feld, das Vorhandenes treffen *und*
|
||||
Neues anlegen können muss. */}
|
||||
<datalist id="aether-correspondents">
|
||||
{knownCorrespondents
|
||||
.filter(c => (file.group_id ? c.group_id === file.group_id : !c.group_id))
|
||||
.map(c => <option key={c.id} value={c.name} />)}
|
||||
</datalist>
|
||||
<p style={{ margin: '6px 0 18px', fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
||||
Who the document is from. Exactly one — unlike tags. A new name is
|
||||
created as you save it.
|
||||
</p>
|
||||
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 4 }}>
|
||||
Document date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={e => setDate(e.target.value)}
|
||||
style={{
|
||||
width: '100%', padding: '7px 10px', borderRadius: 8, fontSize: 13,
|
||||
border: '1px solid var(--color-border)', background: 'var(--color-surface-2)',
|
||||
color: 'var(--color-text)', outline: 'none', colorScheme: 'dark',
|
||||
}}
|
||||
/>
|
||||
<p style={{ margin: '6px 0 0', fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
||||
The date printed on the document — not when you uploaded it. A receipt
|
||||
from December scanned in January still belongs to the old year.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<p style={{ margin: '14px 0 0', fontSize: 12, color: 'var(--color-danger)' }}>{error}</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10, marginTop: 22 }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '8px 16px', border: 'none', background: 'none', cursor: 'pointer',
|
||||
fontSize: 13, color: 'var(--color-text-muted)',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void save()}
|
||||
disabled={busy}
|
||||
style={{
|
||||
padding: '8px 18px', borderRadius: 8, border: 'none', cursor: 'pointer',
|
||||
fontSize: 13, fontWeight: 600, color: '#fff',
|
||||
background: 'var(--color-primary)', opacity: busy ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{busy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Kleine Reihe Aufkleber, wie sie unter einer Kachel steht. */
|
||||
export function TagRow({ tags, max = 3 }: { tags: Tag[]; max?: number }) {
|
||||
if (tags.length === 0) return null
|
||||
const shown = tags.slice(0, max)
|
||||
const rest = tags.length - shown.length
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', gap: 4, minWidth: 0,
|
||||
// Kein Umbruch: In einer Kachelzeile ist eine zweite Zeile genau das,
|
||||
// was hier vermieden werden soll. Was nicht passt, wird zu „+n".
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{shown.map(t => <TagChip key={t.id} tag={t} size="sm" />)}
|
||||
{rest > 0 && (
|
||||
<span style={{ fontSize: 10, color: 'var(--color-text-muted)', alignSelf: 'center' }}>
|
||||
+{rest}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { useState } from 'react'
|
||||
import { tagsApi, correspondentsApi, notifyTagsChanged, type Correspondent, type Tag } from '../api/client'
|
||||
import { tagPalette, tagColor } from './TagChip'
|
||||
|
||||
/**
|
||||
* Ein Etikett umbenennen, einfärben oder löschen.
|
||||
*
|
||||
* Erreichbar über Rechtsklick in der Seitenleiste – dort, wo die Etiketten
|
||||
* stehen. Ein eigener Verwaltungsbereich im Admin-Panel wäre der falsche Ort:
|
||||
* Etiketten gehören dem Einzelnen, nicht der Anlage.
|
||||
*/
|
||||
export default function TagEditDialog({ tag, onClose, onChanged }: {
|
||||
tag: Tag
|
||||
onClose: () => void
|
||||
onChanged: (tag: Tag) => void
|
||||
}) {
|
||||
const [name, setName] = useState(tag.name)
|
||||
const [color, setColor] = useState(tag.color || tagColor(tag))
|
||||
const [icon, setIcon] = useState(tag.icon ?? '')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function save() {
|
||||
if (!name.trim()) { setError('Name must not be empty'); return }
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
const updated = await tagsApi.update(tag.id, name.trim(), color, icon)
|
||||
notifyTagsChanged()
|
||||
onChanged(updated)
|
||||
onClose()
|
||||
} catch {
|
||||
setError('That name is already taken')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 220, padding: 20,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
width: 380, maxWidth: '100%',
|
||||
background: 'var(--color-surface)', border: '1px solid var(--color-border)',
|
||||
borderRadius: 14, padding: 22, boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: '0 0 18px', fontSize: 15, fontWeight: 700, color: 'var(--color-text)' }}>
|
||||
Edit tag
|
||||
</h3>
|
||||
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 4 }}>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
value={name}
|
||||
autoFocus
|
||||
onChange={e => setName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') void save() }}
|
||||
style={{
|
||||
width: '100%', padding: '8px 11px', borderRadius: 8, fontSize: 13, marginBottom: 18,
|
||||
border: '1px solid var(--color-border)', background: 'var(--color-surface-2)',
|
||||
color: 'var(--color-text)', outline: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 8 }}>
|
||||
Colour
|
||||
</label>
|
||||
<ColourPicker value={color} onChange={setColor} />
|
||||
|
||||
<div style={{ marginTop: 16, marginBottom: 4, fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)' }}>
|
||||
Icon
|
||||
</div>
|
||||
<IconPicker value={icon} onChange={setIcon} />
|
||||
|
||||
{/* Vorschau: Die Farbe wirkt am Aufkleber anders als am nackten Punkt,
|
||||
weil sie dort nur als Rahmen und Hintergrundhauch auftaucht. */}
|
||||
<div style={{ marginTop: 14, fontSize: 11, color: 'var(--color-text-muted)' }}>
|
||||
Preview:{' '}
|
||||
<span style={{
|
||||
display: 'inline-flex', padding: '3px 9px', borderRadius: 999,
|
||||
fontSize: 12, fontWeight: 600, color,
|
||||
background: `${color}22`, border: `1px solid ${color}55`,
|
||||
}}>
|
||||
{icon && <span style={{ marginRight: 5 }}>{icon}</span>}
|
||||
{name.trim() || 'tag'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && <p style={{ margin: '14px 0 0', fontSize: 12, color: 'var(--color-danger)' }}>{error}</p>}
|
||||
|
||||
{/* Löschen sitzt im Menü davor, nicht hier: Ein Dialog, in dem
|
||||
„Save" und „Delete" nebeneinander liegen, lädt zum Fehlgriff ein. */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 24, gap: 10 }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '8px 16px', border: 'none', background: 'none', cursor: 'pointer',
|
||||
fontSize: 13, color: 'var(--color-text-muted)',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void save()}
|
||||
disabled={busy}
|
||||
style={{
|
||||
padding: '8px 18px', borderRadius: 8, border: 'none', cursor: 'pointer',
|
||||
fontSize: 13, fontWeight: 600, color: '#fff',
|
||||
background: 'var(--color-primary)', opacity: busy ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Farbwahl aus einer festen Reihe. */
|
||||
/**
|
||||
* Die angebotenen Symbole.
|
||||
*
|
||||
* Bewusst eine **kurze, geordnete Auswahl** statt der vollen Emoji-Tastatur:
|
||||
* Bei 3000 Möglichkeiten wählt man beim zweiten Etikett ein anderes Bild für
|
||||
* dieselbe Sache. Zwanzig Vorschläge decken ab, wofür Menschen Dokumente
|
||||
* ablegen – wer etwas anderes will, tippt es ins Feld daneben.
|
||||
*/
|
||||
export const iconChoices = [
|
||||
'🧾', '💶', '🎓', '📚', '🏛️', '⚡', '🏠', '🚗', '🏥', '🛒',
|
||||
'📦', '✈️', '🔧', '💼', '📱', '🐾', '🎁', '📄', '⭐', '🔒',
|
||||
]
|
||||
|
||||
/**
|
||||
* Symbolwahl. „Kein Symbol" steht als erste Möglichkeit dabei – sonst wäre
|
||||
* ein einmal gewähltes Emoji nicht mehr loszuwerden.
|
||||
*/
|
||||
export function IconPicker({ value, onChange }: { value: string; onChange: (i: string) => void }) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
<button
|
||||
onClick={() => onChange('')}
|
||||
title="No icon"
|
||||
style={{
|
||||
width: 30, height: 30, borderRadius: 8, cursor: 'pointer',
|
||||
border: `2px solid ${value === '' ? 'var(--color-text)' : 'var(--color-border)'}`,
|
||||
background: 'var(--color-surface-2)', color: 'var(--color-text-muted)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<line x1="5" y1="19" x2="19" y2="5" />
|
||||
</svg>
|
||||
</button>
|
||||
{iconChoices.map(ic => (
|
||||
<button
|
||||
key={ic}
|
||||
onClick={() => onChange(ic)}
|
||||
style={{
|
||||
width: 30, height: 30, borderRadius: 8, cursor: 'pointer', fontSize: 15,
|
||||
border: `2px solid ${value === ic ? 'var(--color-text)' : 'transparent'}`,
|
||||
background: 'var(--color-surface-2)', padding: 0, lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{ic}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Freies Feld für alles, was nicht in der Auswahl steht. Zwei Wege zum
|
||||
selben Ziel sind hier richtig: Die Auswahl ist schnell, das Feld ist
|
||||
vollständig. */}
|
||||
<input
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder="or paste any emoji"
|
||||
maxLength={16}
|
||||
style={{
|
||||
marginTop: 8, width: 150, padding: '5px 9px', borderRadius: 8, fontSize: 13,
|
||||
border: '1px solid var(--color-border)', background: 'var(--color-surface-2)',
|
||||
color: 'var(--color-text)', outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ColourPicker({ value, onChange }: { value: string; onChange: (c: string) => void }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{tagPalette.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => onChange(c)}
|
||||
title={c}
|
||||
style={{
|
||||
width: 26, height: 26, borderRadius: '50%', cursor: 'pointer',
|
||||
background: c,
|
||||
// Der gewählte Kreis bekommt einen Ring mit Abstand statt eines
|
||||
// Häkchens: Auf dunklen Farben wäre ein weißes Häkchen kaum zu
|
||||
// sehen, auf hellen ein schwarzes.
|
||||
border: value === c ? '2px solid var(--color-surface)' : '2px solid transparent',
|
||||
boxShadow: value === c ? `0 0 0 2px ${c}` : 'none',
|
||||
transition: 'box-shadow 0.12s',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Einen Absender umbenennen.
|
||||
*
|
||||
* Weniger als beim Etikett – ein Absender hat keine Farbe. Umbenennen ist
|
||||
* dafür wichtiger als dort: Aus „Amazon" wird „Amazon EU S.à r.l.", und alle
|
||||
* bisherigen Rechnungen sollen mitkommen. Genau das leistet ein eigenes Feld
|
||||
* gegenüber einem in den Dateinamen geschriebenen Absender.
|
||||
*/
|
||||
export function CorrespondentRenameDialog({ correspondent, onClose, onChanged }: {
|
||||
correspondent: Correspondent
|
||||
onClose: () => void
|
||||
onChanged: (c: Correspondent) => void
|
||||
}) {
|
||||
const [name, setName] = useState(correspondent.name)
|
||||
const [icon, setIcon] = useState(correspondent.icon ?? '')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function save() {
|
||||
if (!name.trim()) { setError('Name must not be empty'); return }
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
const updated = await correspondentsApi.update(correspondent.id, name.trim(), icon)
|
||||
notifyTagsChanged()
|
||||
onChanged(updated)
|
||||
onClose()
|
||||
} catch {
|
||||
setError('That name is already taken')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 300, padding: 20,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
width: 360, maxWidth: '100%', background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)', borderRadius: 14, padding: 22,
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: '0 0 16px', fontSize: 15, fontWeight: 700, color: 'var(--color-text)' }}>
|
||||
Edit sender
|
||||
</h3>
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') void save() }}
|
||||
autoFocus
|
||||
maxLength={80}
|
||||
style={{
|
||||
width: '100%', padding: '8px 11px', borderRadius: 8, fontSize: 13,
|
||||
border: '1px solid var(--color-border)', background: 'var(--color-surface-2)',
|
||||
color: 'var(--color-text)', outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginTop: 16, marginBottom: 4, fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)' }}>
|
||||
Icon
|
||||
</div>
|
||||
<IconPicker value={icon} onChange={setIcon} />
|
||||
|
||||
<p style={{ margin: '10px 0 0', fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
||||
{correspondent.file_count > 0
|
||||
? `${correspondent.file_count} ${correspondent.file_count === 1 ? 'document keeps' : 'documents keep'} this sender under the new name.`
|
||||
: 'Nothing is from this sender yet.'}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<p style={{ margin: '12px 0 0', fontSize: 12, color: 'var(--color-danger)' }}>{error}</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10, marginTop: 20 }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '8px 16px', border: 'none', background: 'none', cursor: 'pointer',
|
||||
fontSize: 13, color: 'var(--color-text-muted)',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void save()}
|
||||
disabled={busy}
|
||||
style={{
|
||||
padding: '8px 18px', borderRadius: 8, border: 'none', cursor: 'pointer',
|
||||
fontSize: 13, fontWeight: 600, color: '#fff',
|
||||
background: 'var(--color-primary)', opacity: busy ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
interface UploadItem {
|
||||
id: string
|
||||
name: string
|
||||
progress: number // 0-100
|
||||
status: 'uploading' | 'done' | 'error'
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: UploadItem[]
|
||||
onDismiss: (id: string) => void
|
||||
}
|
||||
|
||||
export type { UploadItem }
|
||||
|
||||
export default function UploadQueue({ items, onDismiss }: Props) {
|
||||
if (items.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 w-72 space-y-2 z-40">
|
||||
{items.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded-xl p-3 text-sm shadow-lg border"
|
||||
style={{ background: 'var(--color-surface)', borderColor: 'var(--color-border)' }}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="truncate font-medium text-xs" style={{ color: 'var(--color-text)', maxWidth: 180 }}>
|
||||
{item.name}
|
||||
</span>
|
||||
{item.status !== 'uploading' && (
|
||||
<button onClick={() => onDismiss(item.id)}
|
||||
className="ml-2 opacity-50 hover:opacity-100 text-xs shrink-0"
|
||||
style={{ color: 'var(--color-text-muted)' }}>✕</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.status === 'uploading' && (
|
||||
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--color-surface-2)' }}>
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${item.progress}%`,
|
||||
background: 'linear-gradient(90deg, var(--color-primary), var(--color-accent))',
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
{item.status === 'done' && (
|
||||
<p style={{ color: 'var(--color-success)' }} className="text-xs">✓ Uploaded</p>
|
||||
)}
|
||||
{item.status === 'error' && (
|
||||
<p style={{ color: 'var(--color-danger)' }} className="text-xs truncate">✕ {item.error}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { createContext, useContext, useEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { User } from '../api/client'
|
||||
import type { View } from '../hooks/useNavHistory'
|
||||
|
||||
/**
|
||||
* Der Nutzerbereich – oben rechts in jeder Kopfleiste.
|
||||
*
|
||||
* Vorher saß er unten links in der Seitenleiste, mit dem Abmelden-Symbol
|
||||
* unmittelbar daneben. Zwei Dinge waren daran falsch:
|
||||
*
|
||||
* 1. **Abmelden lag einen Fehlklick entfernt** vom Knopf, der zu den
|
||||
* Einstellungen führt. Es ist die einzige Handlung in dieser Anwendung, die
|
||||
* man nicht rückgängig machen kann, ohne sich neu anzumelden – und sie war
|
||||
* die am leichtesten erreichbare.
|
||||
* 2. Oben rechts ist der Ort, an dem Menschen den eigenen Zugang suchen. Unten
|
||||
* links steht sonst das, was zum *Ort* gehört – der Speicherbalken bleibt
|
||||
* deshalb dort.
|
||||
*
|
||||
* Weitergereicht wird über einen Context, nicht über Eigenschaften: Sonst
|
||||
* müsste jede der sechs Seiten Nutzer, Abmelden und Umschalten durchreichen,
|
||||
* nur damit die Kopfleiste sie hat.
|
||||
*/
|
||||
interface UserMenuValue {
|
||||
user: User
|
||||
onNavigate: (view: View) => void
|
||||
onLogout: () => Promise<void>
|
||||
/** Öffnet die Seitenleiste – nur auf dem Handy sichtbar, siehe PageHeader. */
|
||||
onOpenSidebar?: () => void
|
||||
}
|
||||
|
||||
const Ctx = createContext<UserMenuValue | null>(null)
|
||||
|
||||
export function UserMenuProvider({ value, children }: { value: UserMenuValue; children: ReactNode }) {
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>
|
||||
}
|
||||
|
||||
/**
|
||||
* Den Schalter für die Seitenleiste holt sich die Kopfleiste hier ab.
|
||||
*
|
||||
* Über denselben Context wie der Nutzerbereich, aus demselben Grund: Sonst
|
||||
* müsste jede der sechs Seiten eine Funktion durchreichen, die sie selbst
|
||||
* nicht braucht.
|
||||
*/
|
||||
export function useSidebarToggle(): (() => void) | undefined {
|
||||
return useContext(Ctx)?.onOpenSidebar
|
||||
}
|
||||
|
||||
/** Anzeigename, sonst die Kennung. Nur *einer* von beiden wird gezeigt – zwei
|
||||
* Zeilen mit fast demselben Wort darunter sind keine Information. */
|
||||
export function displayNameOf(user: User): string {
|
||||
return user.display_name?.trim() || user.username
|
||||
}
|
||||
|
||||
export default function UserMenu() {
|
||||
const ctx = useContext(Ctx)
|
||||
const [open, setOpen] = useState(false)
|
||||
const boxRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Klick daneben und Escape schließen – wie bei der Suche und den Dialogen.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function onDown(e: MouseEvent) {
|
||||
if (!boxRef.current?.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Ohne Context (etwa auf der Freigabe-Seite ohne Anmeldung) gibt es nichts
|
||||
// zu zeigen.
|
||||
if (!ctx) return null
|
||||
const { user, onNavigate, onLogout } = ctx
|
||||
const name = displayNameOf(user)
|
||||
|
||||
return (
|
||||
<div ref={boxRef} style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
title={name}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
background: open ? 'var(--color-surface-2)' : 'transparent',
|
||||
border: 'none', borderRadius: 8, cursor: 'pointer',
|
||||
padding: '4px 8px 4px 4px', maxWidth: 180,
|
||||
}}
|
||||
onMouseEnter={e => { if (!open) e.currentTarget.style.background = 'var(--color-surface-2)' }}
|
||||
onMouseLeave={e => { if (!open) e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<Avatar name={name} size={26} />
|
||||
<span style={{
|
||||
fontSize: 13, fontWeight: 500, color: 'var(--color-text)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 'calc(100% + 6px)', right: 0,
|
||||
minWidth: 200,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 10, boxShadow: '0 12px 32px rgba(0,0,0,0.45)',
|
||||
zIndex: 60, padding: 4,
|
||||
}}>
|
||||
{/* Kopf des Menüs: hier ist Platz für beides – der Anzeigename oben,
|
||||
die Kennung als Nebenangabe. In der Leiste selbst steht nur der
|
||||
Anzeigename, dort wäre die zweite Zeile nur Doppelung. */}
|
||||
<div style={{ padding: '8px 10px 10px' }}>
|
||||
<div style={{
|
||||
fontSize: 13, fontWeight: 600, color: 'var(--color-text)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{name}
|
||||
</div>
|
||||
{user.display_name?.trim() && user.display_name.trim() !== user.username && (
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>{user.username}</div>
|
||||
)}
|
||||
{user.is_admin && (
|
||||
<div style={{ fontSize: 10, fontWeight: 600, color: 'var(--color-primary)', marginTop: 2 }}>
|
||||
Administrator
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Item label="Settings" onClick={() => { setOpen(false); onNavigate('profile') }} icon={
|
||||
<><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9v0a1.65 1.65 0 001.51 1H21a2 2 0 110 4h-.09a1.65 1.65 0 00-1.51 1z" /></>
|
||||
} />
|
||||
|
||||
{user.is_admin && (
|
||||
<Item label="Admin panel" onClick={() => { setOpen(false); onNavigate('admin') }} icon={
|
||||
<><path d="M12 3l8 4v5c0 4.4-3.4 8.3-8 9-4.6-.7-8-4.6-8-9V7l8-4z" /></>
|
||||
} />
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Abmelden zuletzt und mit Abstand – die einzige Handlung hier, die
|
||||
man nicht zurücknehmen kann. */}
|
||||
<Item label="Sign out" danger onClick={() => { setOpen(false); void onLogout() }} icon={
|
||||
<><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" /><polyline points="16 17 21 12 16 7" /><line x1="21" y1="12" x2="9" y2="12" /></>
|
||||
} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Der farbige Kreis mit dem ersten Buchstaben. */
|
||||
export function Avatar({ name, size = 26 }: { name: string; size?: number }) {
|
||||
return (
|
||||
<span style={{
|
||||
width: size, height: size, borderRadius: '50%',
|
||||
background: 'var(--color-primary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: Math.round(size * 0.45), fontWeight: 700, color: '#fff', flexShrink: 0,
|
||||
}}>
|
||||
{(name[0] ?? '?').toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return <div style={{ height: 1, background: 'var(--color-border)', margin: '4px 0' }} />
|
||||
}
|
||||
|
||||
function Item({ label, icon, onClick, danger }: {
|
||||
label: string
|
||||
icon: ReactNode
|
||||
onClick: () => void
|
||||
danger?: boolean
|
||||
}) {
|
||||
const color = danger ? 'var(--color-danger)' : 'var(--color-text)'
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, width: '100%',
|
||||
padding: '7px 10px', borderRadius: 7, border: 'none', background: 'transparent',
|
||||
cursor: 'pointer', textAlign: 'left', fontSize: 13, color,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--color-surface-2)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ flexShrink: 0, opacity: danger ? 1 : 0.7 }}>
|
||||
{icon}
|
||||
</svg>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { versionsApi, type FileItem, type FileVersion } from '../api/client'
|
||||
import { formatBytes } from '../pages/FileBrowser'
|
||||
import { useOverlayDismiss } from '../hooks/useOverlayDismiss'
|
||||
|
||||
interface Props {
|
||||
file: FileItem
|
||||
onClose: () => void
|
||||
onRestored: (updated: FileItem) => void
|
||||
}
|
||||
|
||||
// Muss zu maxVersionsKept im Backend passen (internal/files/handler.go).
|
||||
const MAX_VERSIONS_KEPT = 5
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
day: '2-digit', month: 'short', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export default function VersionsDialog({ file, onClose, onRestored }: Props) {
|
||||
const [versions, setVersions] = useState<FileVersion[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [restoring, setRestoring] = useState<string | null>(null)
|
||||
const overlay = useOverlayDismiss(onClose)
|
||||
|
||||
useEffect(() => {
|
||||
versionsApi.list(file.id)
|
||||
.then(data => setVersions(data ?? []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [file.id])
|
||||
|
||||
async function restore(v: FileVersion) {
|
||||
setRestoring(v.id)
|
||||
try {
|
||||
const updated = await versionsApi.restore(file.id, v.id)
|
||||
onRestored(updated)
|
||||
onClose()
|
||||
} catch {
|
||||
setRestoring(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 200,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 16,
|
||||
}} {...overlay}>
|
||||
<div style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 16,
|
||||
width: 520, maxWidth: '100%', maxHeight: '80vh',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
}}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
Version history
|
||||
</h3>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
{file.name}
|
||||
</p>
|
||||
<p style={{ margin: '4px 0 0', fontSize: 11, color: 'var(--color-text-muted)' }}>
|
||||
{file.versioning_enabled
|
||||
? `Versioning on – the last ${MAX_VERSIONS_KEPT} revisions are kept`
|
||||
: 'Versioning off – overwriting replaces the file with no history'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', padding: 4, borderRadius: 6 }}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Current version */}
|
||||
<div style={{
|
||||
padding: '12px 20px',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
background: 'rgba(var(--color-primary-rgb), 0.05)',
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: 8,
|
||||
background: 'var(--color-primary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 11, fontWeight: 700, color: '#fff', flexShrink: 0,
|
||||
}}>
|
||||
v{file.version}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ margin: 0, fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
Current version
|
||||
</p>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 11, color: 'var(--color-text-muted)' }}>
|
||||
{formatBytes(file.size)} · {formatDate(file.updated_at)}
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href={`/api/v1/files/${file.id}/download`}
|
||||
download={file.name}
|
||||
style={{
|
||||
padding: '5px 12px', borderRadius: 6,
|
||||
border: '1px solid var(--color-border)',
|
||||
color: 'var(--color-text)', fontSize: 12,
|
||||
textDecoration: 'none', whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Older versions */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: 16 }}>
|
||||
{loading && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 32 }}>
|
||||
<div style={{ width: 20, height: 20, border: '2px solid var(--color-primary)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />
|
||||
<style>{`@keyframes spin{to{transform:rotate(360deg)}}`}</style>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && versions.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: 32, color: 'var(--color-text-muted)', fontSize: 13 }}>
|
||||
No older versions yet.
|
||||
<br />
|
||||
<span style={{ fontSize: 11, marginTop: 4, display: 'block' }}>
|
||||
Upload a file with the same name to create a version.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && versions.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{versions.map(v => (
|
||||
<div key={v.id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
padding: '10px 12px', borderRadius: 10,
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-surface)',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: 8,
|
||||
background: 'var(--color-surface-2)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', flexShrink: 0,
|
||||
}}>
|
||||
v{v.version}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ margin: 0, fontSize: 12, color: 'var(--color-text)' }}>
|
||||
{formatDate(v.created_at)}
|
||||
</p>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 11, color: 'var(--color-text-muted)' }}>
|
||||
{formatBytes(v.size)}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
||||
<a
|
||||
href={versionsApi.download(file.id, v.id)}
|
||||
download={file.name}
|
||||
style={{
|
||||
padding: '4px 10px', borderRadius: 6,
|
||||
border: '1px solid var(--color-border)',
|
||||
color: 'var(--color-text)', fontSize: 11,
|
||||
textDecoration: 'none', whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
<button
|
||||
onClick={() => restore(v)}
|
||||
disabled={restoring === v.id}
|
||||
style={{
|
||||
padding: '4px 10px', borderRadius: 6,
|
||||
border: '1px solid var(--color-primary)',
|
||||
background: 'transparent', color: 'var(--color-primary)',
|
||||
fontSize: 11, cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
opacity: restoring === v.id ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{restoring === v.id ? 'Restoring…' : 'Restore'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
padding: '10px 20px',
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
fontSize: 11, color: 'var(--color-text-muted)',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
Up to {MAX_VERSIONS_KEPT} revisions are kept
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Dateiarten an einer Stelle. Symbol, Farbe und Cover leiteten ihre
|
||||
* Entscheidung vorher jeweils selbst aus dem MIME-Typ ab – dabei laufen die
|
||||
* drei mit der Zeit auseinander, und eine MP3 bekommt ein Symbol für Ton, aber
|
||||
* die Farbe für „unbekannt".
|
||||
*/
|
||||
export type FileKind =
|
||||
| 'image' | 'video' | 'audio' | 'pdf' | 'archive'
|
||||
| 'sheet' | 'slides' | 'doc' | 'code' | 'text' | 'other'
|
||||
|
||||
/** Endungen, die der MIME-Typ oft nicht verrät. Ein `.tar.gz` kommt je nach
|
||||
* Betreuer als application/gzip, application/x-tar oder octet-stream an. */
|
||||
const archiveExt = ['zip', 'tar', 'gz', 'tgz', 'bz2', 'xz', 'rar', '7z', 'zst', 'deb', 'rpm']
|
||||
const codeExt = [
|
||||
'js', 'ts', 'tsx', 'jsx', 'go', 'py', 'rs', 'rb', 'php', 'java', 'c', 'h', 'cpp',
|
||||
'sh', 'bash', 'sql', 'json', 'yaml', 'yml', 'toml', 'xml', 'html', 'css', 'ini', 'conf',
|
||||
]
|
||||
const sheetExt = ['csv', 'tsv', 'xls', 'xlsx', 'ods', 'numbers']
|
||||
const slidesExt = ['ppt', 'pptx', 'odp', 'key']
|
||||
/** Nur die Textverarbeitung. Markdown und .txt gehören zu 'text' – sie sind
|
||||
* lesbarer Text, kein Office-Format, und lassen sich hier sogar bearbeiten. */
|
||||
const docExt = ['doc', 'docx', 'odt', 'rtf', 'pages']
|
||||
const textExt = ['md', 'txt', 'log', 'rst']
|
||||
|
||||
export function extensionOf(name: string): string {
|
||||
const dot = name.lastIndexOf('.')
|
||||
if (dot <= 0 || dot === name.length - 1) return ''
|
||||
// Bei .tar.gz ist die zweite Endung die aussagekräftige.
|
||||
const ext = name.slice(dot + 1).toLowerCase()
|
||||
if (ext === 'gz' && name.toLowerCase().endsWith('.tar.gz')) return 'tar.gz'
|
||||
return ext
|
||||
}
|
||||
|
||||
export function fileKind(mime: string, name = ''): FileKind {
|
||||
const m = (mime || '').toLowerCase()
|
||||
const ext = extensionOf(name).replace('tar.', '')
|
||||
|
||||
if (m.startsWith('image/')) return 'image'
|
||||
if (m.startsWith('video/')) return 'video'
|
||||
if (m.startsWith('audio/')) return 'audio'
|
||||
if (m.includes('pdf')) return 'pdf'
|
||||
|
||||
if (archiveExt.includes(ext) ||
|
||||
m.includes('zip') || m.includes('tar') || m.includes('gzip') ||
|
||||
m.includes('compressed') || m.includes('x-rar') || m.includes('7z')) return 'archive'
|
||||
|
||||
if (sheetExt.includes(ext) ||
|
||||
m.includes('spreadsheet') || m.includes('excel') || m.includes('csv')) return 'sheet'
|
||||
if (slidesExt.includes(ext) ||
|
||||
m.includes('presentation') || m.includes('powerpoint')) return 'slides'
|
||||
if (textExt.includes(ext)) return 'text'
|
||||
if (codeExt.includes(ext)) return 'code'
|
||||
if (docExt.includes(ext) ||
|
||||
m.includes('msword') || m.includes('wordprocessing') ||
|
||||
m.includes('opendocument.text') || m.includes('rtf')) return 'doc'
|
||||
if (m.startsWith('text/')) return 'text'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
/** Je Art eine Farbe. Sie trägt das Symbol, den Rahmen und den Verlauf des
|
||||
* Covers – so ist eine Datei schon an der Farbe einzuordnen. */
|
||||
export const kindColors: Record<FileKind, string> = {
|
||||
image: '#8b5cf6',
|
||||
video: '#ec4899',
|
||||
audio: '#06b6d4',
|
||||
pdf: '#ef4444',
|
||||
archive: '#f59e0b',
|
||||
sheet: '#22c55e',
|
||||
// Kräftiges Orangerot – deutlich genug vom Bernstein der Archive und vom
|
||||
// Rot der PDFs entfernt, damit die drei nebeneinander unterscheidbar sind.
|
||||
slides: '#ea580c',
|
||||
doc: '#3b82f6',
|
||||
code: '#14b8a6',
|
||||
text: '#94a3b8',
|
||||
other: '#6366f1',
|
||||
}
|
||||
|
||||
export function kindColor(mime: string, name = ''): string {
|
||||
return kindColors[fileKind(mime, name)]
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Einheitlicher Icon-Satz für Menüs und Werkzeugleisten.
|
||||
*
|
||||
* Bewusst Striche statt Emoji: Emoji werden von jedem Betriebssystem anders
|
||||
* gezeichnet, haben eigene Farben und optische Größen – nebeneinander wirkt
|
||||
* eine Liste davon unruhig. Diese hier erben Farbe (`currentColor`) und Größe
|
||||
* vom Aufrufer und sitzen alle auf demselben Raster.
|
||||
*/
|
||||
import { fileKind, kindColor, kindColors } from './filetypes'
|
||||
|
||||
interface IconProps {
|
||||
size?: number
|
||||
}
|
||||
|
||||
const base = (size: number) => ({
|
||||
width: size,
|
||||
height: size,
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 1.75,
|
||||
strokeLinecap: 'round' as const,
|
||||
strokeLinejoin: 'round' as const,
|
||||
style: { flexShrink: 0 },
|
||||
})
|
||||
|
||||
export const DownloadIcon = ({ size = 15 }: IconProps) => (
|
||||
<svg {...base(size)}>
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const ShareIcon = ({ size = 15 }: IconProps) => (
|
||||
<svg {...base(size)}>
|
||||
<circle cx="18" cy="5" r="3" />
|
||||
<circle cx="6" cy="12" r="3" />
|
||||
<circle cx="18" cy="19" r="3" />
|
||||
<line x1="8.6" y1="10.5" x2="15.4" y2="6.5" />
|
||||
<line x1="8.6" y1="13.5" x2="15.4" y2="17.5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const PeopleIcon = ({ size = 15 }: IconProps) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="9" cy="8" r="3.5" />
|
||||
<path d="M2 21v-1a7 7 0 0114 0v1" />
|
||||
<path d="M17 11a3 3 0 100-6M19 21v-1a6 6 0 00-3-5.2" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const InfoIcon = ({ size = 15 }: IconProps) => (
|
||||
<svg {...base(size)}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 11v5" />
|
||||
<path d="M12 8h.01" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const HistoryIcon = ({ size = 15 }: IconProps) => (
|
||||
<svg {...base(size)}>
|
||||
<path d="M3 3v5h5" />
|
||||
<path d="M3.05 13A9 9 0 106 5.3L3 8" />
|
||||
<path d="M12 7v5l4 2" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const RenameIcon = ({ size = 15 }: IconProps) => (
|
||||
<svg {...base(size)}>
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.12 2.12 0 013 3L7 19l-4 1 1-4z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const MoveIcon = ({ size = 15 }: IconProps) => (
|
||||
<svg {...base(size)}>
|
||||
<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
|
||||
<polyline points="12 11 15 14 12 17" />
|
||||
<line x1="9" y1="14" x2="15" y2="14" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const TrashIcon = ({ size = 15 }: IconProps) => (
|
||||
<svg {...base(size)}>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14H6L5 6M10 11v6M14 11v6M9 6V4h6v2" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const FolderOpenIcon = ({ size = 15 }: IconProps) => (
|
||||
<svg {...base(size)}>
|
||||
<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v1H3z" />
|
||||
<path d="M3 10h18l-2 8a2 2 0 01-2 1.6H5A2 2 0 013 18z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Gefüllt, wenn der Ordner bereits Favorit ist. */
|
||||
export const StarIcon = ({ size = 15, filled = false }: IconProps & { filled?: boolean }) => (
|
||||
<svg {...base(size)} fill={filled ? 'currentColor' : 'none'}>
|
||||
<polygon points="12 2.8 15 9 21.6 9.8 16.8 14.3 18.1 21 12 17.8 5.9 21 7.2 14.3 2.4 9.8 9 9" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const CheckIcon = ({ size = 15 }: IconProps) => (
|
||||
<svg {...base(size)}>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// ── Dateitypen ───────────────────────────────────────────────────────────────
|
||||
// Lagen bis 22.08.2026 in FileBrowser.tsx. Hierher gezogen, als die Suche sie
|
||||
// ebenfalls brauchte – eine Komponente sollte nichts aus einer Seite
|
||||
// importieren. Welche Art eine Datei hat, entscheidet filetypes.ts.
|
||||
|
||||
export function getMimeColor(mime: string, name = ''): string {
|
||||
return kindColor(mime, name)
|
||||
}
|
||||
|
||||
export function FileTypeIcon({ mime, name = '', size = 20 }: { mime: string; name?: string; size?: number }) {
|
||||
const kind = fileKind(mime, name)
|
||||
const color = kindColors[kind]
|
||||
const svg = (children: React.ReactNode, filled = false) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24"
|
||||
fill={filled ? color : 'none'} stroke={filled ? 'none' : color} strokeWidth="1.5"
|
||||
strokeLinecap="round" strokeLinejoin="round">
|
||||
{children}
|
||||
</svg>
|
||||
)
|
||||
|
||||
switch (kind) {
|
||||
case 'image':
|
||||
return svg(<>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</>)
|
||||
case 'video':
|
||||
return svg(<>
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||
<polygon points="10 9 15 12 10 15" fill={color} stroke="none" />
|
||||
<line x1="6" y1="4" x2="6" y2="20" />
|
||||
<line x1="18" y1="4" x2="18" y2="20" />
|
||||
</>)
|
||||
case 'audio':
|
||||
return svg(<>
|
||||
<path d="M9 18V6l10-2v12" />
|
||||
<circle cx="6.5" cy="18" r="2.5" />
|
||||
<circle cx="16.5" cy="16" r="2.5" />
|
||||
</>)
|
||||
case 'pdf':
|
||||
// Dieselbe Blattform wie beim Dokument, nur in Rot. Ein eigener
|
||||
// gefuellter Block war der Versuch, beide staerker zu trennen – dafuer
|
||||
// reichen Farbe und der Aufkleber mit der Endung.
|
||||
return svg(<>
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<line x1="10" y1="9" x2="8" y2="9" />
|
||||
</>)
|
||||
case 'archive':
|
||||
// Kiste mit Verschluss – ein Ordnersymbol wäre hier irreführend.
|
||||
return svg(<>
|
||||
<path d="M3 7l2-3h14l2 3v13a1 1 0 01-1 1H4a1 1 0 01-1-1z" />
|
||||
<line x1="3" y1="7" x2="21" y2="7" />
|
||||
<path d="M10 11h4" />
|
||||
<path d="M10 14h4" />
|
||||
</>)
|
||||
case 'sheet':
|
||||
// Tabellenraster mit hervorgehobener Kopfzeile – so liest man es sofort
|
||||
// als Tabelle und nicht als Fenster.
|
||||
return svg(<>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<path d="M3 8.5h18" />
|
||||
<line x1="3" y1="14" x2="21" y2="14" />
|
||||
<line x1="9.5" y1="8.5" x2="9.5" y2="21" />
|
||||
</>)
|
||||
case 'slides':
|
||||
// Leinwand auf Fuß mit einem Balkendiagramm – das Bild, das jeder mit
|
||||
// einer Präsentation verbindet.
|
||||
return svg(<>
|
||||
<rect x="3" y="3" width="18" height="13" rx="2" />
|
||||
<path d="M12 16v3" />
|
||||
<path d="M8.5 21h7" />
|
||||
<path d="M7.5 12.5v-2M11 12.5V8M14.5 12.5v-3.5" />
|
||||
</>)
|
||||
case 'code':
|
||||
return svg(<>
|
||||
<polyline points="16 18 22 12 16 6" />
|
||||
<polyline points="8 6 2 12 8 18" />
|
||||
</>)
|
||||
case 'doc':
|
||||
case 'text':
|
||||
return svg(<>
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<line x1="10" y1="9" x2="8" y2="9" />
|
||||
</>)
|
||||
default:
|
||||
return svg(<>
|
||||
<path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z" />
|
||||
<polyline points="13 2 13 9 20 9" />
|
||||
</>)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Ordner – eine Definition für Kachel, Liste und Suchtreffer. Lag vorher
|
||||
* dreimal im Code und lief prompt auseinander.
|
||||
*
|
||||
* Ganzzahlige Koordinaten auf dem 48er-Raster, und die Anzeigegröße ist ein
|
||||
* Vielfaches davon: So landet jede Kante auf einem ganzen Pixel statt zwischen
|
||||
* zweien, wo die Kantenglättung sie weichzeichnet.
|
||||
*
|
||||
* Die vordere Lage deckt voll, nur die hintere ist durchscheinend. Beide
|
||||
* durchscheinend zu machen, sah auf dem dunklen Grund blass aus.
|
||||
*/
|
||||
export function FolderGlyph({ size = 48, color = '#f59e0b' }: { size?: number; color?: string }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 48 48" fill="none">
|
||||
{/* Hintere Lage mit Reiter – schaut oben links hervor */}
|
||||
<path d="M5 13a3 3 0 013-3h8l4 4h17a3 3 0 013 3v5H5z" fill={color} opacity="0.55" />
|
||||
{/* Vordere Tasche */}
|
||||
<rect x="5" y="15" width="38" height="26" rx="4" fill={color} />
|
||||
{/* Feine helle Oberkante, sonst wirkt die Tasche wie ein flacher Klecks */}
|
||||
<rect x="5" y="15" width="38" height="3" rx="1.5" fill="#fff" opacity="0.16" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Etikett – dieselbe Form wie in der Kopfleiste der Etikettenansicht. */
|
||||
export function TagIcon() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 7v6.2a2 2 0 00.6 1.4l6.8 6.8a2 2 0 002.8 0l6.2-6.2a2 2 0 000-2.8l-6.8-6.8A2 2 0 0011.2 5H5a2 2 0 00-2 2z" />
|
||||
<circle cx="7.5" cy="9.5" r="1.2" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Ein Blatt mit Stift – „im Office-Server bearbeiten". Bewusst nicht das
|
||||
* Symbol eines bestimmten Anbieters: Dahinter kann Collabora oder OnlyOffice
|
||||
* stecken, und das soll dem Menüpunkt nicht anzusehen sein. */
|
||||
export function OfficeIcon() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M13 3H7a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2v-6" />
|
||||
<path d="M18.5 2.5a2.1 2.1 0 013 3L15 12l-4 1 1-4z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* Textebene von PDF.js.
|
||||
*
|
||||
* Der Block unten ist **wörtlich** aus pdfjs-dist/web/pdf_viewer.css
|
||||
* übernommen, nur die Klasse heißt `.pdf-textLayer` statt `.textLayer`.
|
||||
*
|
||||
* Beim ersten Anlauf hatte ich ihn von Hand gekürzt – „wir zeigen nur Seiten
|
||||
* an, der Rest ist Werkzeugleiste". Dabei fielen drei Zeilen weg, die wie
|
||||
* Kleinkram aussahen und keiner waren:
|
||||
*
|
||||
* --min-font-size / --text-scale-factor / scale(var(--min-font-size-inv))
|
||||
*
|
||||
* PDF.js misst beim ersten Aufbau, wie klein der Browser Schrift überhaupt
|
||||
* noch darstellt (Chrome erzwingt eine Mindestgröße), setzt daraus
|
||||
* `--min-font-size` und rechnet sie mit der Skalierung wieder heraus. Ohne
|
||||
* dieses Paar wird jede kleine Schrift vom Browser aufgeblasen: Die
|
||||
* unsichtbaren Textstücke sitzen dann größer da als die gemalten Buchstaben
|
||||
* darunter – zu sehen erst beim **Markieren**, wo die Auswahl als grobe,
|
||||
* versetzte Kästen über dem Text lag statt auf ihm.
|
||||
*
|
||||
* Deshalb hier nichts mehr von Hand kürzen. Was die Datei mitbringt, gehört
|
||||
* zusammen; was wir zusätzlich wollen, steht darunter.
|
||||
*/
|
||||
|
||||
.pdf-textLayer{
|
||||
color-scheme:only light;
|
||||
|
||||
position:absolute;
|
||||
text-align:initial;
|
||||
inset:0;
|
||||
overflow:clip;
|
||||
opacity:1;
|
||||
line-height:1;
|
||||
letter-spacing:normal;
|
||||
word-spacing:normal;
|
||||
-webkit-text-size-adjust:none;
|
||||
-moz-text-size-adjust:none;
|
||||
text-size-adjust:none;
|
||||
forced-color-adjust:none;
|
||||
transform-origin:0 0;
|
||||
caret-color:CanvasText;
|
||||
z-index:0;
|
||||
|
||||
&.highlighting{
|
||||
touch-action:none;
|
||||
}
|
||||
|
||||
:is(span, br){
|
||||
color:transparent;
|
||||
position:absolute;
|
||||
white-space:pre;
|
||||
cursor:text;
|
||||
transform-origin:0% 0%;
|
||||
-webkit-user-select:text;
|
||||
-moz-user-select:text;
|
||||
user-select:text;
|
||||
}
|
||||
--min-font-size:1;
|
||||
--text-scale-factor:calc(var(--total-scale-factor) * var(--min-font-size));
|
||||
--min-font-size-inv:calc(1 / var(--min-font-size));
|
||||
|
||||
> :not(.markedContent),
|
||||
.markedContent span:not(.markedContent){
|
||||
z-index:1;
|
||||
|
||||
--font-height:0;
|
||||
font-size:calc(var(--text-scale-factor) * var(--font-height));
|
||||
|
||||
--scale-x:1;
|
||||
--rotate:0deg;
|
||||
transform:rotate(var(--rotate)) scaleX(var(--scale-x)) scale(var(--min-font-size-inv));
|
||||
}
|
||||
|
||||
.markedContent{
|
||||
display:contents;
|
||||
}
|
||||
|
||||
span[role="img"]{
|
||||
-webkit-user-select:none;
|
||||
-moz-user-select:none;
|
||||
user-select:none;
|
||||
cursor:default;
|
||||
}
|
||||
|
||||
.highlight{
|
||||
--highlight-bg-color:rgb(180 0 170 / 0.25);
|
||||
--highlight-selected-bg-color:rgb(0 100 0 / 0.25);
|
||||
--highlight-backdrop-filter:none;
|
||||
--highlight-selected-backdrop-filter:none;
|
||||
|
||||
@media screen and (forced-colors: active){
|
||||
--highlight-bg-color:transparent;
|
||||
--highlight-selected-bg-color:transparent;
|
||||
--highlight-backdrop-filter:var(--hcm-highlight-filter);
|
||||
--highlight-selected-backdrop-filter:var(
|
||||
--hcm-highlight-selected-filter
|
||||
);
|
||||
}
|
||||
|
||||
margin:-1px;
|
||||
padding:1px;
|
||||
background-color:var(--highlight-bg-color);
|
||||
backdrop-filter:var(--highlight-backdrop-filter);
|
||||
border-radius:4px;
|
||||
|
||||
&.appended{
|
||||
position:initial;
|
||||
}
|
||||
|
||||
&.begin{
|
||||
border-radius:4px 0 0 4px;
|
||||
}
|
||||
|
||||
&.end{
|
||||
border-radius:0 4px 4px 0;
|
||||
}
|
||||
|
||||
&.middle{
|
||||
border-radius:0;
|
||||
}
|
||||
|
||||
&.selected{
|
||||
background-color:var(--highlight-selected-bg-color);
|
||||
backdrop-filter:var(--highlight-selected-backdrop-filter);
|
||||
scroll-margin-top:50px;
|
||||
}
|
||||
}
|
||||
|
||||
::-moz-selection{
|
||||
background:rgba(0 0 255 / 0.25);
|
||||
background:color-mix(in srgb, AccentColor, transparent 50%);
|
||||
color:transparent;
|
||||
}
|
||||
|
||||
::selection{
|
||||
background:rgba(0 0 255 / 0.25);
|
||||
background:color-mix(in srgb, AccentColor, transparent 50%);
|
||||
color:transparent;
|
||||
}
|
||||
|
||||
&.selectionRendering{
|
||||
::-moz-selection{
|
||||
background:transparent;
|
||||
color:transparent;
|
||||
}
|
||||
::selection{
|
||||
background:transparent;
|
||||
color:transparent;
|
||||
}
|
||||
}
|
||||
br::-moz-selection{
|
||||
background:transparent;
|
||||
}
|
||||
br::selection{
|
||||
background:transparent;
|
||||
}
|
||||
|
||||
.endOfContent{
|
||||
display:block;
|
||||
position:absolute;
|
||||
inset:100% 0 0;
|
||||
z-index:0;
|
||||
cursor:default;
|
||||
-webkit-user-select:none;
|
||||
-moz-user-select:none;
|
||||
user-select:none;
|
||||
}
|
||||
|
||||
&.selecting .endOfContent{
|
||||
top:0;
|
||||
}
|
||||
}
|
||||
/*
|
||||
* Fundstellen der Suche.
|
||||
*
|
||||
* Eine eigene Ebene über der Textebene, kein <mark> im Text. Warum, steht
|
||||
* ausführlich in PdfViewer.tsx bei `paint` – kurz: Der Grund eines <mark>
|
||||
* ragte oben aus der Textebene heraus, und die schneidet ab (`overflow:
|
||||
* clip`). Das sah man als angeschnittene Streifen. Diese Ebene schneidet
|
||||
* nichts ab; die Kästen sind dieselben, die der Browser beim Markieren mit
|
||||
* der Maus malt.
|
||||
*
|
||||
* `pointer-events: none`, damit die Ebene das Markieren mit der Maus nicht
|
||||
* abfängt – darunter liegt die Schrift, die man auswählen können soll.
|
||||
*
|
||||
* Die Farbe bleibt durchsichtig: Der lesbare Text steht auf dem gemalten Bild
|
||||
* *darunter*. Mit 0,95 Deckkraft lag ein satter Block über dem Wort, und
|
||||
* ausgerechnet das gesuchte Wort war das einzige, das man nicht mehr lesen
|
||||
* konnte. Der Betrachter von PDF.js selbst arbeitet aus demselben Grund mit
|
||||
* 0,25.
|
||||
*/
|
||||
.pdf-hitLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.pdf-hit {
|
||||
position: absolute;
|
||||
background: rgba(245, 158, 11, 0.3);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.pdf-hit.pdf-hit-current {
|
||||
background: rgba(245, 158, 11, 0.55);
|
||||
/* Der aktuelle Treffer bekommt zusätzlich eine Kante statt noch mehr Farbe –
|
||||
Deckkraft würde wieder das Wort verschlucken. */
|
||||
box-shadow: 0 0 0 1px rgba(180, 83, 9, 0.75);
|
||||
}
|
||||
Reference in new issue
Block a user