Files
perinco-CRM/docs/05-frontend.md
T
schakal00783andClaude Opus 5 ad7f806258 feat(design): Huegel im Theme Aura schaukeln leicht
Die Seitenleiste im hellen Aura sah aus wie ein Standbild. Jetzt bewegt
@keyframes aura-hills die Grafik-Ebene in 26 s um +-9 px waagerecht und
4 px senkrecht - langsam genug, dass es beim Arbeiten nicht auffaellt.

Bewegt wird nur die zweite Hintergrundebene. Der Schleier davor bleibt
stehen, sonst wandert die Helligkeit unter den Menuepunkten. Die Grafik
ist unten verankert und beschnitten, deshalb reisst am Rand keine Luecke
auf. Bei "Bewegung reduzieren" bleibt sie stehen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 22:51:39 +02:00

18 KiB
Raw Blame History

05 Frontend

Eine einzige React-Anwendung ohne Router: App.tsx hält die aktuelle Ansicht in einem Zustand und rendert sie. Das genügt, weil die App hinter einer Anmeldung sitzt und keine tiefen Links braucht.

Menü und Ansichten

flowchart TD
    SB["Seitenleiste"] --> K["Kategorien"]
    K --> D["Dashboard<br/>(Startansicht, auch über das Logo)"]
    K --> AQ["Akquise"]
    AQ --> AQ1["Eingang<br/>Funde der letzten 14 Tage"]
    AQ --> AQ2["Archiv<br/>älter; nach 90 Tagen gelöscht"]
    AQ --> AQ3["Papierkorb<br/>von Hand gelöscht; 90 Tage"]
    AQ2 -. "Zurück in den Eingang<br/>(Funddatum = heute)" .-> AQ1
    AQ3 -. "Wiederherstellen<br/>(Funddatum bleibt)" .-> AQ2
    K --> O["Opportunities"]
    K --> C["Interviews"]
    K --> L["Kontakte"]
    L --> L1["Alle Kontakte"]
    L --> L2["Aus Mails<br/>Vorbereich, Zähler im Menü"]
    K --> M["Mails"]
    M --> M1["Postfach<br/>Reiter: Postfach, Kontakt-Vorschläge"]
    M --> M2["Papierkorb<br/>im CRM gelöschte Mails"]
    K --> SC["Scanner"]
    SC --> SC1["Dashboard"]
    SC --> SC2["Unpassend"]
    SC --> SC3["Suchagenten"]
    SC --> SC4["Börsen-Konten"]
    SB --> E["Einstellungen"]
    E --> E1["Nutzerverwaltung (admin)"]
    E --> E2["Mein Konto"]
    E --> E3["Postfach (admin)"]
    E --> E4["Scanner (admin)"]
    E --> E5["Whisper"]

Postfach und Kontakt-Vorschläge sind Reiter innerhalb der Ansicht Mails, keine Menüpunkte - sie gehören zusammen und wechseln oft hin und her.

Die Menüpunkte Kontakte und Scanner klappen ihre Unterpunkte auf; der Kopfeintrag ist dabei selbst eine Ansicht (Kontaktliste bzw. Scanner-Dashboard). Bei eingeklappter Leiste (Symbolbreite) erscheinen nur die Symbole, die Unterpunkte kommen mit den Beschriftungen zurück. Beide Gruppen entstehen aus derselben Struktur MenuGroup in Sidebar.tsx.

Aus Mails trägt einen Zähler mit der Anzahl der offenen Funde - er kommt aus App.tsx, das die Kontakte ohnehin geladen hat.

Zustand und Datenfluss

flowchart TB
    subgraph Kontexte["Kontexte in main.tsx"]
        TH["ThemeProvider<br/>hell/dunkel/System"]
        TO["ToastProvider"]
        AU["AuthProvider<br/>JWT, Benutzer"]
        WH["WhisperProvider<br/>Modell, Worker"]
    end

    APP["App.tsx<br/>currentView, settingsTab, focusLeadId"]
    NAV["NavigationProvider<br/>goTo(view, {tab, leadId})"]
    DATA["loadData()<br/>leads, connections, events, users, opportunities"]

    Kontexte --> APP
    APP --> NAV
    APP --> DATA
    DATA --> V1["Opportunities"]
    NOTE["Akquise lädt selbst<br/>acquisitionsApi, nicht über loadData()"]
    DATA --> V2["Interviews"]
    DATA --> V3["Kontakte<br/>mail_pool = false"]
    DATA --> V5["Aus Mails<br/>mail_pool = true + Kontakte zum Abgleich"]
    DATA --> V4["Mails<br/>leads + opportunities für den Zuordnungsdialog"]
    NAV --> DF["DictationField<br/>springt nach Whisper"]
    NAV --> ML["Mails<br/>springt zum Kontakt einer Zuordnung"]

    SCV["Scanner-Ansichten"] -->|"laden selbst"| API["/api/scanner"]
    MV["Mailansicht"] -->|"lädt selbst"| MAPI["/api/mail"]

