diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx new file mode 100644 index 0000000..4720a04 --- /dev/null +++ b/src/components/ui/Modal.tsx @@ -0,0 +1,161 @@ +import React, { useEffect, useRef } from 'react'; + +interface ModalProps { + open: boolean; + title: React.ReactNode; + subtitle?: React.ReactNode; + onClose: () => void; + children: React.ReactNode; + footer?: React.ReactNode; + /** sm für Bestätigungen, lg für Formulare, xl für Karten mit Reitern. */ + size?: 'sm' | 'md' | 'lg' | 'xl'; +} + +const SIZES = { + sm: 'max-w-md', + md: 'max-w-2xl', + lg: 'max-w-4xl', + xl: 'max-w-6xl', +}; + +/* + * Dialogfenster. + * + * Zwei Dinge aus der Featureliste stecken hier drin: + * * Ein Klick auf den Hintergrund schliesst das vorderste Fenster. + * * Esc tut dasselbe - und zwar nur beim obersten Fenster, sonst wuerde ein + * Kontakt, der aus einem Unternehmen heraus geoeffnet wurde, beide + * Ebenen auf einmal schliessen. + */ + +// Zaehlt die offenen Fenster. Die zuletzt geoeffnete Ebene ist die oberste. +let openCount = 0; + +export const Modal: React.FC = ({ + open, + title, + subtitle, + onClose, + children, + footer, + size = 'md', +}) => { + const depthRef = useRef(0); + + useEffect(() => { + if (!open) return; + openCount += 1; + depthRef.current = openCount; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + if (depthRef.current !== openCount) return; + event.stopPropagation(); + onClose(); + }; + + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + document.addEventListener('keydown', onKeyDown); + + return () => { + openCount -= 1; + document.removeEventListener('keydown', onKeyDown); + if (openCount === 0) document.body.style.overflow = previousOverflow; + }; + }, [open, onClose]); + + if (!open) return null; + + return ( +
{ + if (event.target === event.currentTarget) onClose(); + }} + role="presentation" + > +
+
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ +
+ +
{children}
+ + {footer && ( +
+ {footer} +
+ )} +
+
+ ); +}; + +interface ConfirmProps { + open: boolean; + title: string; + message: React.ReactNode; + confirmLabel?: string; + onConfirm: () => void; + onCancel: () => void; + danger?: boolean; +} + +export const ConfirmDialog: React.FC = ({ + open, + title, + message, + confirmLabel = 'Löschen', + onConfirm, + onCancel, + danger = true, +}) => ( + + + + + } + > +

{message}