Die CRM-Daten werden einmal zentral geladen und als Props durchgereicht; die Scanner- und Mailansichten laden ihre eigenen Daten selbst - der Scanner, weil er eine andere Datenbank und eine Auffrischung im 30-Sekunden-Takt hat; die Mails, weil sie blättern, suchen und filtern und dabei nicht die ganze Liste im Speicher halten sollen. Kontakte und Opportunities bekommt die Mailansicht dagegen als Props: der Zuordnungsdialog braucht sie beide zum Vorschlagen.

/api/leads liefert beide Bestände in einem Aufruf; App.tsx teilt sie in leads (Kontakte) und mailPoolLeads (Vorbereich „Aus Mails") auf. Alle anderen Ansichten bekommen ausschließlich leads - ein Fund aus einer Mail soll in keiner Auswahlliste auftauchen. Die Ansicht Aus Mails braucht beides: sie vergleicht jeden Fund gegen den Kontaktbestand und markiert Dubletten.

goTo('Kontakte', { leadId }) ist der Sprung aus einer Mailzuordnung zum Kontakt. App.tsx hält die Id in focusLeadId, die Kontaktansicht öffnet den Kontakt und meldet mit onFocusHandled() zurück - der Effekt hängt an leads und wartet damit auf einen gerade erst angelegten Kontakt.

reload={() => loadData(true)} lädt still nach - ohne Ladeanzeige, damit ein Speichern im Dialog nicht die ganze Liste flackern lässt.

Komponentenkarte

src/
  App.tsx                     Layout, Datenladen, Ansichtswechsel
  types.ts                    Gemeinsame Typen, Farbpalette, Rechenhilfen
  lib/
    api.ts                    Fetch-Wrapper (JWT, 401-Behandlung) + CRM-Endpunkte
    scannerApi.ts             Typisierte Endpunkte unter /api/scanner
    mailApi.ts                Endpunkte unter /api/mail, UTC-Datumshelfer
    auth.tsx                  Anmeldung, Benutzer, Rolle
    theme.tsx                 Hell/Dunkel/System
    navigation.tsx            Ansichtswechsel für tiefe Komponenten
    whisper.tsx               Modell, Worker-Lebenszyklus
    dictation.tsx             Aufnahme-Hook
    audio.ts                  Aufnahme zu 16-kHz-Mono-PCM
    imageCompress.ts          Bilder vor dem Upload verkleinern (auch das LinkedIn-Foto)
  components/
    Sidebar.tsx               Menü inkl. aufklappbarer Gruppen (Kontakte, Scanner)
    Header.tsx                Titel, Untertitel, Design-Auswahl, Hell/Dunkel-Schalter
    opportunities/            Kachel, Liste, Pipeline (Drag & Drop), Formular
    calendar/                 Monat/Woche/Tag, Termine (Drag & Drop), @-Mentions
    leads/                    Kachel, Liste, Netzwerkdiagramm (d3-force),
                              Vorbereich "Aus Mails" mit Dublettenabgleich
    mail/                     Postfachliste, Detail, Zuordnung, Kontakt-Vorschläge
    scanner/                  Dashboard, Unpassend, Suchagenten, Börsen-Konten
    settings/                 Reiter: Nutzerverwaltung, Konto, Postfach, Scanner, Whisper
    whisper/                  Einrichtung der Spracherkennung
    ui/                       Modal, Avatare, Diktatfeld

Themes ohne dark:-Varianten

Die Darstellung hat zwei unabhängige Achsen, beide als Klasse am <html>:

Achse Klasse Werte
Farbwelt theme-perinco, theme-pixel, theme-outdoor, theme-cyberpunk, theme-rainy, theme-snow, theme-aura perinco ist der Standard
Modus dark bzw. keine hell, dunkel, oder der Systemeinstellung folgen

src/index.css tauscht darin nur Variablenwerte: canvas, surface, ink-*, brand-*, die getönten Statusfarben, dazu --radius-*, --shadow-* und die Schriften. Die Komponenten benutzen durchgängig diese Skalen und folgen damit automatisch - kein dark: und keine themespezifische Klasse im Markup.

flowchart LR
    U["Benutzer wählt<br/>im Header"] --> DB["crm_users.theme<br/>crm_users.theme_mode"]
    U --> LS["localStorage<br/>nur Zwischenspeicher"]
    DB -->|"beim Anmelden"| H["Klassen am html<br/>theme-*, dark"]
    LS -->|"vor dem ersten Frame"| H
    H --> V["Variablen in index.css<br/>Farben, Radien, Schatten, Schrift"]
    V --> W["Utility-Klassen der Komponenten"]
    SYS["Systemeinstellung"] -.->|"ohne eigene Wahl"| H

Die Wahl gehört dem Benutzer, nicht dem Browser: sie steht in crm_users und kommt mit /api/auth/me. Wer sich an einem anderen Rechner anmeldet, findet sein Design wieder, und zwei Benutzer an derselben Maschine kommen sich nicht in die Quere. Der localStorage bleibt daneben als Zwischenspeicher liegen, damit das Inline-Skript in index.html die Klassen vor dem ersten Frame setzen kann - sonst blitzt beim Laden das Standarddesign auf. Das hat genau eine sichtbare Folge: melden sich zwei Leute an demselben Rechner an, sieht der zweite für einen Augenblick das Design des ersten.

Der ThemeProvider steht deshalb in main.tsx unter dem AuthProvider - er liest den Benutzer.

Hintergrundbilder in Leiste und Seitenleiste

Alle Themes außer perinco legen eine Grafik hinter die obere Leiste und die Seitenleiste: 8-bit-Himmel und Steinmauer, blauer Himmel und Wiese, Neonröhren und regnerische Gasse, Regenwolken und nasse Hügel, verschneite Berge und Waldrand. Die Dateien liegen als SVG in public/themes/ und sind von Hand gezeichnet - stilisierte Grafik statt Foto, damit sie in jeder Auflösung scharf bleibt und wenige Kilobyte wiegt.

Zwei Anker im Markup: app-header an <header> und app-sidebar an <aside>. Mehr braucht es nicht, den Rest machen zwei Variablen:

.app-header  { background-image: var(--theme-header-image, none);  background-size: cover; }
.app-sidebar { background-image: var(--theme-sidebar-image, none); background-position: bottom center; }

Ohne Theme steht in den Variablen nichts, der Rückfall none greift, und perinco bleibt einfarbig. bg-surface bleibt an beiden Elementen: die Farbe liegt unter dem Bild und trägt, solange es lädt oder fehlt.

Die Variable enthält zwei Ebenen - einen Schleier aus der Flächenfarbe des Themes und darunter die Grafik:

--theme-header-image: linear-gradient(90deg, rgb(26 32 22 / .95) 0%, rgb(26 32 22 / .6) 55%, …),
  url('/themes/outdoor-header.svg');

Das ist kein Umweg, sondern erledigt zwei Dinge zugleich:

  1. Eine Grafik für beide Modi. Der dunkle Schleier des Dunkelmodus tönt denselben blauen Himmel zur Nacht ab - es braucht keine zweite Datei.
  2. Lesbarkeit. In der oberen Leiste liegt der Schleier an den Rändern dicht (Titel links, Schalter rechts) und lässt die Grafik nur in der leeren Mitte durch; in der Seitenleiste deckt er oben über den Menüpunkten und gibt nach unten frei, wo die Leiste ohnehin leer ist.

Eine Lehre aus dem ersten Versuch: Die Cyberpunk-Grafik hatte zuerst eine deckende Nachtfläche. Im hellen Modus wurde daraus unter dem weissen Schleier ein grauer Schmutzstreifen. Seither ist sie durchsichtig und zeigt nur Licht - über Dunkel leuchtet das Neon, über Weiss bleiben blasse farbige Linien. Wer eine Grafik ergänzt: keine deckenden Flächen, nur Motiv.

Rainy, Snow und Aura: Themes mit Wetter

Rainy, Snow und Aura sind die Themes mit Bewegung. Hier zuerst Rainy, Snow und Aura folgen unten. Farben und Grafiken funktionieren wie bei den anderen; dazu kommt src/components/RainyWeather.tsx, das der ThemeProvider nur bei aktivem Rainy einhängt - auch auf der Anmeldeseite, und beim Wechsel auf ein anderes Theme verschwindet es samt Animationsschleife.

Effekt Umsetzung
Nieselregen Ein <canvas> über dem ganzen Fenster. Alle 220–650 ms startet ein Tropfen (höchstens 14 gleichzeitig), fällt leicht schräg und zerspritzt am unteren Fensterrand in 3–6 Spritzer mit Schwerkraft plus einen kleinen Ring. Farbe aus --rain-color
Wetterleuchten Ein Element, das beim Blitz exakt über .app-header gelegt und per Web Animations API kurz eingeblendet wird (flackernd, 0,9 s, alle 7–18 s). Das Licht ist ein Schein um eine zufällige Stelle in der Mitte der Leiste

Der Trick beim Blitz ist die Maske: .rainy-flash benutzt rainy-header.svg - dieselbe Datei wie das Hintergrundbild, gleich skaliert - als mask-image. Das Licht erscheint dadurch nur auf den Wolken selbst. Die Grafik darf deshalb nur Wolken enthalten, keinen Himmel: sonst leuchtet die ganze Leiste. Im Dunkelmodus hellt der Blitz per mix-blend-mode: screen auf, im hellen Modus deckt er normal mit warmem Weiss (--flash-blend, --flash-color) - screen macht aus hellem Grau nur etwas helleres Grau.

Beide Ebenen haben pointer-events: none und z-[90], liegen also unter den Dialogen (z-[100]). Bei prefers-reduced-motion: reduce startet weder Regen noch Blitz. Die Canvas-Schleife läuft über requestAnimationFrame und ruht damit in Hintergrund-Tabs; der Zeitschritt ist auf 50 ms begrenzt, damit nach der Rückkehr nichts nachgeholt wird.

Snow

src/components/SnowWeather.tsx, eingehängt vom ThemeProvider wie der Regen: ein <canvas> über dem Fenster (z-[90], pointer-events: none), bei prefers-reduced-motion still.

Teil Umsetzung
Flocken Alle 70–160 ms eine neue, höchstens 70 gleichzeitig. Radius 1–3 px, große fallen schneller, alle schaukeln seitlich (Sinus). Farbe --snow-flake, im hellen Modus mit Kontur --snow-flake-edge - weiss auf fast weiss wäre unsichtbar
Schneedecke Eine Höhenkarte in 3-px-Spalten über die Fensterbreite. Eine Flocke landet, sobald sie die Oberfläche ihrer Spalte erreicht, und verteilt sich auf fünf Spalten
Glätten Diffusion: jede Spalte gleicht sich an ihre Nachbarn an, Faktor an der Zeit (SPREAD), nicht an der Framezahl - gleich auf 60 und 144 Hz. Mengenerhaltend, aus Einzelflocken werden flache Hügel
Deckel MAX_DEPTH = 26 px, voll nach rund vier Minuten. Was danach landet, verschwindet
Räumfahrzeug Startet 2,5 s nach dem Laden, dann alle 4 min (PLOW_INTERVAL_MS), Richtung wechselt jedes Mal. Fährt mit 140 px/s von außerhalb des Fensters herein, setzt jede Spalte hinter der Schaufelkante (und unter dem Fahrzeug) auf null und sammelt die Menge in pushed. Daraus wächst der Wall vor der Schaufel (Wurzel der Menge, gedeckelt auf MAX_DEPTH + 8); dazu fliegen Brocken. Hinter dem Fensterrand verschwindet alles. Gezeichnet im Canvas, nach rechts und für die Rückfahrt per scale(-1, 1) gespiegelt

Vier Minuten sind abgestimmt: so lange braucht die Decke, bis sie voll ist.

Die Decke lebt nur im Speicher; beim Ändern der Fensterbreite wird sie auf die neue Spaltenzahl umgerechnet, nach dem Neuladen ist der Boden frei.

Erfahrungswert Wachstum: Das Flockenlimit bestimmt die Landerate, nicht das Spawn-Intervall - eine Flocke braucht gut 10 s durchs Fenster, bei 70 gleichzeitig landen also nur etwa 5 je Sekunde. Wer die Wachstumszeit ändert, rechnet mit dieser Zahl.

Aura

Ein Theme mit zwei Namen: hell heißt es Sunshine, dunkel Mond. Beides ist theme-aura, der Unterschied kommt wie immer aus .dark.

Teil Umsetzung
Leiste Vier Hintergrundebenen statt zwei: Sonne bzw. Mond (aura-sun.svg, aura-moon.svg, 130 px in der Ecke oben rechts, halb angeschnitten), Schleier, Sterne (aura-stars.svg, nur nachts) und ein Himmelsverlauf. Weil jede Ebene eigene Lage und Größe braucht, stehen die in --aura-header-position und --aura-header-size
Ziehende Wolken Reines CSS: .app-header::before mit zwei nahtlosen 800-px-Kacheln (aura-clouds-near.svg, aura-clouds-far.svg). @keyframes aura-clouds schiebt sie in 150 s um genau 1600 bzw. 800 px - Neustart ohne Ruck, vordere Lage doppelt so schnell. Nachts auf --aura-cloud-opacity 0,28 abgedunkelt
Schaukelnde Hügel Reines CSS, nur hell: @keyframes aura-hills schiebt in der Seitenleiste allein die Grafik-Ebene (aura-sidebar.svg) 26 s lang um ±9 px waagerecht und 4 px senkrecht. Der Schleier davor bleibt stehen, sonst würde die Schrift wandern. Die Grafik ist unten verankert und wird beschnitten (preserveAspectRatio="xMidYMax slice"), deshalb reißt am Rand keine Lücke auf
Sonnenstrahlen src/components/AuraWeather.tsx, nur hell. Alle 16–24 s zwei oder drei .aura-ray-Elemente: vom Sonnenmittelpunkt in Richtung Ecke unten links, leicht aufgefächert, per clip-path zur Sonne hin verjüngt und weichgezeichnet. Web Animations API: 4 s anschwellen, leicht schwenken, verblassen, danach entfernt

Zwei Stolperstellen:

  1. Wolken hinter die Schrift. ::before mit z-index: -1 verschwände hinter dem Hintergrund der Leiste. isolation: isolate an .app-header macht sie zum eigenen Stapelkontext - dann liegen die Wolken über dem Himmel, aber unter Titel und Knöpfen. overflow: hidden an der Leiste wäre der naheliegende Weg und ist falsch: sie enthält Aufklappmenüs.
  2. Sonne und Strahlen gehören zusammen. SUN_FROM_RIGHT/SUN_FROM_TOP in AuraWeather.tsx sind aus Position und Größe der Sonne in index.css gerechnet. Wer die Sonne verschiebt, muss beide mitziehen, sonst kommen die Strahlen aus dem Nichts.
  3. Hügel bewegen, Schleier nicht. background-position gilt je Ebene. Wer im Keyframe nur einen Wert schreibt, verschiebt auch den Schleier - die Menüpunkte bekommen dann wandernde Helligkeit. Deshalb stehen dort immer zwei Werte: center top für den Schleier, die Bewegung für die Grafik.

Was ein neues Theme können muss

Die Rollen der Skalen sind über die ganze App gleich; ein Theme, das sie verletzt, macht Text unlesbar. Nachgezählt im Markup:

Skala Rolle Verwendungen
ink-50, ink-100 Flächen und Hover 102 × bg-
ink-200 Rahmen 224 × border-, 1 × bg-
ink-300–ink-900 Text; ink-400 trägt die Hinweistexte 241 × text-ink-400
ink-950 nur unter Dialog-Overlays, bleibt immer dunkel 5 × bg-
brand-50, brand-100 getönte Flächen 70 × bg-
brand-200–brand-400 Rahmen 76 × border-
brand-500, brand-600 gefüllte Knöpfe - weisse Schrift darauf 100 × bg-
brand-600–brand-800 Text auf getönten Flächen 108 × text-

Daraus folgen zwei harte Regeln:

  1. brand-500 und brand-600 müssen dunkel genug für weisse Schrift sein. text-white steht 69 × im Markup, immer auf einer Markenfläche. Ein helles Theme kann dort kein Pastell verwenden.
  2. Der helle Block eines Themes braucht :not(.dark). html.theme-x hat eine Klasse mehr in der Spezifität als .dark und würde den Dunkelmodus sonst überstimmen.

Was ein Theme über Farben hinaus ändern kann: Eckenradien (--radius-*, weil Tailwind v4 rounded-xl zu var(--radius-xl) auflöst), Schatten (--shadow-*) und die Schriften (--font-sans für Fliesstext, --font-display für Überschriften und Feldbeschriftungen - letztere tragen dafür die Klasse field-label). rounded-full ist die eine Ausnahme: dort schreibt Tailwind einen festen Wert, deshalb hat Pixel Art dafür eine eigene Regel.

Drei Werte bleiben in jedem Theme und Modus gleich: ink-950 (liegt nur unter Dialog-Overlays), text-white (steht immer auf farbigen Flächen) und die 39 festen Hex-Werte für Phasenfarben, Benutzerfarben und Diagramme - die folgen bewusst keinem Theme, weil sie Bedeutung tragen und nicht Dekoration sind.

Eine bewusste Ausnahme: der Mailinhalt bleibt auch im Dunkelmodus auf weissem Grund. Fremdes Mail-HTML bringt seine eigenen Farben mit; ein dunkler Hintergrund darunter macht schwarze Schrift auf schwarzer Fläche. Der Rahmen liest sich damit wie ein Blatt Papier im dunklen Fenster - gewollt.