+
+); diff --git a/src/components/ui/RichTextEditor.tsx b/src/components/ui/RichTextEditor.tsx new file mode 100644 index 0000000..421ceec --- /dev/null +++ b/src/components/ui/RichTextEditor.tsx @@ -0,0 +1,114 @@ +import React, { useEffect, useRef } from 'react'; + +/* + * Kleiner WYSIWYG-Editor. + * + * Bewusst ohne Fremdpaket: Quill & Co. bringen ihr eigenes Farbschema mit, das + * im Dunkelmodus gegen die Variablen dieser Anwendung arbeitet, und kosten + * mehr als 200 kB. Gebraucht werden hier sechs Formate - das ist mit + * `contentEditable` und `document.execCommand` in wenigen Zeilen erledigt. + * + * `execCommand` gilt als veraltet, ist aber in allen aktuellen Browsern + * implementiert und der einzige Weg, der ohne eigenes Auswahl- und + * Bereichsmodell auskommt. Ein Ersatz waere ein eigenes Dokumentmodell - das + * waere fuer ein Beschreibungsfeld unverhaeltnismaessig. + */ + +interface RichTextEditorProps { + value: string; + onChange: (html: string) => void; + placeholder?: string; + minHeight?: number; + /** Zusatzknöpfe rechts in der Leiste, z. B. für die KI. */ + actions?: React.ReactNode; +} + +interface ToolButton { + command: string; + argument?: string; + label: React.ReactNode; + title: string; +} + +const BUTTONS: ToolButton[] = [ + { command: 'bold', label: F, title: 'Fett (Strg+B)' }, + { command: 'italic', label: K, title: 'Kursiv (Strg+I)' }, + { command: 'formatBlock', argument: 'h2', label: 'H2', title: 'Überschrift' }, + { command: 'formatBlock', argument: 'h3', label: 'H3', title: 'Zwischenüberschrift' }, + { command: 'formatBlock', argument: 'p', label: '¶', title: 'Absatz' }, + { command: 'insertUnorderedList', label: '• •', title: 'Aufzählung' }, + { command: 'insertOrderedList', label: '1.', title: 'Nummerierte Liste' }, + { command: 'removeFormat', label: '⌫', title: 'Formatierung entfernen' }, +]; + +export const RichTextEditor: React.FC = ({ + value, + onChange, + placeholder, + minHeight = 220, + actions, +}) => { + const ref = useRef(null); + + /* + * Der Inhalt wird nur dann von aussen gesetzt, wenn er sich wirklich + * unterscheidet. Sonst schriebe jeder Tastendruck das DOM neu und die + * Schreibmarke spränge ans Ende. + */ + useEffect(() => { + const element = ref.current; + if (!element) return; + if (element.innerHTML !== value) element.innerHTML = value || ''; + }, [value]); + + const run = (button: ToolButton) => { + ref.current?.focus(); + document.execCommand(button.command, false, button.argument); + onChange(ref.current?.innerHTML ?? ''); + }; + + return ( +
+
+ {BUTTONS.map((button) => ( + + ))} + {actions && {actions}} +
+ +
onChange((event.target as HTMLDivElement).innerHTML)} + onPaste={(event) => { + /* + * Aus Word oder einem Portal kommt HTML mit fremden Schriftgroessen, + * Farben und -Wolken. Eingefuegt wird deshalb der reine + * Text - die Formatierung macht der Benutzer hier. + */ + event.preventDefault(); + const text = event.clipboardData.getData('text/plain'); + document.execCommand('insertText', false, text); + onChange(ref.current?.innerHTML ?? ''); + }} + className="prose-editor max-w-none px-3 py-2 text-sm text-ink-900 focus:outline-none" + /> +
+ ); +}; diff --git a/src/components/ui/StarRating.tsx b/src/components/ui/StarRating.tsx new file mode 100644 index 0000000..6b08974 --- /dev/null +++ b/src/components/ui/StarRating.tsx @@ -0,0 +1,58 @@ +import React from 'react'; + +interface StarRatingProps { + value: number; + /** Ohne onChange ist die Anzeige rein lesend. */ + onChange?: (value: number) => void; + size?: number; + className?: string; +} + +/** + * Fuenf Sterne: wie interessant ist ein Unternehmen fuer uns. + * + * Ein Klick auf den bereits gesetzten Stern nimmt die Bewertung wieder zurueck + * (0 = nicht bewertet) - sonst liesse sich eine versehentliche Bewertung nicht + * mehr entfernen. + */ +export const StarRating: React.FC = ({ value, onChange, size = 16, className = '' }) => { + const readOnly = !onChange; + + return ( + + {[1, 2, 3, 4, 5].map((star) => { + const filled = star <= value; + const icon = ( + + ); + + if (readOnly) return {icon}; + + return ( + + ); + })} + + ); +}; diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx new file mode 100644 index 0000000..062d3c4 --- /dev/null +++ b/src/components/ui/form.tsx @@ -0,0 +1,100 @@ +import React from 'react'; + +/* + * Gemeinsame Bausteine der Formulare. + * + * Die Klassen stehen an genau einer Stelle: sonst driften Eingabefelder + * zwischen Kontakt-, Unternehmens- und Projektformular auseinander, und ein + * Design muesste jede Kopie einzeln erreichen. + */ + +export const inputClass = + 'w-full rounded-lg border border-ink-200 bg-surface px-3 py-2 text-sm text-ink-900 placeholder:text-ink-400 disabled:bg-ink-50 disabled:text-ink-400'; + +export const labelClass = + 'field-label mb-1 block text-xs font-semibold uppercase tracking-wide text-ink-500'; + +export const Field: React.FC<{ + label: string; + children: React.ReactNode; + hint?: string; + className?: string; +}> = ({ label, children, hint, className = '' }) => ( +
+ + {children} + {hint &&

{hint}

} +
+); + +export const PrimaryButton: React.FC> = ({ + className = '', + ...props +}) => ( + + ))} +
+ ); +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..ca30d18 --- /dev/null +++ b/src/index.css @@ -0,0 +1,652 @@ +@import 'tailwindcss'; + +/* + * ICON CRM - Corporate Identity + * + * Primaerfarbe #C1862F (Gold), heller Akzent #DFA85A + * Flaechen neutrales Grau bis #0A0A0A im Dunkelmodus + * Schrift Montserrat + * + * Aufbau wie im perinco CRM: im Markup steht nirgends eine feste Farbe, + * sondern durchgaengig brand-*, ink-*, canvas und surface. Ein Moduswechsel + * oder ein Design tauscht deshalb nur Variablenwerte - die Komponenten + * brauchen keine dark:-Varianten. + * + * Die Rollen der Skalen sind ueberall gleich: + * ink-50/100 Flaechen und Hover + * ink-200 Rahmen + * ink-300-900 Text; 400 traegt die Hinweistexte + * ink-950 liegt nur unter Dialog-Overlays, bleibt immer dunkel + * brand-50/100 getoente Flaechen + * brand-200-400 Rahmen und Akzente + * brand-500 das CI-Gold - Logo, aktive Kanten, Fokusring + * brand-600 gefuellte Knoepfe; hier steht WEISSE Schrift drauf, deshalb + * dunkler als 500 (Kontrast 4.8:1 statt 2.9:1) + * brand-700-900 Text auf getoenten Flaechen + */ + +@theme { + --font-sans: 'Montserrat', ui-sans-serif, system-ui, sans-serif; + --font-display: 'Montserrat', ui-sans-serif, system-ui, sans-serif; + + /* Flaechen semantisch benannt statt "weiss" - im Dunkelmodus werden nur + diese Werte getauscht. */ + --color-canvas: #f6f6f7; + --color-surface: #ffffff; + + --color-brand-50: #fdf8f0; + --color-brand-100: #f8ecd8; + --color-brand-200: #efd8ae; + --color-brand-300: #e3bd7c; + --color-brand-400: #d3a253; + --color-brand-500: #c1862f; + --color-brand-600: #a06c22; + --color-brand-700: #7d541b; + --color-brand-800: #5f4116; + --color-brand-900: #48310f; + --color-brand-accent: #dfa85a; + + --color-ink-50: #f7f7f8; + --color-ink-100: #ededef; + --color-ink-200: #dcdce0; + --color-ink-300: #bcbcc2; + --color-ink-400: #93939b; + --color-ink-500: #6b6b73; + --color-ink-600: #55555c; + --color-ink-700: #414147; + --color-ink-800: #2b2b30; + --color-ink-900: #1a1a1a; + --color-ink-950: #0a0a0a; + + --shadow-card: 0 1px 2px rgb(10 10 10 / 0.04), 0 4px 16px rgb(10 10 10 / 0.06); + --shadow-lift: 0 8px 28px rgb(10 10 10 / 0.12); +} + +/* + * Dunkelmodus. Die neutrale Skala wird gedreht: 50 ist die dunkelste Flaeche, + * 900 der hellste Text. + * + * Zwei Werte bleiben in beiden Modi gleich und sind deshalb bewusst so + * gewaehlt: ink-950 liegt nur unter Dialog-Overlays und muss dunkel bleiben, + * und text-white steht immer auf farbigen Flaechen. + */ +.dark { + --color-canvas: #0a0a0a; + --color-surface: #161616; + + --color-ink-50: #1c1c1e; + --color-ink-100: #26262a; + --color-ink-200: #36363c; + /* Nicht die exakte Umkehrung von #bcbcc2 - dunkler waeren die Hinweistexte + (text-ink-300) auf der Flaeche kaum noch zu lesen. */ + --color-ink-300: #6b6b73; + --color-ink-400: #90909a; + --color-ink-500: #a9a9b2; + --color-ink-600: #bfbfc6; + --color-ink-700: #d4d4d9; + --color-ink-800: #e7e7ea; + --color-ink-900: #f6f6f7; + --color-ink-950: #000000; + + /* Das Gold bleibt das Gold. Die hellen Stufen werden zu dunklen Toenen + (Flaechen), die dunklen zu hellen (Text). 600 traegt weiter die gefuellten + Knoepfe, darf hier aber heller sein - auf Schwarz leuchtet es sonst nicht. */ + --color-brand-50: #2a1e0a; + --color-brand-100: #3a290d; + --color-brand-200: #5a3f15; + --color-brand-300: #7d541b; + --color-brand-400: #a87428; + --color-brand-600: #d0953c; + --color-brand-700: #e3bd7c; + --color-brand-800: #efd8ae; + --color-brand-900: #f8ecd8; + + /* Getoente Status-Pillen: hell mit dunklem Text wird hier gedreht. Die + Stufen 500/600 bleiben unberuehrt, dort steht weisse Schrift drauf. */ + --color-sky-50: #0d2537; + --color-sky-200: #1d4a6b; + --color-sky-700: #7cc4ef; + + --color-emerald-50: #0b2a1e; + --color-emerald-200: #14513a; + --color-emerald-700: #6ee7b7; + + --color-amber-50: #2e2008; + --color-amber-200: #5c4210; + --color-amber-700: #f0b356; + + --color-violet-50: #241a3d; + --color-violet-200: #3f2f66; + --color-violet-700: #b49bf2; + + --color-red-50: #2e1414; + --color-red-200: #5e2626; + --color-red-700: #f28b8b; +} + +/* =========================================================================== + * Half-Life + * + * Black Mesa: Beton, Warnorange und harte Kanten. Der Akzent ist das Orange des + * HEV-Anzugs, die Flächen sind Sichtbeton mit kaltem Stich, die Ecken fast + * rechtwinklig - Laboreinrichtung hat keine Radien. + * + * Zwei Dinge sind bewusst so gelöst: + * + * * Der Akzent bleibt Orange und wird nicht zu Gelb. Auf brand-500 und + * brand-600 steht überall weiße Schrift; ein helleres Warngelb wäre dort + * nicht mehr lesbar. Das Gelb-Schwarz der Gefahrenstreifen kommt deshalb + * nicht als Fläche vor. + * * Die Überschriften laufen in Oswald und gesperrt in Versalien - eine + * schmale Grotesk ist das, was die Beschilderung in Black Mesa ausmacht. + * Der Fließtext bleibt Montserrat: die Akquise-Tabellen haben acht Spalten + * bei 12 px, und eine Schmalschrift wäre darin nicht mehr zu lesen. + * + * Der helle Block ist mit :not(.dark) abgegrenzt. Ohne das gewänne er gegen + * .dark (eine Klasse mehr in der Spezifität) und der Dunkelmodus wäre kaputt. + * =========================================================================== */ +html.theme-halflife:not(.dark) { + /* + * Grafik hinter Kopfleiste und Menü. + * + * Die Variable enthält ZWEI Ebenen: einen Schleier aus der Flächenfarbe und + * darunter die Zeichnung. Das ist kein Umweg, sondern der Grund, dass + * dieselbe Grafik in hell und dunkel funktioniert - und die Bedingung dafür, + * dass die Schrift lesbar bleibt. In der Kopfleiste steht der Schleier an den + * Rändern dicht (Titel links, Knöpfe rechts) und lässt die Grafik nur in der + * Mitte durch; im Menü liegt er oben über den Einträgen und gibt nach unten + * frei. + */ + --theme-header-image: linear-gradient( + 90deg, + rgb(244 242 236 / 0.96) 0%, + rgb(244 242 236 / 0.55) 28%, + rgb(244 242 236 / 0.5) 55%, + rgb(244 242 236 / 0.96) 100% + ), + url('/themes/halflife-header.svg'); + --theme-sidebar-image: linear-gradient( + 180deg, + rgb(244 242 236 / 0.98) 0%, + rgb(244 242 236 / 0.93) 45%, + rgb(244 242 236 / 0.62) 78%, + rgb(244 242 236 / 0.72) 100% + ), + url('/themes/halflife-sidebar.svg'); + + /* Laborflur: Sichtbeton, kalt-warm gemischt. Deutlich dunkler als die Karten, + damit die Panels wie eingesetzte Platten wirken. */ + --color-canvas: #dedbd3; + --color-surface: #f4f2ec; + + --color-brand-50: #fdf1e5; + --color-brand-100: #fae0c6; + --color-brand-200: #f4bf8a; + --color-brand-300: #ec9c4f; + --color-brand-400: #e5811f; + --color-brand-500: #e0720b; + /* Gefüllte Knöpfe tragen weiße Schrift - dieser Ton hält den Kontrast. */ + --color-brand-600: #b85a05; + --color-brand-700: #8f4604; + --color-brand-800: #6a3403; + --color-brand-900: #4b2502; + --color-brand-accent: #ff8c1a; + + /* Stahlgrau mit kaltem Stich statt Neutralgrau. */ + --color-ink-50: #eceae4; + --color-ink-100: #dedbd3; + --color-ink-200: #bfbcb2; + --color-ink-300: #98958b; + /* Hinweistexte (text-ink-400) müssen auf dem Beton lesbar bleiben - + deshalb dunkler als die reine Umkehrung. */ + --color-ink-400: #6b6961; + --color-ink-500: #55534c; + --color-ink-600: #44423c; + --color-ink-700: #35332e; + --color-ink-800: #26241f; + --color-ink-900: #171512; + --color-ink-950: #0b0a08; + + /* Kanten wie an Laboreinrichtung: fast rechtwinklig. */ + --radius-sm: 0.0625rem; + --radius-md: 0.0625rem; + --radius-lg: 0.125rem; + --radius-xl: 0.125rem; + --radius-2xl: 0.1875rem; + + /* Hart und versetzt statt weich - Beton wirft keinen weichen Schatten. */ + --shadow-card: 0 1px 0 0 rgb(23 21 18 / 0.12), 2px 2px 0 0 rgb(23 21 18 / 0.06); + --shadow-lift: 4px 4px 0 0 rgb(23 21 18 / 0.18); + + --font-display: 'Oswald', 'Montserrat', ui-sans-serif, system-ui, sans-serif; +} + +/* + * Half-Life, dunkel: Ravenholm. Die Flächen sind nicht neutral schwarz, sondern + * ins Blaugrün gezogen; das Orange leuchtet kräftiger, weil es die einzige + * warme Farbe im Bild ist. + */ +html.theme-halflife.dark { + /* Dieselbe Zeichnung, dunkler Schleier: aus dem Laborflur wird Nachtschicht. */ + --theme-header-image: linear-gradient( + 90deg, + rgb(22 28 31 / 0.96) 0%, + rgb(22 28 31 / 0.62) 28%, + rgb(22 28 31 / 0.55) 55%, + rgb(22 28 31 / 0.96) 100% + ), + url('/themes/halflife-header.svg'); + --theme-sidebar-image: linear-gradient( + 180deg, + rgb(22 28 31 / 0.97) 0%, + rgb(22 28 31 / 0.9) 45%, + rgb(22 28 31 / 0.55) 78%, + rgb(22 28 31 / 0.68) 100% + ), + url('/themes/halflife-sidebar.svg'); + + --color-canvas: #0d1113; + --color-surface: #161c1f; + + --color-brand-50: #2a1608; + --color-brand-100: #3a1e0a; + --color-brand-200: #5c3010; + --color-brand-300: #7d4315; + --color-brand-400: #b25f1c; + --color-brand-500: #e0720b; + --color-brand-600: #ff8c1a; + --color-brand-700: #f4bf8a; + --color-brand-800: #fae0c6; + --color-brand-900: #fdf1e5; + --color-brand-accent: #ff8c1a; + + --color-ink-50: #191f22; + --color-ink-100: #222a2e; + --color-ink-200: #33403f; + --color-ink-300: #667470; + --color-ink-400: #8b9894; + --color-ink-500: #a6b2ae; + --color-ink-600: #bfc9c5; + --color-ink-700: #d5dcd9; + --color-ink-800: #e8edeb; + --color-ink-900: #f5f8f7; + --color-ink-950: #000000; + + --radius-sm: 0.0625rem; + --radius-md: 0.0625rem; + --radius-lg: 0.125rem; + --radius-xl: 0.125rem; + --radius-2xl: 0.1875rem; + + --shadow-card: 0 0 0 1px rgb(224 114 11 / 0.1), 2px 2px 0 0 rgb(0 0 0 / 0.5); + --shadow-lift: 4px 4px 0 0 rgb(0 0 0 / 0.6); + + --font-display: 'Oswald', 'Montserrat', ui-sans-serif, system-ui, sans-serif; + + /* Die getönten Status-Pillen aus .dark ins Kalte gezogen. */ + --color-amber-50: #2b1e05; + --color-amber-200: #573d0c; + --color-emerald-50: #0a2620; + --color-emerald-200: #14493c; +} + +/* =========================================================================== + * Pixel Art + * + * 8-bit: keine runden Ecken, harte versetzte Schatten ohne Weichzeichnung, + * Rahmen als dunkle Outline, gesättigtes Arcade-Grün. + * + * Die Pixelschrift steckt nur in --font-display (Überschriften und + * Feldbeschriftungen). Der Fließtext bleibt Montserrat: die Tabellen haben bis + * zu acht Spalten bei 12 px, und eine Pixelschrift wäre darin nicht mehr zu + * lesen - ein Design, das die Arbeit behindert, wäre ein schlechtes Design. + * =========================================================================== */ +html.theme-pixel:not(.dark) { + --theme-header-image: linear-gradient( + 90deg, + rgb(246 248 241 / 0.95) 0%, + rgb(246 248 241 / 0.45) 30%, + rgb(246 248 241 / 0.4) 55%, + rgb(246 248 241 / 0.95) 100% + ), + url('/themes/pixel-header.svg'); + --theme-sidebar-image: linear-gradient( + 180deg, + rgb(246 248 241 / 0.97) 0%, + rgb(246 248 241 / 0.92) 48%, + rgb(246 248 241 / 0.7) 80%, + rgb(246 248 241 / 0.8) 100% + ), + url('/themes/pixel-sidebar.svg'); + + /* Deutlich dunkler als die Karten: Pixelgrafik lebt von harten Kanten, und + bei zu ähnlichen Tönen verschwimmen Karte und Grund. */ + --color-canvas: #cdd8c2; + --color-surface: #f6f8f1; + + --color-brand-50: #e0f2d4; + --color-brand-100: #c8e8b4; + /* Rahmen sind hier keine Andeutung, sondern Kontur - deshalb dunkel. */ + --color-brand-200: #2b7a1c; + --color-brand-300: #256b18; + --color-brand-400: #35931f; + --color-brand-500: #2e8b22; + --color-brand-600: #24701a; + --color-brand-700: #1b5613; + --color-brand-800: #143f0e; + --color-brand-900: #0e2f0a; + --color-brand-accent: #2e8b22; + + --color-ink-50: #eef2e8; + --color-ink-100: #dde4d4; + /* Der Kniff des Designs: ink-200 ist fast ausschließlich Rahmen und wird hier + zur schwarzen Outline - an allen Stellen zugleich, ohne eine Zeile Markup. */ + --color-ink-200: #1f2a1a; + --color-ink-300: #5a6653; + --color-ink-400: #47523f; + --color-ink-500: #38412f; + --color-ink-600: #2c3425; + --color-ink-700: #22291c; + --color-ink-800: #182015; + --color-ink-900: #0e140c; + --color-ink-950: #000000; + + --radius-sm: 0; + --radius-md: 0; + --radius-lg: 0; + --radius-xl: 0; + --radius-2xl: 0; + + /* Versetzt statt weichgezeichnet - so sehen Schatten in Pixelgrafik aus. */ + --shadow-card: 4px 4px 0 0 #1f2a1a; + --shadow-lift: 6px 6px 0 0 #1f2a1a; + + --font-display: 'Silkscreen', 'Courier New', ui-monospace, monospace; +} + +/* + * Pixel Art, dunkel: Arcade-Automat im dunklen Raum. Flächen fast schwarz mit + * blauem Stich, Konturen in gedecktem Grün - schwarze Outlines wären auf + * schwarzem Grund unsichtbar. + */ +html.theme-pixel.dark { + --theme-header-image: linear-gradient( + 90deg, + rgb(21 26 34 / 0.96) 0%, + rgb(21 26 34 / 0.7) 30%, + rgb(21 26 34 / 0.62) 55%, + rgb(21 26 34 / 0.96) 100% + ), + url('/themes/pixel-header.svg'); + --theme-sidebar-image: linear-gradient( + 180deg, + rgb(21 26 34 / 0.97) 0%, + rgb(21 26 34 / 0.92) 48%, + rgb(21 26 34 / 0.76) 80%, + rgb(21 26 34 / 0.86) 100% + ), + url('/themes/pixel-sidebar.svg'); + + --color-canvas: #0d0f14; + --color-surface: #151a22; + + --color-brand-50: #0f2a0c; + --color-brand-100: #163c11; + --color-brand-200: #2c7a1f; + --color-brand-300: #35931f; + --color-brand-400: #3aa524; + --color-brand-500: #3aa524; + --color-brand-600: #49bf30; + --color-brand-700: #7fdc63; + --color-brand-800: #b0ec9c; + --color-brand-900: #d8f7cd; + --color-brand-accent: #49bf30; + + --color-ink-50: #1a2029; + --color-ink-100: #232b36; + --color-ink-200: #33734a; + --color-ink-300: #6f7f92; + --color-ink-400: #93a3b5; + --color-ink-500: #adbccb; + --color-ink-600: #c2cfdb; + --color-ink-700: #d6e0ea; + --color-ink-800: #e9eff5; + --color-ink-900: #f6f9fc; + --color-ink-950: #000000; + + --radius-sm: 0; + --radius-md: 0; + --radius-lg: 0; + --radius-xl: 0; + --radius-2xl: 0; + + --shadow-card: 4px 4px 0 0 #000000; + --shadow-lift: 6px 6px 0 0 #000000; + + --font-display: 'Silkscreen', 'Courier New', ui-monospace, monospace; +} + +@layer base { + html, + body { + height: 100%; + } + + body { + background-color: var(--color-canvas); + color: var(--color-ink-900); + font-family: var(--font-sans); + font-weight: 400; + -webkit-font-smoothing: antialiased; + transition: + background-color 0.2s ease, + color 0.2s ease; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + font-family: var(--font-display); + font-weight: 700; + letter-spacing: -0.015em; + } + + /* Feldbeschriftungen tragen dieselbe Klasse, damit ein spaeteres Design sie + mit einer Regel erreicht. */ + .field-label { + font-family: var(--font-display); + } + + /* Fokus-Ring durchgaengig im CI-Gold */ + :focus-visible { + outline: 2px solid var(--color-brand-500); + outline-offset: 2px; + } + + /* + * Half-Life: Überschriften und Feldbeschriftungen laufen gesperrt in + * Versalien - so ist Black Mesa beschildert. Die negative Laufweite der + * Grundeinstellung würde Oswald ineinanderschieben. + */ + html.theme-halflife h1, + html.theme-halflife h2, + html.theme-halflife h3, + html.theme-halflife h4, + html.theme-halflife h5, + html.theme-halflife h6 { + letter-spacing: 0.04em; + text-transform: uppercase; + font-weight: 600; + } + + html.theme-halflife .field-label { + letter-spacing: 0.12em; + } + + /* + * Pixelschrift hat eine feste Zeichenbreite und trägt ihre Sperrung selbst; + * die negative Laufweite der Überschriften würde die Buchstaben ineinander + * schieben. Außerdem kein Kantenglätten - Pixel sollen Pixel bleiben. + */ + html.theme-pixel h1, + html.theme-pixel h2, + html.theme-pixel h3, + html.theme-pixel h4, + html.theme-pixel h5, + html.theme-pixel h6, + html.theme-pixel .field-label { + letter-spacing: 0; + -webkit-font-smoothing: none; + } + + /* + * Hintergrundbilder der Kopfleiste und des Menüs. + * + * Ohne Design steht in den Variablen nichts, dann greift der Rückfall `none` + * und beide Flächen bleiben einfarbig - ICON sieht also aus wie immer. + * `bg-surface` an den Elementen bleibt: die Farbe liegt unter dem Bild und + * trägt, solange die Grafik lädt oder fehlt. + */ + .app-header { + background-image: var(--theme-header-image, none); + background-repeat: no-repeat; + background-position: center; + background-size: cover; + } + + .app-sidebar { + background-image: var(--theme-sidebar-image, none); + background-repeat: no-repeat; + /* Unten verankert: dort sitzt in beiden Grafiken das Motiv. */ + background-position: bottom center; + background-size: cover; + } +} + +@layer utilities { + /* + * "rounded-full" lässt sich nicht über eine Variable abschalten - Tailwind + * schreibt dort einen festen Wert. Für Pixel Art muss es aber weg: runde + * Punkte und Pillen neben eckigen Karten wären ein Bruch. Der + * Attributselektor greift genau dieses eine Klassenwort und ist mit dem + * vorangestellten html spezifischer als die Utility-Klasse. + */ + html.theme-pixel [class~='rounded-full'] { + border-radius: 0; + } +} + +@layer utilities { + /* Das ICON Logo ist fuer helle Hintergruende gemacht - im Dunkelmodus wird + die Helligkeit gedreht und das Gold per hue-rotate zurueckgeholt. */ + .dark .logo-adaptive { + filter: invert(1) hue-rotate(180deg); + } + + .scrollbar-slim::-webkit-scrollbar { + width: 8px; + height: 8px; + } + .scrollbar-slim::-webkit-scrollbar-track { + background: transparent; + } + .scrollbar-slim::-webkit-scrollbar-thumb { + background: var(--color-ink-200); + border-radius: 999px; + } + .scrollbar-slim::-webkit-scrollbar-thumb:hover { + background: var(--color-ink-300); + } + + /* + * Der WYSIWYG-Editor und die Leseansicht der Ausschreibungstexte. + * + * Tailwind setzt Ueberschriften und Listen im Basis-Reset zurueck - in einem + * Feld, dessen Inhalt aus

,
    und

    besteht, saehe sonst alles + * gleich aus. Hier bekommen diese Elemente ihre Darstellung zurueck, und nur + * hier. + */ + /* + * Der Altbestand traegt Inline-Farben aus dem frueheren Editor. Sie hier + * auszuhebeln ist kein Schoenheitseingriff: im Dunkelmodus stuende sonst + * schwarze Schrift auf weissen Kaesten mitten auf dunklem Grund. + * + * !important ist noetig, weil Inline-Stile sonst immer gewinnen. Eigene + * Farbwahl gibt es in diesem Editor nicht - es geht also nichts verloren. + */ + .prose-editor [style] { + color: inherit !important; + background-color: transparent !important; + font-family: inherit !important; + font-size: inherit !important; + } + + /* + * Lange Woerter und Adressen muessen umbrechen duerfen. Der Altbestand ist + * voller geschuetzter Leerzeichen; bis ein Text einmal gespeichert und dabei + * bereinigt wurde, haelt das hier die Zeilen im Rahmen. + */ + .prose-editor { + overflow-wrap: anywhere; + word-break: break-word; + } + + .prose-editor:empty::before { + content: attr(data-placeholder); + color: var(--color-ink-400); + } + .prose-editor h2 { + font-size: 1.05rem; + font-weight: 700; + margin: 0.9em 0 0.3em; + } + .prose-editor h3 { + font-size: 0.95rem; + font-weight: 600; + margin: 0.8em 0 0.25em; + } + .prose-editor p { + margin: 0.4em 0; + } + .prose-editor ul, + .prose-editor ol { + margin: 0.4em 0 0.4em 1.25em; + } + .prose-editor ul { + list-style: disc; + } + .prose-editor ol { + list-style: decimal; + } + .prose-editor li { + margin: 0.15em 0; + } + /* Ein Aufzaehlungspunkt, der nur eine weitere Liste enthaelt, bekommt kein + eigenes Zeichen - sonst stehen zwei nebeneinander. */ + .prose-editor li:has(> ul):not(:has(> :not(ul))), + .prose-editor li:has(> ol):not(:has(> :not(ol))) { + list-style: none; + } + .prose-editor strong { + font-weight: 600; + } + .prose-editor > :first-child { + margin-top: 0; + } + + .line-clamp-2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + .line-clamp-3 { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + } +} diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..4e7d2f4 --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,415 @@ +import type { + AppUser, + Company, + Connection, + Contact, + DirectoryUser, + Employment, + HistoryEntry, + Interview, + Lead, + LeadTodo, + PerincoContact, + Project, + Tender, +} from '../types'; + +const TOKEN_KEY = 'icon_crm_token'; + +export function getToken(): string | null { + return localStorage.getItem(TOKEN_KEY); +} +export function setToken(token: string) { + localStorage.setItem(TOKEN_KEY, token); +} +export function clearToken() { + localStorage.removeItem(TOKEN_KEY); +} + +export class ApiError extends Error { + status: number; + constructor(message: string, status: number) { + super(message); + this.status = status; + } +} + +/** Wird bei 401 gefeuert, damit die Anwendung zurueck auf den Login springt. */ +let onUnauthorized: (() => void) | null = null; +export function setUnauthorizedHandler(handler: (() => void) | null) { + onUnauthorized = handler; +} + +export async function apiRequest(path: string, options: RequestInit = {}): Promise { + const headers = new Headers(options.headers); + if (options.body && !headers.has('Content-Type') && !(options.body instanceof FormData)) { + headers.set('Content-Type', 'application/json'); + } + const token = getToken(); + if (token) headers.set('Authorization', `Bearer ${token}`); + + let response: Response; + try { + response = await fetch(`/api${path}`, { ...options, headers }); + } catch { + throw new ApiError('Server nicht erreichbar. Bitte Verbindung prüfen.', 0); + } + + if (response.status === 401) { + clearToken(); + onUnauthorized?.(); + } + + const isJson = response.headers.get('content-type')?.includes('application/json'); + const payload = isJson ? await response.json().catch(() => null) : null; + + if (!response.ok) { + const message = + (payload && typeof payload === 'object' && 'error' in payload && String(payload.error)) || + `Anfrage fehlgeschlagen (HTTP ${response.status}).`; + throw new ApiError(message, response.status); + } + + return payload as T; +} + +const get = (path: string) => apiRequest(path); +const post = (path: string, body?: unknown) => + apiRequest(path, { method: 'POST', body: body === undefined ? undefined : JSON.stringify(body) }); +const patch = (path: string, body: unknown) => + apiRequest(path, { method: 'PATCH', body: JSON.stringify(body) }); +const del = (path: string) => apiRequest(path, { method: 'DELETE' }); + +/** Haengt nur gesetzte Werte als Abfrageparameter an. */ +function query(params: Record): string { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null || value === '') continue; + search.set(key, String(value)); + } + const text = search.toString(); + return text ? `?${text}` : ''; +} + +export const authApi = { + login: (identifier: string, password: string) => + post<{ token: string; user: AppUser }>('/auth/login', { identifier, password }), + me: () => get<{ user: AppUser }>('/auth/me'), + updateMe: (patchBody: Partial>) => + patch<{ user: AppUser }>('/auth/me', patchBody), + changePassword: (current_password: string, new_password: string) => + post<{ ok: true }>('/auth/change-password', { current_password, new_password }), +}; + +export const usersApi = { + directory: () => get<{ users: DirectoryUser[] }>('/users/directory'), + list: () => get<{ users: AppUser[] }>('/users'), + create: (body: Record) => post<{ user: AppUser }>('/users', body), + update: (id: number, body: Record) => patch<{ user: AppUser }>(`/users/${id}`, body), + remove: (id: number) => del<{ ok: true }>(`/users/${id}`), +}; + +export interface CompanyDetail { + company: Company; + contacts: Array>; + former: Array<{ + employment_id: number; + id: number; + first_name: string | null; + last_name: string; + avatar: string | null; + position: string | null; + started_on: string | null; + ended_on: string | null; + now_company: string | null; + now_company_id: number | null; + }>; + interviews: Interview[]; + history: HistoryEntry[]; + mentioned: HistoryEntry[]; +} + +export const companiesApi = { + list: (params: Record = {}) => + get<{ companies: Company[] }>(`/companies${query(params)}`), + options: () => get<{ industries: string[]; groups: string[] }>('/companies/options'), + detail: (id: number) => get(`/companies/${id}`), + create: (body: Record) => post<{ company: Company }>('/companies', body), + update: (id: number, body: Record) => + patch<{ company: Company }>(`/companies/${id}`, body), + remove: (id: number) => del<{ ok: true }>(`/companies/${id}`), + addInterview: (id: number, body: Record) => + post<{ id: number }>(`/companies/${id}/interviews`, body), +}; + +export interface ContactDetail { + contact: Contact; + employments: Employment[]; + interviews: Interview[]; + history: HistoryEntry[]; + mentioned: HistoryEntry[]; +} + +export const contactsApi = { + list: (params: Record = {}) => + get<{ contacts: Contact[] }>(`/contacts${query(params)}`), + detail: (id: number) => get(`/contacts/${id}`), + create: (body: Record) => post<{ contact: Contact }>('/contacts', body), + update: (id: number, body: Record) => + patch<{ contact: Contact }>(`/contacts/${id}`, body), + remove: (id: number) => del<{ ok: true }>(`/contacts/${id}`), + addInterview: (id: number, body: Record) => + post<{ id: number }>(`/contacts/${id}/interviews`, body), + /* + * Werdegang von Hand pflegen. Die Stationen entstehen zwar beim Setzen eines + * Arbeitgebers und beim CV-Import von selbst - aber kein Lebenslauf wird + * vollständig erkannt, und ein falscher Firmenname ließ sich sonst nicht mehr + * geradeziehen. + * + * `company` (Name) oder `company_id` - ein unbekannter Name legt das + * Unternehmen an, wie im CV-Import auch. + */ + addEmployment: (id: number, body: Record) => + post<{ id: number }>(`/contacts/${id}/employments`, body), + updateEmployment: (employmentId: number, body: Record) => + patch<{ ok: true }>(`/contacts/employments/${employmentId}`, body), + removeEmployment: (employmentId: number) => + del<{ ok: true }>(`/contacts/employments/${employmentId}`), + lastEmployers: () => + get<{ + entries: Array<{ + contact_id: number; + first_name: string | null; + last_name: string; + avatar: string | null; + company_id: number; + company: string; + industry: string | null; + position: string | null; + ended_on: string | null; + }>; + }>('/contacts/last-employers'), + linkedInPhoto: (url: string) => + post<{ avatar: string; bytes: number }>('/contacts/linkedin-photo', { url }), +}; + +export const tendersApi = { + list: (params: Record = {}) => get<{ tenders: Tender[] }>(`/tenders${query(params)}`), + detail: (id: number) => + get<{ tender: Tender; contacts: Contact[]; history: HistoryEntry[] }>(`/tenders/${id}`), + create: (body: Record) => post<{ tender: Tender }>('/tenders', body), + update: (id: number, body: Record) => patch<{ tender: Tender }>(`/tenders/${id}`, body), + remove: (id: number) => del<{ ok: true }>(`/tenders/${id}`), +}; + +export const projectsApi = { + list: (params: Record = {}) => + get<{ projects: Project[] }>(`/projects${query(params)}`), + create: (body: Record) => post<{ project: Project }>('/projects', body), + update: (id: number, body: Record) => + patch<{ project: Project }>(`/projects/${id}`, body), + remove: (id: number) => del<{ ok: true }>(`/projects/${id}`), +}; + +export const leadsApi = { + list: (archived = false) => get<{ leads: Lead[] }>(`/leads${query({ archived: archived ? 1 : 0 })}`), + detail: (id: number) => + get<{ lead: Lead; todos: LeadTodo[]; history: HistoryEntry[] }>(`/leads/${id}`), + create: (body: Record) => post<{ lead: Lead }>('/leads', body), + update: (id: number, body: Record) => patch<{ lead: Lead }>(`/leads/${id}`, body), + remove: (id: number) => del<{ ok: true }>(`/leads/${id}`), + reminders: () => + get<{ due: Lead[]; stale: Lead[]; stale_days: number }>('/leads/reminders'), + acknowledge: (id: number, snooze_days?: number) => + post<{ ok: true }>(`/leads/${id}/acknowledge`, { snooze_days }), + addTodo: (id: number, text: string, due_on?: string | null) => + post<{ id: number }>(`/leads/${id}/todos`, { text, due_on }), + updateTodo: (todoId: number, body: Record) => + patch<{ ok: true }>(`/leads/todos/${todoId}`, body), + removeTodo: (todoId: number) => del<{ ok: true }>(`/leads/todos/${todoId}`), +}; + +/** Vorschlag der KI zu einem Lebenslauf. */ +export interface CvSuggestion { + first_name: string | null; + last_name: string | null; + position: string | null; + email: string | null; + phone: string | null; + linkedin: string | null; + company: string | null; + tags: string[]; + bio: string | null; + rate_onsite: number | null; + rate_remote: number | null; + employments: Array<{ company: string; position: string | null; from: string | null; to: string | null }>; + confidence: number | null; +} + +export interface CvImport { + id: number; + filename: string; + original_name: string; + mime: string | null; + size_bytes: number; + status: 'eingang' | 'screening' | 'auswahl' | 'verworfen'; + extracted: CvSuggestion | null; +/** + * Traegt der Lebenslauf ein Bewerbungsfoto? Das Bild selbst steht nicht in + * der Liste - es wird bei Bedarf einzeln geholt (`cvsApi.photo`) und erst im + * Browser verkleinert (siehe lib/imageCompress.ts). + */ + has_photo: boolean; + error: string | null; + contact_id: number | null; + contact_name: string | null; + uploaded_by_name: string | null; + created_at: string; + processed_at: string | null; +} + +export const cvsApi = { + list: () => + get<{ imports: CvImport[]; ai: { configured: boolean; model: string } }>('/cvs'), + /** Mehrere Dateien auf einmal - der Browser setzt die multipart-Grenze selbst. */ + upload: (files: File[]) => { + const body = new FormData(); + for (const file of files) body.append('files', file); + return apiRequest<{ ids: number[]; count: number }>('/cvs', { method: 'POST', body }); + }, + analyze: (id: number) => + post<{ extracted: CvSuggestion; photo: string | null }>(`/cvs/${id}/analyze`), + /** Das gefundene Foto, unveraendert - nur fuer das Formular. */ + photo: (id: number) => get<{ photo: string | null }>(`/cvs/${id}/photo`), + accept: (id: number, overrides: Record = {}) => + post<{ contact_id: number }>(`/cvs/${id}/accept`, overrides), + reject: (id: number) => post<{ ok: true }>(`/cvs/${id}/reject`), + remove: (id: number) => del<{ ok: true }>(`/cvs/${id}`), +}; + +export type AiTask = 'write' | 'improve' | 'shorten' | 'bullets' | 'profile'; + +/** + * Zustand der KI-Einrichtung. Der Schlüssel steht hier nur verkürzt + * ("sk-proj-…a1b2") - vollständig verlässt er den Server nie. + */ +export interface AiConfig { + configured: boolean; + model: string; + defaultModel: string; + /** 'db' = in den Einstellungen gepflegt, 'env' = aus server/.env, 'none' = keiner */ + source: 'db' | 'env' | 'none'; + masked: string | null; + envAvailable: boolean; +} + +export const aiApi = { + status: () => get<{ configured: boolean; model: string; tasks: AiTask[] }>('/ai/status'), + text: (task: AiTask, current: string, prompt?: string) => + post<{ text: string; model: string }>('/ai/text', { task, current, prompt }), + config: () => get('/ai/config'), + saveConfig: (changes: { key?: string; model?: string }) => patch('/ai/config', changes), + test: () => post<{ ok: true; model: string; ms: number; answer: string }>('/ai/test'), +}; + +export const perincoApi = { + status: () => get<{ configured: boolean; url: string | null }>('/perinco/status'), + contacts: () => get<{ contacts: PerincoContact[]; cached: boolean }>('/perinco/contacts'), +}; + +export const historyApi = { + list: (params: Record = {}) => + get<{ entries: HistoryEntry[] }>(`/history${query(params)}`), + add: (entity_type: string, entity_id: number | null, text: string) => + post<{ entry: HistoryEntry }>('/history', { entity_type, entity_id, text }), + remove: (id: number) => del<{ ok: true }>(`/history/${id}`), +}; + +export interface SearchGroup { + type: 'contact' | 'company' | 'tender' | 'project' | 'lead' | 'history'; + label: string; + items: Array & { id: number; title?: string }>; +} + +export interface MentionCandidate { + kind: 'user' | 'contact' | 'company'; + id: number; + label: string; + hint: string | null; + avatar: string | null; + color?: string | null; +} + +export interface AiAnswer { + answer: string; + refs: Array<{ type: 'contact' | 'company' | 'tender' | 'project' | 'lead'; id: number; label: string }>; + model: string; + truncated: boolean; +} + +export const searchApi = { + search: (q: string) => get<{ query: string; groups: SearchGroup[]; total: number }>(`/search${query({ q })}`), + mentions: (q: string) => get<{ candidates: MentionCandidate[] }>(`/search/mentions${query({ q })}`), + ask: (question: string) => post('/search/ai', { question }), +}; + +export interface OptionValue { + id: number; + field: string; + value: string; + position: number; + /** Wie oft der Wert im Bestand verwendet wird. */ + used: number; +} + +export interface OptionField { + field: string; + label: string; + values: OptionValue[]; +} + +export const optionsApi = { + list: () => get<{ fields: OptionField[] }>('/options'), + create: (field: string, value: string) => post<{ id: number }>('/options', { field, value }), + rename: (id: number, value: string) => patch<{ ok: true; renamed: number }>(`/options/${id}`, { value }), + remove: (id: number) => del<{ ok: true }>(`/options/${id}`), +}; + +export interface NetworkData { + companies: Array; + contacts: Array<{ + id: number; + name: string; + position: string | null; + avatar: string | null; + kind: string; + status: string; + company_id: number | null; + }>; + edges: Array & { type: 'employment' | 'former' | 'contact' | 'group' }>; + groups: Array<{ name: string; count: number }>; +} + +export const networkApi = { + load: (params: Record = {}) => get(`/network${query(params)}`), +}; + +export const connectionsApi = { + list: () => get<{ connections: Connection[] }>('/connections'), + create: (contact_a: number, contact_b: number, kind?: string) => + post('/connections', { contact_a, contact_b, kind }), + update: (id: number, kind: string) => patch<{ ok: true }>(`/connections/${id}`, { kind }), + remove: (id: number) => del<{ ok: true }>(`/connections/${id}`), +}; + +export interface DashboardData { + counts: Record; + contacts_by_status: Array<{ status: string; count: number }>; + companies_by_industry: Array<{ industry: string; count: number }>; + tenders_by_stage: Array<{ stage: string; count: number }>; + recent: HistoryEntry[]; +} + +export const dashboardApi = { + load: () => get('/dashboard'), +}; diff --git a/src/lib/audio.ts b/src/lib/audio.ts new file mode 100644 index 0000000..6a7a38c --- /dev/null +++ b/src/lib/audio.ts @@ -0,0 +1,43 @@ +/** + * Whisper erwartet Mono-PCM mit 16 kHz als Float32. Der MediaRecorder liefert + * aber WebM/Opus in der Aufnahmerate der Hardware - hier wird dekodiert und + * heruntergerechnet. + */ +export const WHISPER_SAMPLE_RATE = 16000; + +export interface DecodedAudio { + audio: Float32Array; + /** Laenge der Aufnahme in Sekunden. */ + seconds: number; +} + +export async function toWhisperAudio(blob: Blob): Promise { + const bytes = await blob.arrayBuffer(); + + const AudioContextClass = + window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + if (!AudioContextClass) throw new Error('Dieser Browser kann kein Audio dekodieren.'); + + const context = new AudioContextClass(); + let decoded: AudioBuffer; + try { + decoded = await context.decodeAudioData(bytes); + } finally { + // Nicht geschlossene Kontexte zaehlen gegen das Browserlimit. + await context.close().catch(() => {}); + } + + if (decoded.duration === 0) throw new Error('Die Aufnahme ist leer.'); + + // Resampling ueber einen OfflineAudioContext - das erledigt gleich das + // Zusammenfassen mehrerer Kanaele auf Mono. + const frames = Math.max(1, Math.ceil(decoded.duration * WHISPER_SAMPLE_RATE)); + const offline = new OfflineAudioContext(1, frames, WHISPER_SAMPLE_RATE); + const source = offline.createBufferSource(); + source.buffer = decoded; + source.connect(offline.destination); + source.start(); + const resampled = await offline.startRendering(); + + return { audio: resampled.getChannelData(0), seconds: decoded.duration }; +} diff --git a/src/lib/auth.tsx b/src/lib/auth.tsx new file mode 100644 index 0000000..8fe4731 --- /dev/null +++ b/src/lib/auth.tsx @@ -0,0 +1,83 @@ +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import type { AppUser } from '../types'; +import { authApi, clearToken, getToken, setToken, setUnauthorizedHandler } from './api'; + +interface AuthContextValue { + user: AppUser | null; + isLoading: boolean; + isAdmin: boolean; + login: (identifier: string, password: string) => Promise; + logout: () => void; + /** Aktualisiert den lokalen Benutzer, z. B. nach Bearbeiten des eigenen Kontos. */ + updateUser: (user: AppUser) => void; +} + +const AuthContext = createContext(null); + +export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const logout = useCallback(() => { + clearToken(); + setUser(null); + }, []); + + // Ein 401 aus irgendeinem Aufruf wirft sauber zurueck auf den Login, statt + // eine leere Ansicht stehen zu lassen. + useEffect(() => { + setUnauthorizedHandler(() => setUser(null)); + return () => setUnauthorizedHandler(null); + }, []); + + // Beim Start pruefen, ob das gespeicherte Token noch gilt. + useEffect(() => { + let cancelled = false; + + const restore = async () => { + if (!getToken()) { + setIsLoading(false); + return; + } + try { + const { user: current } = await authApi.me(); + if (!cancelled) setUser(current); + } catch { + clearToken(); + } finally { + if (!cancelled) setIsLoading(false); + } + }; + + void restore(); + return () => { + cancelled = true; + }; + }, []); + + const login = useCallback(async (identifier: string, password: string) => { + const { token, user: loggedIn } = await authApi.login(identifier, password); + setToken(token); + setUser(loggedIn); + }, []); + + const value = useMemo( + () => ({ + user, + isLoading, + isAdmin: user?.role === 'admin', + login, + logout, + updateUser: setUser, + }), + [user, isLoading, login, logout] + ); + + return {children}; +}; + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth muss innerhalb von verwendet werden.'); + return ctx; +} diff --git a/src/lib/dictation.tsx b/src/lib/dictation.tsx new file mode 100644 index 0000000..441d5ef --- /dev/null +++ b/src/lib/dictation.tsx @@ -0,0 +1,142 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useWhisper } from './whisper'; + +/** + * Aufnahme fuer das Diktat. Die Erkennung selbst macht der Whisper-Worker im + * Browser (siehe whisper.tsx) - hier geht es nur um Mikrofon, Dauer und das + * Weiterreichen der Aufnahme. + */ + +/** Erstes Format, das der Browser aufnehmen kann. */ +function pickMimeType(): string { + const candidates = ['audio/webm', 'audio/ogg', 'audio/mp4']; + for (const type of candidates) { + if (MediaRecorder.isTypeSupported?.(type)) return type; + } + return ''; +} + +type RecorderState = 'idle' | 'recording' | 'transcribing'; + +interface UseRecorderResult { + state: RecorderState; + /** Laufende Aufnahmedauer in Sekunden. */ + seconds: number; + start: () => Promise; + stop: () => void; + cancel: () => void; +} + +export function useRecorder( + onText: (text: string) => void, + onError: (message: string) => void +): UseRecorderResult { + const { transcribe, status } = useWhisper(); + const [state, setState] = useState('idle'); + const [seconds, setSeconds] = useState(0); + + const recorderRef = useRef(null); + const chunksRef = useRef([]); + const streamRef = useRef(null); + const timerRef = useRef(null); + const discardRef = useRef(false); + + const cleanup = useCallback(() => { + if (timerRef.current !== null) { + window.clearInterval(timerRef.current); + timerRef.current = null; + } + // Mikrofon freigeben, sonst bleibt die Aufnahmeanzeige des Browsers stehen. + streamRef.current?.getTracks().forEach((track) => track.stop()); + streamRef.current = null; + recorderRef.current = null; + chunksRef.current = []; + setSeconds(0); + }, []); + + useEffect(() => cleanup, [cleanup]); + + const start = useCallback(async () => { + if (state !== 'idle') return; + if (status !== 'ready') { + onError('Das Spracherkennungsmodell ist noch nicht geladen.'); + return; + } + discardRef.current = false; + + let stream: MediaStream; + try { + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + } catch (err) { + const name = (err as DOMException)?.name; + onError( + name === 'NotAllowedError' + ? 'Kein Zugriff aufs Mikrofon. Bitte im Browser erlauben.' + : name === 'NotFoundError' + ? 'Es wurde kein Mikrofon gefunden.' + : 'Die Aufnahme konnte nicht gestartet werden.' + ); + return; + } + + const mimeType = pickMimeType(); + let recorder: MediaRecorder; + try { + recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined); + } catch { + stream.getTracks().forEach((track) => track.stop()); + onError('Dieser Browser kann kein passendes Audioformat aufnehmen.'); + return; + } + + streamRef.current = stream; + recorderRef.current = recorder; + chunksRef.current = []; + + recorder.ondataavailable = (event) => { + if (event.data.size > 0) chunksRef.current.push(event.data); + }; + + recorder.onstop = async () => { + const blob = new Blob(chunksRef.current, { type: recorder.mimeType || 'audio/webm' }); + cleanup(); + + if (discardRef.current) { + setState('idle'); + return; + } + if (blob.size < 1024) { + setState('idle'); + onError('Die Aufnahme war zu kurz.'); + return; + } + + setState('transcribing'); + try { + const { text } = await transcribe(blob); + if (text) onText(text); + else onError('Es war nichts Verständliches zu hören.'); + } catch (err) { + onError(err instanceof Error ? err.message : 'Die Erkennung ist fehlgeschlagen.'); + } finally { + setState('idle'); + } + }; + + recorder.start(); + setState('recording'); + setSeconds(0); + timerRef.current = window.setInterval(() => setSeconds((value) => value + 1), 1000); + }, [state, status, cleanup, onError, onText, transcribe]); + + const stop = useCallback(() => { + if (recorderRef.current?.state === 'recording') recorderRef.current.stop(); + }, []); + + const cancel = useCallback(() => { + discardRef.current = true; + stop(); + }, [stop]); + + return { state, seconds, start, stop, cancel }; +} diff --git a/src/lib/imageCompress.ts b/src/lib/imageCompress.ts new file mode 100644 index 0000000..305bd93 --- /dev/null +++ b/src/lib/imageCompress.ts @@ -0,0 +1,113 @@ +/* + * Bilder im Browser verkleinern, bevor sie hochgeladen werden. + * + * Fotos und Logos liegen als Data-URL in der Datenbank. Ohne diesen Schritt + * landet ein 3-MB-Foto aus dem Telefon unveraendert in einer Textspalte und + * geht bei jedem Laden der Liste erneut ueber die Leitung. + * + * 400 px ist bewusst mehr als jede Ansicht braucht (die groesste zeigt 96 px): + * die Featureliste nennt diese Groesse fuer den CV-Import, und ein spaeterer + * Ausbau soll das Bild nicht erneut skalieren muessen. + */ + +const TARGET_SIZE = 400; +const QUALITY = 0.82; + +/** Grenze fuer die Quelldatei - komprimiert wird sie ohnehin. */ +export const MAX_SOURCE_BYTES = 25 * 1024 * 1024; + +/** + * Obergrenze fuer das Ergebnis. + * + * 400 px WebP liegt in der Praxis bei 20-60 kB, die Grenze greift also nie - + * sie ist die Zusicherung, nicht die Regel. Ein Foto aus einem Lebenslauf kann + * ein Scan in Druckaufloesung sein; ohne Grenze laege so etwas ungeprueft in + * einer Textspalte. + */ +export const MAX_RESULT_BYTES = 512 * 1024; + +/** Groesse einer Data-URL in Byte - Base64 traegt 3 Byte je 4 Zeichen. */ +function dataUrlBytes(dataUrl: string): number { + const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1); + const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0; + return Math.floor((base64.length * 3) / 4) - padding; +} + +export async function compressImageFile(file: File): Promise { + if (!file.type.startsWith('image/')) { + throw new Error('Die Datei ist kein Bild.'); + } + if (file.size > MAX_SOURCE_BYTES) { + throw new Error('Das Bild ist zu groß (max. 25 MB).'); + } + return compressImageSource(await readAsDataUrl(file)); +} + +/** + * Verkleinert eine Data- oder http-Adresse auf ein Quadrat von 400 px und + * hoechstens {@link MAX_RESULT_BYTES}. + * + * Bleibt das Ergebnis ueber der Grenze - ein sehr unruhiges Bild kann das -, + * wird die Kantenlaenge schrittweise verringert, bis es passt. Lieber ein + * kleineres Foto als eine Zeile, die jede Liste ausbremst. + */ +export async function compressImageSource(source: string): Promise { + const image = await loadImage(source); + + // Mittiger Ausschnitt: ein Portrait wird sonst verzerrt, und der Kopf sitzt + // bei Profilfotos praktisch immer in der Mitte. + const side = Math.min(image.naturalWidth, image.naturalHeight); + const sx = (image.naturalWidth - side) / 2; + const sy = (image.naturalHeight - side) / 2; + + let size = Math.min(TARGET_SIZE, side); + let last = ''; + + // Hoechstens fuenf Anlaeufe: 400 -> 320 -> 256 -> 204 -> 163 px. + for (let attempt = 0; attempt < 5; attempt += 1) { + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const context = canvas.getContext('2d'); + if (!context) throw new Error('Das Bild konnte nicht verarbeitet werden.'); + context.imageSmoothingQuality = 'high'; + context.drawImage(image, sx, sy, side, side, 0, 0, size, size); + + // WebP zuerst, dann JPEG, dann PNG: aeltere Browser liefern bei einem nicht + // unterstuetzten Typ stillschweigend PNG zurueck - das faellt hier auf. + let dataUrl = ''; + for (const type of ['image/webp', 'image/jpeg', 'image/png']) { + const candidate = canvas.toDataURL(type, QUALITY); + if (candidate.startsWith(`data:${type}`)) { + dataUrl = candidate; + break; + } + } + if (!dataUrl) throw new Error('Das Bild konnte nicht umgewandelt werden.'); + + last = dataUrl; + if (dataUrlBytes(dataUrl) <= MAX_RESULT_BYTES) return dataUrl; + size = Math.round(size * 0.8); + } + + return last; +} + +function readAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(new Error('Die Datei konnte nicht gelesen werden.')); + reader.readAsDataURL(file); + }); +} + +function loadImage(source: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + image.crossOrigin = 'anonymous'; + image.onload = () => resolve(image); + image.onerror = () => reject(new Error('Das Bild konnte nicht geladen werden.')); + image.src = source; + }); +} diff --git a/src/lib/kanban.ts b/src/lib/kanban.ts new file mode 100644 index 0000000..5d291ad --- /dev/null +++ b/src/lib/kanban.ts @@ -0,0 +1,105 @@ +import { useCallback, useState, type DragEvent } from 'react'; + +/* + * Ziehen und Fallenlassen auf den Kanban-Brettern (Leads, Ausschreibungen). + * + * Die Gesten stecken hier und nicht in den Ansichten, weil sie dreimal an + * denselben Stellen schiefgehen: + * + * 1. **Ohne Nutzlast keine Geste.** Firefox startet eine Ziehgeste nur, wenn + * `dragstart` etwas in `dataTransfer` legt. Chrome ist da nachsichtiger - + * deshalb faellt es beim Entwickeln nicht auf und beim Benutzen sofort. + * 2. **Der Zustand allein reicht nicht.** Die Kennung wird zusaetzlich in + * `dataTransfer` mitgegeben. Sie ueberlebt damit auch einen Neuaufbau der + * Liste waehrend der Geste. + * 3. **Ohne `dropEffect` zeigt der Zeiger ein Verbotsschild**, obwohl das + * Fallenlassen erlaubt ist. + * + * Bilder in den Kacheln (Logo, Foto) muessen zusaetzlich `draggable={false}` + * tragen - sonst zieht der Browser das Bild statt der Kachel. Das erledigt die + * Avatar-Komponente. + */ + +/** Eigenes Format neben `text/plain`: erkennt eigene Kacheln von fremdem Text. */ +const MIME = 'application/x-icon-card'; + +interface KanbanDrag { + /** Kennung der Kachel am Zeiger - fuer die gedaempfte Darstellung. */ + draggedId: number | null; + /** Spalte unter dem Zeiger - fuer die Hervorhebung des Ziels. */ + hoverColumn: K | null; + /** Auf die Kachel legen. */ + cardProps: (id: number) => { + draggable: true; + onDragStart: (event: DragEvent) => void; + onDragEnd: () => void; + }; + /** Auf die Spalte legen. */ + columnProps: (column: K) => { + onDragOver: (event: DragEvent) => void; + onDragLeave: (event: DragEvent) => void; + onDrop: (event: DragEvent) => void; + }; +} + +export function useKanbanDrag( + onMove: (id: number, column: K) => void +): KanbanDrag { + const [draggedId, setDraggedId] = useState(null); + const [hoverColumn, setHoverColumn] = useState(null); + + const cardProps = useCallback( + (id: number) => + ({ + draggable: true, + onDragStart: (event: DragEvent) => { + event.dataTransfer.setData(MIME, String(id)); + event.dataTransfer.setData('text/plain', String(id)); + event.dataTransfer.effectAllowed = 'move'; + setDraggedId(id); + }, + onDragEnd: () => { + setDraggedId(null); + setHoverColumn(null); + }, + }) as const, + [] + ); + + const columnProps = useCallback( + (column: K) => ({ + onDragOver: (event: DragEvent) => { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + setHoverColumn((current) => (current === column ? current : column)); + }, + onDragLeave: (event: DragEvent) => { + // Der Zeiger wandert staendig ueber Kindelemente - nur das Verlassen + // der Spalte selbst zaehlt. + if (event.currentTarget.contains(event.relatedTarget as Node | null)) return; + setHoverColumn((current) => (current === column ? null : current)); + }, + onDrop: (event: DragEvent) => { + event.preventDefault(); + const carried = event.dataTransfer.getData(MIME) || event.dataTransfer.getData('text/plain'); + const id = draggedId ?? Number(carried); + setDraggedId(null); + setHoverColumn(null); + if (Number.isInteger(id) && id > 0) onMove(id, column); + }, + }), + [draggedId, onMove] + ); + + return { draggedId, hoverColumn, cardProps, columnProps }; +} + +/** Rahmen der Spalte, solange eine Kachel darueber schwebt. */ +export function columnClass(isHover: boolean): string { + return isHover ? 'border-brand-400 bg-brand-50/40' : 'border-ink-200 bg-canvas'; +} + +/** Die Kachel am Zeiger wird gedaempft - sonst sieht man nicht, was man zieht. */ +export function cardClass(isDragged: boolean): string { + return isDragged ? 'opacity-40' : ''; +} diff --git a/src/lib/mentions.tsx b/src/lib/mentions.tsx new file mode 100644 index 0000000..c7aff12 --- /dev/null +++ b/src/lib/mentions.tsx @@ -0,0 +1,319 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { searchApi, type MentionCandidate } from './api'; +import { useNavigation } from './navigation'; +import { initials } from '../types'; + +/* + * @-Erwaehnungen. + * + * Gespeichert wird ein Token: `@[Anzeigename](contact:12)`, ebenso fuer + * `company` und `user`. Der Name steht mit im Token, damit ein Text auch dann + * lesbar bleibt, wenn die Listen nicht geladen sind. + * + * Jede Erwaehnung ist anklickbar und oeffnet das zugehoerige Profil - und sie + * landet ueber den Index in icon_mentions in der Historie des Genannten. + */ + +const MENTION_TOKEN = /@\[([^\]\n]+)\]\((contact|company|user):(\d+)\)/g; + +export type MentionKind = 'contact' | 'company' | 'user'; + +type Segment = + | { type: 'text'; value: string } + | { type: 'mention'; kind: MentionKind; id: number; label: string }; + +export function parseMentions(text: string): Segment[] { + const segments: Segment[] = []; + let lastIndex = 0; + + MENTION_TOKEN.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = MENTION_TOKEN.exec(text)) !== null) { + if (match.index > lastIndex) segments.push({ type: 'text', value: text.slice(lastIndex, match.index) }); + segments.push({ + type: 'mention', + label: match[1], + kind: match[2] as MentionKind, + id: Number(match[3]), + }); + lastIndex = match.index + match[0].length; + } + if (lastIndex < text.length) segments.push({ type: 'text', value: text.slice(lastIndex) }); + return segments; +} + +/** Text ohne Token-Syntax - fuer Vorschauen und Kurzfassungen. */ +export function mentionPlainText(text: string): string { + return parseMentions(text) + .map((segment) => (segment.type === 'text' ? segment.value : `@${segment.label}`)) + .join(''); +} + +/** Enthält der Text mindestens eine Erwähnung? */ +export function hasMentions(text: string | null | undefined): boolean { + if (!text) return false; + MENTION_TOKEN.lastIndex = 0; + return MENTION_TOKEN.test(text); +} + +export function mentionToken(candidate: MentionCandidate): string { + // Klammern im Namen wuerden das Token zerlegen. + const label = candidate.label.replace(/[[\]()]/g, '').trim() || 'Unbenannt'; + return `@[${label}](${candidate.kind}:${candidate.id})`; +} + +/** Rendert einen Text mit klickbaren Erwaehnungen. */ +export const MentionText: React.FC<{ text: string | null | undefined; className?: string }> = ({ + text, + className, +}) => { + const { open } = useNavigation(); + if (!text) return null; + + return ( + + {parseMentions(text).map((segment, index) => + segment.type === 'text' ? ( + {segment.value} + ) : ( + + ) + )} + + ); +}; + +interface MentionFieldProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + rows?: number; + className?: string; + id?: string; +} + +/** + * Textfeld mit Vorschlagsliste: ein `@` oeffnet sie, die Auswahl setzt den + * Token an der Schreibmarke ein. + */ +export const MentionField: React.FC = ({ + value, + onChange, + placeholder, + rows = 4, + className = '', + id, +}) => { + /* + * Zwei Zustaende. + * + * lesen der Text, wie er gemeint ist - Erwaehnungen sind Knoepfe und + * fuehren auf das Profil + * bearbeiten der Rohtext mit dem Token `@[Name](contact:12)`, denn anders + * laesst sich eine Erwaehnung nicht setzen oder loeschen + * + * Vorher stand beides untereinander: oben der Rohtext, darunter dieselbe + * Aussage noch einmal als Vorschau. Das war doppelt und niemand hat die + * Vorschau als das Klickbare erkannt. + * + * Ein leeres Feld startet gleich im Bearbeiten-Zustand - sonst muesste man + * erst einen Knopf finden, um ueberhaupt etwas schreiben zu koennen. + */ + const [editing, setEditing] = useState(() => !value.trim()); + const textareaRef = useRef(null); + const [candidates, setCandidates] = useState([]); + const [queryText, setQueryText] = useState(null); + const [active, setActive] = useState(0); + + // Steht die Schreibmarke hinter einem @ samt angefangenem Wort, ist das die + // Suche. Ein Leerzeichen im Wort beendet sie - sonst stuende die Liste bei + // jedem Satz offen, der ein @ enthaelt. + const detectQuery = (text: string, caret: number): string | null => { + const upToCaret = text.slice(0, caret); + const match = /@([^\s@[\]()]{0,40})$/.exec(upToCaret); + return match ? match[1] : null; + }; + + useEffect(() => { + if (queryText === null) { + setCandidates([]); + return; + } + let cancelled = false; + const timer = setTimeout(async () => { + try { + const { candidates: found } = await searchApi.mentions(queryText); + if (!cancelled) { + setCandidates(found.slice(0, 8)); + setActive(0); + } + } catch { + if (!cancelled) setCandidates([]); + } + }, 150); + + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [queryText]); + + const insert = (candidate: MentionCandidate) => { + const field = textareaRef.current; + if (!field) return; + const caret = field.selectionStart ?? value.length; + const before = value.slice(0, caret).replace(/@([^\s@[\]()]{0,40})$/, ''); + const after = value.slice(caret); + const token = `${mentionToken(candidate)} `; + + onChange(`${before}${token}${after}`); + setQueryText(null); + + // Die Schreibmarke hinter den eingefuegten Token setzen - sonst springt + // sie ans Ende und der Satz wird von hinten weitergeschrieben. + requestAnimationFrame(() => { + const position = before.length + token.length; + field.focus(); + field.setSelectionRange(position, position); + }); + }; + + const onKeyDown = (event: React.KeyboardEvent) => { + if (queryText === null || candidates.length === 0) return; + if (event.key === 'ArrowDown') { + event.preventDefault(); + setActive((index) => (index + 1) % candidates.length); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + setActive((index) => (index - 1 + candidates.length) % candidates.length); + } else if (event.key === 'Enter' || event.key === 'Tab') { + event.preventDefault(); + insert(candidates[active]); + } else if (event.key === 'Escape') { + event.preventDefault(); + setQueryText(null); + } + }; + + const hint = useMemo( + () => 'Mit @ lassen sich Kontakte, Unternehmen und Kollegen nennen.', + [] + ); + + if (!editing) { + return ( +

    +
    +

    + {value.trim() ? ( + + ) : ( + {placeholder ?? 'Kein Text'} + )} +

    + +
    + {hasMentions(value) && ( +

    + Erwähnungen sind anklickbar und öffnen das Profil. +

    + )} +
    + ); + } + + return ( +
    +