Quellcode des ICON CRM (6)
This commit is contained in:
@@ -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<ModalProps> = ({
|
||||
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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-ink-950/60 p-4 backdrop-blur-sm sm:p-8"
|
||||
// Nur ein Klick auf die Flaeche selbst schliesst - ein Klick im Fenster
|
||||
// steigt zwar hierher auf, hat dann aber ein anderes Ziel.
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={`my-auto w-full ${SIZES[size]} overflow-hidden rounded-2xl bg-surface shadow-lift`}
|
||||
>
|
||||
<header className="flex items-start gap-4 border-b border-ink-200 px-6 py-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate text-lg font-bold text-ink-900">{title}</h2>
|
||||
{subtitle && <p className="mt-0.5 truncate text-sm text-ink-400">{subtitle}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
className="-mr-2 -mt-1 rounded-lg p-2 text-ink-400 hover:bg-ink-100 hover:text-ink-700"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" className="h-5 w-5" fill="currentColor" aria-hidden="true">
|
||||
<path d="M6.3 5A.9.9 0 0 0 5 6.3L8.7 10 5 13.7A.9.9 0 1 0 6.3 15L10 11.3 13.7 15a.9.9 0 0 0 1.3-1.3L11.3 10 15 6.3A.9.9 0 0 0 13.7 5L10 8.7Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="max-h-[calc(100vh-14rem)] overflow-y-auto px-6 py-5 scrollbar-slim">{children}</div>
|
||||
|
||||
{footer && (
|
||||
<footer className="flex items-center justify-end gap-2 border-t border-ink-200 bg-ink-50 px-6 py-3">
|
||||
{footer}
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ConfirmProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: React.ReactNode;
|
||||
confirmLabel?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
export const ConfirmDialog: React.FC<ConfirmProps> = ({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Löschen',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
danger = true,
|
||||
}) => (
|
||||
<Modal
|
||||
open={open}
|
||||
title={title}
|
||||
onClose={onCancel}
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-lg border border-ink-200 px-4 py-2 text-sm font-medium text-ink-700 hover:bg-ink-100"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-semibold text-white ${
|
||||
danger ? 'bg-red-600 hover:bg-red-700' : 'bg-brand-600 hover:bg-brand-700'
|
||||
}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-ink-700">{message}</p>
|
||||
</Modal>
|
||||
);
|
||||
@@ -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: <strong>F</strong>, title: 'Fett (Strg+B)' },
|
||||
{ command: 'italic', label: <em>K</em>, 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<RichTextEditorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
minHeight = 220,
|
||||
actions,
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement>(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 (
|
||||
<div className="rounded-lg border border-ink-200 bg-surface">
|
||||
<div className="flex flex-wrap items-center gap-1 border-b border-ink-200 px-2 py-1.5">
|
||||
{BUTTONS.map((button) => (
|
||||
<button
|
||||
key={`${button.command}-${button.argument ?? ''}`}
|
||||
type="button"
|
||||
title={button.title}
|
||||
// Der Editor darf den Fokus nicht verlieren - sonst geht die
|
||||
// markierte Stelle verloren, bevor der Befehl greift.
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => run(button)}
|
||||
className="min-w-[2rem] rounded px-2 py-1 text-sm text-ink-600 hover:bg-ink-100 hover:text-ink-900"
|
||||
>
|
||||
{button.label}
|
||||
</button>
|
||||
))}
|
||||
{actions && <span className="ml-auto flex flex-wrap items-center gap-1">{actions}</span>}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={ref}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
data-placeholder={placeholder}
|
||||
style={{ minHeight }}
|
||||
onInput={(event) => onChange((event.target as HTMLDivElement).innerHTML)}
|
||||
onPaste={(event) => {
|
||||
/*
|
||||
* Aus Word oder einem Portal kommt HTML mit fremden Schriftgroessen,
|
||||
* Farben und <span style>-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"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<StarRatingProps> = ({ value, onChange, size = 16, className = '' }) => {
|
||||
const readOnly = !onChange;
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-0.5 ${className}`} title={`${value} von 5`}>
|
||||
{[1, 2, 3, 4, 5].map((star) => {
|
||||
const filled = star <= value;
|
||||
const icon = (
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
style={{ width: size, height: size }}
|
||||
fill={filled ? 'currentColor' : 'none'}
|
||||
stroke="currentColor"
|
||||
strokeWidth={filled ? 0 : 1.4}
|
||||
className={filled ? 'text-brand-500' : 'text-ink-300'}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M10 1.8l2.5 5.1 5.6.8-4 3.9.9 5.6L10 14.6l-5 2.6.9-5.6-4-3.9 5.6-.8z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
if (readOnly) return <span key={star}>{icon}</span>;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onChange?.(value === star ? 0 : star);
|
||||
}}
|
||||
aria-label={`${star} von 5 Sternen`}
|
||||
className="rounded p-0.5 hover:scale-110"
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -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 = '' }) => (
|
||||
<div className={className}>
|
||||
<label className={labelClass}>{label}</label>
|
||||
{children}
|
||||
{hint && <p className="mt-1 text-xs text-ink-400">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const PrimaryButton: React.FC<React.ButtonHTMLAttributes<HTMLButtonElement>> = ({
|
||||
className = '',
|
||||
...props
|
||||
}) => (
|
||||
<button
|
||||
{...props}
|
||||
className={`rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 disabled:opacity-50 ${className}`}
|
||||
/>
|
||||
);
|
||||
|
||||
export const SecondaryButton: React.FC<React.ButtonHTMLAttributes<HTMLButtonElement>> = ({
|
||||
className = '',
|
||||
...props
|
||||
}) => (
|
||||
<button
|
||||
{...props}
|
||||
className={`rounded-lg border border-ink-200 px-4 py-2 text-sm font-medium text-ink-700 hover:bg-ink-100 disabled:opacity-50 ${className}`}
|
||||
/>
|
||||
);
|
||||
|
||||
/** Getoente Pille - Status, Art, Kennzeichen. */
|
||||
export const Pill: React.FC<{ tone?: string; children: React.ReactNode; className?: string }> = ({
|
||||
tone = 'ink',
|
||||
children,
|
||||
className = '',
|
||||
}) => (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${TONES[tone] ?? TONES.ink} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
const TONES: Record<string, string> = {
|
||||
ink: 'border-ink-200 bg-ink-50 text-ink-600',
|
||||
brand: 'border-brand-200 bg-brand-50 text-brand-700',
|
||||
sky: 'border-sky-200 bg-sky-50 text-sky-700',
|
||||
emerald: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||
amber: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||
violet: 'border-violet-200 bg-violet-50 text-violet-700',
|
||||
red: 'border-red-200 bg-red-50 text-red-700',
|
||||
};
|
||||
|
||||
/** Umschalter zwischen zwei oder mehr Ansichten. */
|
||||
export function Segmented<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
value: T;
|
||||
options: Array<{ value: T; label: React.ReactNode; title?: string }>;
|
||||
onChange: (value: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="inline-flex rounded-lg border border-ink-200 bg-surface p-0.5">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
title={option.title}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
value === option.value ? 'bg-brand-600 text-white' : 'text-ink-600 hover:bg-ink-100'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+652
@@ -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 <h2>, <ul> und <p> 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;
|
||||
}
|
||||
}
|
||||
+415
@@ -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<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
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 = <T>(path: string) => apiRequest<T>(path);
|
||||
const post = <T>(path: string, body?: unknown) =>
|
||||
apiRequest<T>(path, { method: 'POST', body: body === undefined ? undefined : JSON.stringify(body) });
|
||||
const patch = <T>(path: string, body: unknown) =>
|
||||
apiRequest<T>(path, { method: 'PATCH', body: JSON.stringify(body) });
|
||||
const del = <T>(path: string) => apiRequest<T>(path, { method: 'DELETE' });
|
||||
|
||||
/** Haengt nur gesetzte Werte als Abfrageparameter an. */
|
||||
function query(params: Record<string, unknown>): 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<Pick<AppUser, 'color' | 'avatar' | 'theme' | 'theme_mode'>>) =>
|
||||
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<string, unknown>) => post<{ user: AppUser }>('/users', body),
|
||||
update: (id: number, body: Record<string, unknown>) => patch<{ user: AppUser }>(`/users/${id}`, body),
|
||||
remove: (id: number) => del<{ ok: true }>(`/users/${id}`),
|
||||
};
|
||||
|
||||
export interface CompanyDetail {
|
||||
company: Company;
|
||||
contacts: Array<Pick<Contact, 'id' | 'first_name' | 'last_name' | 'position' | 'email' | 'phone' | 'avatar' | 'kind' | 'status'>>;
|
||||
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<string, unknown> = {}) =>
|
||||
get<{ companies: Company[] }>(`/companies${query(params)}`),
|
||||
options: () => get<{ industries: string[]; groups: string[] }>('/companies/options'),
|
||||
detail: (id: number) => get<CompanyDetail>(`/companies/${id}`),
|
||||
create: (body: Record<string, unknown>) => post<{ company: Company }>('/companies', body),
|
||||
update: (id: number, body: Record<string, unknown>) =>
|
||||
patch<{ company: Company }>(`/companies/${id}`, body),
|
||||
remove: (id: number) => del<{ ok: true }>(`/companies/${id}`),
|
||||
addInterview: (id: number, body: Record<string, unknown>) =>
|
||||
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<string, unknown> = {}) =>
|
||||
get<{ contacts: Contact[] }>(`/contacts${query(params)}`),
|
||||
detail: (id: number) => get<ContactDetail>(`/contacts/${id}`),
|
||||
create: (body: Record<string, unknown>) => post<{ contact: Contact }>('/contacts', body),
|
||||
update: (id: number, body: Record<string, unknown>) =>
|
||||
patch<{ contact: Contact }>(`/contacts/${id}`, body),
|
||||
remove: (id: number) => del<{ ok: true }>(`/contacts/${id}`),
|
||||
addInterview: (id: number, body: Record<string, unknown>) =>
|
||||
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<string, unknown>) =>
|
||||
post<{ id: number }>(`/contacts/${id}/employments`, body),
|
||||
updateEmployment: (employmentId: number, body: Record<string, unknown>) =>
|
||||
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<string, unknown> = {}) => get<{ tenders: Tender[] }>(`/tenders${query(params)}`),
|
||||
detail: (id: number) =>
|
||||
get<{ tender: Tender; contacts: Contact[]; history: HistoryEntry[] }>(`/tenders/${id}`),
|
||||
create: (body: Record<string, unknown>) => post<{ tender: Tender }>('/tenders', body),
|
||||
update: (id: number, body: Record<string, unknown>) => patch<{ tender: Tender }>(`/tenders/${id}`, body),
|
||||
remove: (id: number) => del<{ ok: true }>(`/tenders/${id}`),
|
||||
};
|
||||
|
||||
export const projectsApi = {
|
||||
list: (params: Record<string, unknown> = {}) =>
|
||||
get<{ projects: Project[] }>(`/projects${query(params)}`),
|
||||
create: (body: Record<string, unknown>) => post<{ project: Project }>('/projects', body),
|
||||
update: (id: number, body: Record<string, unknown>) =>
|
||||
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<string, unknown>) => post<{ lead: Lead }>('/leads', body),
|
||||
update: (id: number, body: Record<string, unknown>) => 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<string, unknown>) =>
|
||||
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<string, unknown> = {}) =>
|
||||
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<AiConfig>('/ai/config'),
|
||||
saveConfig: (changes: { key?: string; model?: string }) => patch<AiConfig>('/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<string, unknown> = {}) =>
|
||||
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<Record<string, unknown> & { 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<AiAnswer>('/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<Company & { id: number }>;
|
||||
contacts: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
position: string | null;
|
||||
avatar: string | null;
|
||||
kind: string;
|
||||
status: string;
|
||||
company_id: number | null;
|
||||
}>;
|
||||
edges: Array<Record<string, unknown> & { type: 'employment' | 'former' | 'contact' | 'group' }>;
|
||||
groups: Array<{ name: string; count: number }>;
|
||||
}
|
||||
|
||||
export const networkApi = {
|
||||
load: (params: Record<string, unknown> = {}) => get<NetworkData>(`/network${query(params)}`),
|
||||
};
|
||||
|
||||
export const connectionsApi = {
|
||||
list: () => get<{ connections: Connection[] }>('/connections'),
|
||||
create: (contact_a: number, contact_b: number, kind?: string) =>
|
||||
post<Connection>('/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<string, number>;
|
||||
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<DashboardData>('/dashboard'),
|
||||
};
|
||||
@@ -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<DecodedAudio> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<void>;
|
||||
logout: () => void;
|
||||
/** Aktualisiert den lokalen Benutzer, z. B. nach Bearbeiten des eigenen Kontos. */
|
||||
updateUser: (user: AppUser) => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<AppUser | null>(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<AuthContextValue>(
|
||||
() => ({
|
||||
user,
|
||||
isLoading,
|
||||
isAdmin: user?.role === 'admin',
|
||||
login,
|
||||
logout,
|
||||
updateUser: setUser,
|
||||
}),
|
||||
[user, isLoading, login, logout]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth muss innerhalb von <AuthProvider> verwendet werden.');
|
||||
return ctx;
|
||||
}
|
||||
@@ -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<void>;
|
||||
stop: () => void;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
export function useRecorder(
|
||||
onText: (text: string) => void,
|
||||
onError: (message: string) => void
|
||||
): UseRecorderResult {
|
||||
const { transcribe, status } = useWhisper();
|
||||
const [state, setState] = useState<RecorderState>('idle');
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const timerRef = useRef<number | null>(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 };
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<HTMLImageElement> {
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -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<K extends string> {
|
||||
/** 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<HTMLElement>) => void;
|
||||
onDragEnd: () => void;
|
||||
};
|
||||
/** Auf die Spalte legen. */
|
||||
columnProps: (column: K) => {
|
||||
onDragOver: (event: DragEvent<HTMLElement>) => void;
|
||||
onDragLeave: (event: DragEvent<HTMLElement>) => void;
|
||||
onDrop: (event: DragEvent<HTMLElement>) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function useKanbanDrag<K extends string>(
|
||||
onMove: (id: number, column: K) => void
|
||||
): KanbanDrag<K> {
|
||||
const [draggedId, setDraggedId] = useState<number | null>(null);
|
||||
const [hoverColumn, setHoverColumn] = useState<K | null>(null);
|
||||
|
||||
const cardProps = useCallback(
|
||||
(id: number) =>
|
||||
({
|
||||
draggable: true,
|
||||
onDragStart: (event: DragEvent<HTMLElement>) => {
|
||||
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<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
setHoverColumn((current) => (current === column ? current : column));
|
||||
},
|
||||
onDragLeave: (event: DragEvent<HTMLElement>) => {
|
||||
// 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<HTMLElement>) => {
|
||||
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' : '';
|
||||
}
|
||||
@@ -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 (
|
||||
<span className={className}>
|
||||
{parseMentions(text).map((segment, index) =>
|
||||
segment.type === 'text' ? (
|
||||
<React.Fragment key={index}>{segment.value}</React.Fragment>
|
||||
) : (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (segment.kind === 'user') return;
|
||||
open(segment.kind, segment.id);
|
||||
}}
|
||||
className={
|
||||
segment.kind === 'user'
|
||||
? 'mx-0.5 rounded bg-ink-100 px-1 font-medium text-ink-700'
|
||||
: 'mx-0.5 rounded bg-brand-100 px-1 font-medium text-brand-700 hover:bg-brand-200'
|
||||
}
|
||||
title={segment.kind === 'company' ? 'Unternehmen öffnen' : 'Profil öffnen'}
|
||||
>
|
||||
@{segment.label}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
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<MentionFieldProps> = ({
|
||||
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<HTMLTextAreaElement>(null);
|
||||
const [candidates, setCandidates] = useState<MentionCandidate[]>([]);
|
||||
const [queryText, setQueryText] = useState<string | null>(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<HTMLTextAreaElement>) => {
|
||||
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 (
|
||||
<div className="rounded-lg border border-ink-200 bg-surface">
|
||||
<div className="flex items-start gap-2 px-3 py-2">
|
||||
<p className="min-w-0 flex-1 whitespace-pre-wrap text-sm text-ink-800">
|
||||
{value.trim() ? (
|
||||
<MentionText text={value} />
|
||||
) : (
|
||||
<span className="text-ink-400">{placeholder ?? 'Kein Text'}</span>
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditing(true);
|
||||
// Nach dem Wechsel steht das Feld erst im naechsten Frame.
|
||||
requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
}}
|
||||
className="shrink-0 rounded-lg px-2 py-1 text-xs font-medium text-ink-500 hover:bg-ink-100 hover:text-ink-800"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
</div>
|
||||
{hasMentions(value) && (
|
||||
<p className="border-t border-ink-100 px-3 py-1.5 text-xs text-ink-400">
|
||||
Erwähnungen sind anklickbar und öffnen das Profil.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<textarea
|
||||
id={id}
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
rows={rows}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => {
|
||||
onChange(event.target.value);
|
||||
setQueryText(detectQuery(event.target.value, event.target.selectionStart ?? 0));
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={() => {
|
||||
// Verzoegert, damit ein Klick auf einen Vorschlag noch ankommt.
|
||||
setTimeout(() => setQueryText(null), 150);
|
||||
}}
|
||||
className={`w-full rounded-lg border border-ink-200 bg-surface px-3 py-2 text-sm text-ink-900 placeholder:text-ink-400 ${className}`}
|
||||
/>
|
||||
<div className="mt-1 flex items-start gap-2">
|
||||
<p className="min-w-0 flex-1 text-xs text-ink-400">{hint}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(false)}
|
||||
className="shrink-0 rounded-lg px-2 py-1 text-xs font-medium text-brand-700 hover:bg-brand-50"
|
||||
>
|
||||
Fertig
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{queryText !== null && candidates.length > 0 && (
|
||||
<ul className="absolute z-30 mt-1 max-h-64 w-full overflow-auto rounded-lg border border-ink-200 bg-surface shadow-lift">
|
||||
{candidates.map((candidate, index) => (
|
||||
<li key={`${candidate.kind}-${candidate.id}`}>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => insert(candidate)}
|
||||
onMouseEnter={() => setActive(index)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm ${
|
||||
index === active ? 'bg-brand-50' : ''
|
||||
}`}
|
||||
>
|
||||
{candidate.avatar ? (
|
||||
<img src={candidate.avatar} alt="" className="h-6 w-6 rounded-full object-cover" />
|
||||
) : (
|
||||
<span
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-semibold text-white"
|
||||
style={{ backgroundColor: candidate.color || '#93939b' }}
|
||||
>
|
||||
{initials(candidate.label)}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-medium text-ink-900">{candidate.label}</span>
|
||||
<span className="ml-auto text-xs text-ink-400">
|
||||
{candidate.kind === 'user'
|
||||
? 'Benutzer'
|
||||
: candidate.kind === 'company'
|
||||
? 'Unternehmen'
|
||||
: candidate.hint || 'Kontakt'}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import React, { createContext, useContext } from 'react';
|
||||
import type { View } from '../types';
|
||||
|
||||
/*
|
||||
* Ansichtswechsel fuer tief liegende Komponenten.
|
||||
*
|
||||
* Eine angeklickte @-Erwaehnung oder ein Treffer der globalen Suche muss die
|
||||
* passende Karte oeffnen koennen, ohne dass jede Ebene dazwischen einen
|
||||
* Rueckruf durchreicht.
|
||||
*/
|
||||
|
||||
export type OpenTarget = 'contact' | 'company' | 'tender' | 'project' | 'lead' | 'user';
|
||||
|
||||
/** Reiter der Einstellungen - auch von aussen ansteuerbar. */
|
||||
export type SettingsTab = 'konto' | 'benutzer' | 'stammdaten' | 'ki' | 'diktat';
|
||||
|
||||
interface NavigationValue {
|
||||
setView: (view: View) => void;
|
||||
/** Oeffnet die Karte eines Datensatzes - wechselt dabei die Ansicht. */
|
||||
open: (type: OpenTarget, id: number) => void;
|
||||
/**
|
||||
* Wechselt in die Einstellungen und oeffnet dort einen bestimmten Reiter.
|
||||
*
|
||||
* Gebraucht fuer das Diktat: der Knopf im Formular meldet, dass das
|
||||
* Sprachmodell fehlt, und muss direkt dorthin fuehren koennen, wo es geladen
|
||||
* wird - ein blosser Hinweis "steht in den Einstellungen" waere eine
|
||||
* Suchaufgabe.
|
||||
*/
|
||||
goToSettings: (tab: SettingsTab) => void;
|
||||
}
|
||||
|
||||
const NavigationContext = createContext<NavigationValue | null>(null);
|
||||
|
||||
export const NavigationProvider: React.FC<{ value: NavigationValue; children: React.ReactNode }> = ({
|
||||
value,
|
||||
children,
|
||||
}) => <NavigationContext.Provider value={value}>{children}</NavigationContext.Provider>;
|
||||
|
||||
export function useNavigation(): NavigationValue {
|
||||
const ctx = useContext(NavigationContext);
|
||||
if (!ctx) throw new Error('useNavigation muss innerhalb von <NavigationProvider> verwendet werden.');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { optionsApi } from './api';
|
||||
import { useAuth } from './auth';
|
||||
import { DEFAULT_CONTACT_KINDS } from '../types';
|
||||
|
||||
/*
|
||||
* Die gepflegten Auswahllisten (Einstellungen → Stammdaten).
|
||||
*
|
||||
* Sie werden einmal nach der Anmeldung geladen und von jedem Formular
|
||||
* mitbenutzt - Branche und Kontaktart tauchen an einem halben Dutzend Stellen
|
||||
* auf, und jede davon einzeln laden zu lassen hieße, dieselbe Anfrage
|
||||
* mehrfach zu stellen.
|
||||
*
|
||||
* Solange nichts geladen ist, gelten die Vorgabewerte: ein leeres Auswahlfeld
|
||||
* wäre schlimmer als eine unvollständige Liste.
|
||||
*/
|
||||
|
||||
interface OptionsValue {
|
||||
industries: string[];
|
||||
contactKinds: string[];
|
||||
reload: () => Promise<void>;
|
||||
}
|
||||
|
||||
const OptionsContext = createContext<OptionsValue | null>(null);
|
||||
|
||||
export const OptionsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { user } = useAuth();
|
||||
const [industries, setIndustries] = useState<string[]>([]);
|
||||
const [contactKinds, setContactKinds] = useState<string[]>(DEFAULT_CONTACT_KINDS);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
const { fields } = await optionsApi.list();
|
||||
const values = (field: string) =>
|
||||
fields.find((entry) => entry.field === field)?.values.map((value) => value.value) ?? [];
|
||||
|
||||
setIndustries(values('industry'));
|
||||
const kinds = values('contact_kind');
|
||||
setContactKinds(kinds.length > 0 ? kinds : DEFAULT_CONTACT_KINDS);
|
||||
} catch {
|
||||
/* Ohne Liste gelten die Vorgabewerte - kein Grund, die Anwendung zu stoppen. */
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const value = useMemo<OptionsValue>(
|
||||
() => ({ industries, contactKinds, reload }),
|
||||
[industries, contactKinds, reload]
|
||||
);
|
||||
|
||||
return <OptionsContext.Provider value={value}>{children}</OptionsContext.Provider>;
|
||||
};
|
||||
|
||||
export function useOptions(): OptionsValue {
|
||||
const ctx = useContext(OptionsContext);
|
||||
if (!ctx) throw new Error('useOptions muss innerhalb von <OptionsProvider> verwendet werden.');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Hilfen für die WYSIWYG-Felder.
|
||||
*
|
||||
* Der Bestand ist gemischt: manche Texte sind reiner Text mit Zeilenumbrüchen
|
||||
* (aus dem alten CRM), manche sind HTML (aus dem Ausschreibungstext kopiert),
|
||||
* und manche sind beides - ein paar Kopfzeilen als Text, darunter ein HTML-Block.
|
||||
*
|
||||
* Ohne Umwandlung zeigt ein WYSIWYG-Feld den reinen Text als einen einzigen
|
||||
* Absatz und - schlimmer - HTML-Quelltext als sichtbare spitze Klammern.
|
||||
*/
|
||||
|
||||
/** Enthält der Text bereits Auszeichnung? */
|
||||
function hasTags(text: string): boolean {
|
||||
return /<[a-z][\s\S]*>/i.test(text);
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/** Reinen Text in Absätze umwandeln; Leerzeilen trennen, einfache Umbrüche bleiben. */
|
||||
function paragraphs(text: string): string {
|
||||
return text
|
||||
.split(/\n{2,}/)
|
||||
.map((block) => block.trim())
|
||||
.filter(Boolean)
|
||||
.map((block) => `<p>${escapeHtml(block).replace(/\n/g, '<br>')}</p>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
/*
|
||||
* Der Altbestand kommt aus einem anderen Editor (Quill) und bringt drei Dinge
|
||||
* mit, die hier stoeren:
|
||||
*
|
||||
* 1. Inline-Farben - `background-color: rgb(255,255,255); color: rgb(34,34,34)`
|
||||
* an fast jedem Textstueck. Im hellen Modus faellt das nicht auf, im
|
||||
* dunklen wird daraus schwarze Schrift auf weissen Kaesten.
|
||||
* 2. Geschuetzte Leerzeichen zwischen JEDEM Wort (bis zu 470 je Text). Sie
|
||||
* verbieten den Zeilenumbruch - der Text laeuft waagerecht aus dem Bild.
|
||||
* 3. Verschachtelte Listen `<li><ul>…` ohne eigenen Text im aeusseren
|
||||
* Punkt - daraus werden doppelte Aufzaehlungszeichen.
|
||||
*
|
||||
* Bereinigt wird bewusst zurueckhaltend: nur Farbe und Hintergrund fliegen aus
|
||||
* den Stilangaben, Fett und Kursiv bleiben. Dieselbe Aufraeumarbeit macht die
|
||||
* ICON Website beim Anzeigen ohnehin schon.
|
||||
*/
|
||||
function sanitize(html: string): string {
|
||||
if (typeof DOMParser === 'undefined') return html;
|
||||
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
|
||||
for (const element of Array.from(doc.body.querySelectorAll<HTMLElement>('[style]'))) {
|
||||
element.style.removeProperty('color');
|
||||
element.style.removeProperty('background-color');
|
||||
element.style.removeProperty('background');
|
||||
element.style.removeProperty('font-family');
|
||||
element.style.removeProperty('font-size');
|
||||
if (!element.getAttribute('style')?.trim()) element.removeAttribute('style');
|
||||
}
|
||||
|
||||
// Verschachtelte Liste, deren aeusserer Punkt nichts enthaelt: der Punkt
|
||||
// faellt weg, die innere Liste rueckt an seine Stelle.
|
||||
for (const nested of Array.from(doc.body.querySelectorAll('li > ul, li > ol'))) {
|
||||
const parent = nested.parentElement;
|
||||
if (!parent) continue;
|
||||
const hasOwnText = Array.from(parent.childNodes).some(
|
||||
(node) => node !== nested && (node.textContent ?? '').replace(/\u00a0/g, ' ').trim().length > 0
|
||||
);
|
||||
if (!hasOwnText) parent.replaceWith(...Array.from(nested.childNodes));
|
||||
}
|
||||
|
||||
// Geschuetzte Leerzeichen zu normalen machen - sonst bricht keine Zeile um.
|
||||
return doc.body.innerHTML.replace(/\u00a0/g, ' ').replace(/ /g, ' ');
|
||||
} catch {
|
||||
return html;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bringt einen gespeicherten Text in eine Form, die der Editor darstellen kann.
|
||||
*
|
||||
* Drei Fälle:
|
||||
* * reiner Text → Absätze
|
||||
* * reines HTML → unverändert
|
||||
* * Text, dann HTML → der Kopf wird zu Absätzen, der Rest bleibt
|
||||
*
|
||||
* Der dritte Fall ist kein Sonderfall, sondern der Normalfall bei den
|
||||
* Projekten: dort steht ein kurzer Kopf (KUNDE, KONTAKT, START) und darunter
|
||||
* der übernommene Ausschreibungstext.
|
||||
*/
|
||||
export function toEditorHtml(value: string | null | undefined): string {
|
||||
const text = String(value ?? '');
|
||||
if (!text.trim()) return '';
|
||||
if (!hasTags(text)) return paragraphs(text);
|
||||
|
||||
const firstTag = text.search(/<[a-z]/i);
|
||||
if (firstTag <= 0) return sanitize(text);
|
||||
|
||||
const head = text.slice(0, firstTag).trim();
|
||||
const rest = sanitize(text.slice(firstTag));
|
||||
return head ? `${paragraphs(head)}${rest}` : rest;
|
||||
}
|
||||
|
||||
/** Reiner Text aus HTML - für Vorschauen und um zu prüfen, ob etwas dasteht. */
|
||||
export function stripHtml(html: string): string {
|
||||
return String(html ?? '')
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { authApi } from './api';
|
||||
import { useAuth } from './auth';
|
||||
import { THEME_PALETTES, type ThemePalette } from '../types';
|
||||
|
||||
/*
|
||||
* Modus (hell/dunkel) und Design.
|
||||
*
|
||||
* Die Wahl liegt doppelt: im localStorage, damit das Inline-Skript in
|
||||
* index.html sie vor dem ersten Frame setzen kann, und am Benutzer in der
|
||||
* Datenbank, damit sie auf jedem Geraet gilt. Ohne Wahl folgt der Modus der
|
||||
* Systemeinstellung.
|
||||
*/
|
||||
|
||||
export type ThemeName = ThemePalette;
|
||||
export type ThemeMode = 'light' | 'dark';
|
||||
|
||||
const MODE_KEY = 'icon_crm_theme_mode';
|
||||
const THEME_KEY = 'icon_crm_theme';
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: ThemeName;
|
||||
mode: ThemeMode;
|
||||
/** true, solange der Benutzer nichts gewaehlt hat und das System entscheidet. */
|
||||
followsSystem: boolean;
|
||||
setTheme: (theme: ThemeName) => void;
|
||||
setMode: (mode: ThemeMode) => void;
|
||||
toggleMode: () => void;
|
||||
/** Zurueck auf "folgt dem System". */
|
||||
resetMode: () => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
function systemPrefersDark(): boolean {
|
||||
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
|
||||
}
|
||||
|
||||
function readStored<T extends string>(key: string): T | null {
|
||||
try {
|
||||
return (localStorage.getItem(key) as T | null) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function store(key: string, value: string | null) {
|
||||
try {
|
||||
if (value === null) localStorage.removeItem(key);
|
||||
else localStorage.setItem(key, value);
|
||||
} catch {
|
||||
/* Privater Modus oder gesperrte Website-Daten - dann gilt nur die Sitzung. */
|
||||
}
|
||||
}
|
||||
|
||||
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { user, updateUser } = useAuth();
|
||||
const [storedMode, setStoredMode] = useState<ThemeMode | null>(() => readStored<ThemeMode>(MODE_KEY));
|
||||
const [theme, setThemeState] = useState<ThemeName>(() => {
|
||||
// Ein Wert aus einer früheren Fassung (etwa ein entferntes Design) darf
|
||||
// nicht dazu führen, dass eine Klasse gesetzt wird, die es nicht gibt.
|
||||
const stored = readStored<ThemeName>(THEME_KEY);
|
||||
return stored && THEME_PALETTES.includes(stored) ? stored : 'icon';
|
||||
});
|
||||
const [systemDark, setSystemDark] = useState(systemPrefersDark);
|
||||
|
||||
// Die Wahl des angemeldeten Benutzers gewinnt gegen den lokalen Speicher:
|
||||
// sie gilt auf jedem Geraet.
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
if (user.theme_mode && user.theme_mode !== storedMode) {
|
||||
setStoredMode(user.theme_mode);
|
||||
store(MODE_KEY, user.theme_mode);
|
||||
}
|
||||
/*
|
||||
* Auch der Wert vom Server wird geprüft. Ein Design, das aus der Liste
|
||||
* geflogen ist, steht weiter am Konto - und hat die Anwendung schon einmal
|
||||
* beim Start abstürzen lassen (14.09.2026, "duke").
|
||||
*/
|
||||
if (user.theme && THEME_PALETTES.includes(user.theme as ThemeName) && user.theme !== theme) {
|
||||
setThemeState(user.theme as ThemeName);
|
||||
store(THEME_KEY, user.theme);
|
||||
}
|
||||
// Absichtlich nur an user gebunden - sonst schriebe jede lokale Aenderung
|
||||
// sich selbst wieder zurueck.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user?.id, user?.theme, user?.theme_mode]);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia?.('(prefers-color-scheme: dark)');
|
||||
if (!media) return;
|
||||
const listener = (event: MediaQueryListEvent) => setSystemDark(event.matches);
|
||||
media.addEventListener('change', listener);
|
||||
return () => media.removeEventListener('change', listener);
|
||||
}, []);
|
||||
|
||||
const mode: ThemeMode = storedMode ?? (systemDark ? 'dark' : 'light');
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle('dark', mode === 'dark');
|
||||
/*
|
||||
* Jede theme-* Klasse abräumen, nicht nur die bekannten: das Inline-Skript
|
||||
* in index.html setzt sie aus dem localStorage, und dort kann ein längst
|
||||
* entfernter Name stehen. Der bliebe sonst für immer am <html> hängen.
|
||||
*/
|
||||
for (const name of [...root.classList]) {
|
||||
if (name.startsWith('theme-')) root.classList.remove(name);
|
||||
}
|
||||
if (theme !== 'icon') root.classList.add(`theme-${theme}`);
|
||||
}, [mode, theme]);
|
||||
|
||||
const persist = useCallback(
|
||||
async (body: { theme?: ThemeName; theme_mode?: ThemeMode | null }) => {
|
||||
if (!user) return;
|
||||
try {
|
||||
const { user: updated } = await authApi.updateMe(body);
|
||||
updateUser(updated);
|
||||
} catch {
|
||||
/* Die Darstellung steht lokal schon richtig - ein Fehler beim Sichern
|
||||
darf den Wechsel nicht zurueckdrehen. */
|
||||
}
|
||||
},
|
||||
[user, updateUser]
|
||||
);
|
||||
|
||||
const setMode = useCallback(
|
||||
(next: ThemeMode) => {
|
||||
setStoredMode(next);
|
||||
store(MODE_KEY, next);
|
||||
void persist({ theme_mode: next });
|
||||
},
|
||||
[persist]
|
||||
);
|
||||
|
||||
const value = useMemo<ThemeContextValue>(
|
||||
() => ({
|
||||
theme,
|
||||
mode,
|
||||
followsSystem: storedMode === null,
|
||||
setTheme: (next: ThemeName) => {
|
||||
setThemeState(next);
|
||||
store(THEME_KEY, next);
|
||||
void persist({ theme: next });
|
||||
},
|
||||
setMode,
|
||||
toggleMode: () => setMode(mode === 'dark' ? 'light' : 'dark'),
|
||||
resetMode: () => {
|
||||
setStoredMode(null);
|
||||
store(MODE_KEY, null);
|
||||
void persist({ theme_mode: null });
|
||||
},
|
||||
}),
|
||||
[theme, mode, storedMode, setMode, persist]
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
};
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error('useTheme muss innerhalb von <ThemeProvider> verwendet werden.');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { toWhisperAudio } from './audio';
|
||||
import type { WorkerRequest, WorkerResponse } from './whisper.worker';
|
||||
|
||||
/**
|
||||
* Diktat laeuft vollstaendig im Browser: Whisper als ONNX-Modell ueber
|
||||
* transformers.js, gerechnet auf der GPU (WebGPU) oder ersatzweise auf der CPU
|
||||
* (WASM). Der Server ist daran nicht beteiligt - Aufnahmen verlassen das Geraet
|
||||
* nicht.
|
||||
*
|
||||
* Es gibt bewusst nichts einzustellen:
|
||||
*
|
||||
* large-v3-turbo - beste Erkennung im Deutschen, auch bei Fachbegriffen
|
||||
* ("S4 HANA"). small verliert genau dort, tiny ist unbrauchbar.
|
||||
* q4 - andere Genauigkeiten scheitern: q8 liefert auf der
|
||||
* Grafikkarte Unsinn, fp16 blockiert den Browser-Tab, fp32
|
||||
* passt nicht in einen Speicherblock.
|
||||
* automatisch - Grafikkarte wenn vorhanden, sonst CPU
|
||||
*
|
||||
* Die Belege dazu stehen im README. Wer daran drehen will, aendert diese
|
||||
* Konstanten - und prueft die Kombination vorher mit einer echten Aufnahme.
|
||||
*/
|
||||
export const WHISPER_MODEL = 'onnx-community/whisper-large-v3-turbo';
|
||||
export const WHISPER_DTYPE = 'q4' as const;
|
||||
export const WHISPER_LANGUAGE = 'de';
|
||||
|
||||
/**
|
||||
* WebGPU steht noch nicht in den TypeScript-Standardtypen. Statt eines eigenen
|
||||
* Typpakets hier nur das, was wirklich gebraucht wird.
|
||||
*/
|
||||
interface GpuAdapterLike {
|
||||
info?: Record<string, string>;
|
||||
limits?: Record<string, number>;
|
||||
}
|
||||
interface GpuLike {
|
||||
requestAdapter(): Promise<GpuAdapterLike | null>;
|
||||
}
|
||||
|
||||
function getGpu(): GpuLike | null {
|
||||
if (typeof navigator === 'undefined') return null;
|
||||
return (navigator as unknown as { gpu?: GpuLike }).gpu ?? null;
|
||||
}
|
||||
|
||||
/** Cache, in dem transformers.js die Modelldateien ablegt. */
|
||||
const MODEL_CACHE = 'transformers-cache';
|
||||
|
||||
/** Woran man im Cache erkennt, dass die Modelldateien schon vorliegen. */
|
||||
const MODEL_FILE_PATTERN = /encoder_model[^/]*q4[^/]*\.onnx$/i;
|
||||
|
||||
/**
|
||||
* Bittet den Browser, den Speicher dauerhaft zu behandeln.
|
||||
*
|
||||
* Die Modelldateien liegen im Cache-Speicher der Seite (nicht in Cookies - dort
|
||||
* passen nur wenige Kilobyte). Der ueberlebt Sitzungen ohnehin, gilt ohne diese
|
||||
* Bitte aber als "best effort": bei Platznot auf dem Geraet darf der Browser ihn
|
||||
* verwerfen. Mit erteilter Erlaubnis raeumt er ihn nur noch auf ausdruecklichen
|
||||
* Wunsch des Benutzers weg.
|
||||
*
|
||||
* Chrome entscheidet das nach eigenen Kriterien (u. a. wie oft die Seite genutzt
|
||||
* wird) und kann ablehnen. Dann funktioniert alles genauso - nur ohne diese
|
||||
* zusaetzliche Zusicherung.
|
||||
*/
|
||||
async function requestPersistentStorage(): Promise<boolean> {
|
||||
try {
|
||||
if (!navigator.storage?.persist) return false;
|
||||
if (await navigator.storage.persisted()) return true;
|
||||
return await navigator.storage.persist();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liegt das Modell schon im Browser? Wird direkt am Cache geprueft und nicht
|
||||
* selbst mitgeschrieben - so wird auch erkannt, was in einer anderen Sitzung
|
||||
* geladen wurde.
|
||||
*/
|
||||
async function detectCached(): Promise<boolean> {
|
||||
try {
|
||||
if (typeof caches === 'undefined') return false;
|
||||
const cache = await caches.open(MODEL_CACHE);
|
||||
const keys = await cache.keys();
|
||||
return keys.some(
|
||||
(request) => request.url.includes(`${WHISPER_MODEL}/`) && MODEL_FILE_PATTERN.test(request.url)
|
||||
);
|
||||
} catch {
|
||||
// Cache API gesperrt (privater Modus) - dann eben ohne Erkennung.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ohne Rueckmeldung so lange gewartet? Dann ist etwas haengen geblieben. Das
|
||||
* Anlegen der ONNX-Sitzung meldet keinen Fortschritt, darf also dauern - aber
|
||||
* nicht endlos.
|
||||
*/
|
||||
const STALL_TIMEOUT_MS = 120_000;
|
||||
|
||||
/**
|
||||
* Grenze fuer eine einzelne Erkennung, abhaengig von der Aufnahmelaenge: auf
|
||||
* der CPU ist ein Vielfaches der Aufnahmedauer normal, endlos aber nie.
|
||||
*/
|
||||
function transcribeTimeoutMs(seconds: number): number {
|
||||
return Math.min(300_000, Math.max(45_000, Math.round(seconds * 15_000)));
|
||||
}
|
||||
|
||||
export type WhisperStatus = 'idle' | 'loading' | 'ready' | 'error';
|
||||
|
||||
/** Woran gerade gearbeitet wird, solange geladen wird. */
|
||||
export type WhisperPhase = 'download' | 'init' | null;
|
||||
|
||||
export interface WhisperCapabilities {
|
||||
webgpu: boolean;
|
||||
adapter: string | null;
|
||||
cores: number | null;
|
||||
mediaRecorder: boolean;
|
||||
secureContext: boolean;
|
||||
}
|
||||
|
||||
interface WhisperContextValue {
|
||||
status: WhisperStatus;
|
||||
/** Download oder Initialisierung - nur waehrend des Ladens gesetzt. */
|
||||
phase: WhisperPhase;
|
||||
/** 0-100, waehrend des Downloads. */
|
||||
progress: number;
|
||||
progressLabel: string;
|
||||
error: string | null;
|
||||
/** Modell liegt bereits im Browser-Cache. */
|
||||
isCached: boolean;
|
||||
capabilities: WhisperCapabilities;
|
||||
/** Tatsaechlich genutzter Rechenweg - Grafikkarte wenn vorhanden. */
|
||||
resolvedDevice: 'webgpu' | 'wasm';
|
||||
load: () => Promise<void>;
|
||||
unload: () => void;
|
||||
clearCache: () => Promise<void>;
|
||||
/** Nimmt eine Aufnahme und liefert den erkannten Text. */
|
||||
transcribe: (blob: Blob) => Promise<{ text: string; ms: number; seconds: number }>;
|
||||
/** Browser kann grundsaetzlich aufnehmen und rechnen. */
|
||||
isSupported: boolean;
|
||||
}
|
||||
|
||||
const WhisperContext = createContext<WhisperContextValue | null>(null);
|
||||
|
||||
export const WhisperProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [status, setStatus] = useState<WhisperStatus>('idle');
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [progressLabel, setProgressLabel] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isCached, setIsCached] = useState(false);
|
||||
const [phase, setPhase] = useState<WhisperPhase>(null);
|
||||
const [capabilities, setCapabilities] = useState<WhisperCapabilities>({
|
||||
webgpu: getGpu() !== null,
|
||||
adapter: null,
|
||||
cores: typeof navigator !== 'undefined' ? navigator.hardwareConcurrency ?? null : null,
|
||||
mediaRecorder: typeof MediaRecorder !== 'undefined',
|
||||
secureContext: typeof window !== 'undefined' ? window.isSecureContext : false,
|
||||
});
|
||||
|
||||
const workerRef = useRef<Worker | null>(null);
|
||||
const loadWaiterRef = useRef<{ resolve: () => void; reject: (err: Error) => void } | null>(null);
|
||||
/** Zeitpunkt der letzten Worker-Nachricht - Grundlage der Haenger-Erkennung. */
|
||||
const lastActivityRef = useRef(0);
|
||||
const stallTimerRef = useRef<number | null>(null);
|
||||
const jobsRef = useRef(
|
||||
new Map<number, { resolve: (value: { text: string; ms: number }) => void; reject: (err: Error) => void }>()
|
||||
);
|
||||
const nextJobId = useRef(1);
|
||||
|
||||
// Details zur GPU nachladen - das geht nur asynchron.
|
||||
useEffect(() => {
|
||||
const gpu = getGpu();
|
||||
if (!gpu) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const adapter = await gpu.requestAdapter();
|
||||
if (cancelled) return;
|
||||
if (!adapter) {
|
||||
setCapabilities((prev) => ({ ...prev, webgpu: false, adapter: 'kein Adapter verfügbar' }));
|
||||
return;
|
||||
}
|
||||
const info = adapter.info ?? {};
|
||||
const described = [info.vendor, info.architecture, info.device, info.description]
|
||||
.filter(Boolean)
|
||||
.join(' / ');
|
||||
setCapabilities((prev) => ({ ...prev, adapter: described || 'Adapter vorhanden' }));
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setCapabilities((prev) => ({
|
||||
...prev,
|
||||
webgpu: false,
|
||||
adapter: err instanceof Error ? err.message : 'Abfrage fehlgeschlagen',
|
||||
}));
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Grafikkarte wenn verfuegbar, sonst CPU - daran gibt es nichts zu waehlen.
|
||||
const resolvedDevice: 'webgpu' | 'wasm' = capabilities.webgpu ? 'webgpu' : 'wasm';
|
||||
|
||||
const isSupported = capabilities.mediaRecorder && capabilities.secureContext;
|
||||
|
||||
// Cache-Zustand am Browser erfragen, nicht selbst mitschreiben.
|
||||
const refreshCacheState = useCallback(async () => {
|
||||
setIsCached(await detectCached());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshCacheState();
|
||||
}, [refreshCacheState, status]);
|
||||
|
||||
const stopStallWatch = useCallback(() => {
|
||||
if (stallTimerRef.current !== null) {
|
||||
window.clearInterval(stallTimerRef.current);
|
||||
stallTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const ensureWorker = useCallback((): Worker => {
|
||||
if (workerRef.current) return workerRef.current;
|
||||
|
||||
const worker = new Worker(new URL('./whisper.worker.ts', import.meta.url), { type: 'module' });
|
||||
|
||||
/** Bricht das Laden mit einer verwertbaren Meldung ab. */
|
||||
const failLoad = (message: string) => {
|
||||
stopStallWatch();
|
||||
setStatus('error');
|
||||
setPhase(null);
|
||||
setError(message);
|
||||
// Nach einem Fehlschlag ist der Worker nicht mehr vertrauenswuerdig.
|
||||
workerRef.current?.terminate();
|
||||
workerRef.current = null;
|
||||
loadWaiterRef.current?.reject(new Error(message));
|
||||
loadWaiterRef.current = null;
|
||||
// Laufende Erkennungen warten sonst fuer immer auf einen toten Worker.
|
||||
for (const job of jobsRef.current.values()) job.reject(new Error(message));
|
||||
jobsRef.current.clear();
|
||||
};
|
||||
|
||||
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
|
||||
const message = event.data;
|
||||
lastActivityRef.current = Date.now();
|
||||
|
||||
if (message.type === 'progress') {
|
||||
if (message.total > 0) {
|
||||
const done = message.loaded >= message.total;
|
||||
setProgress(Math.min(100, (message.loaded / message.total) * 100));
|
||||
setProgressLabel(
|
||||
`${Math.round(message.loaded / 1e6)} von ${Math.round(message.total / 1e6)} MB`
|
||||
);
|
||||
// Nach dem letzten Byte folgt das Anlegen der Rechensitzung - das
|
||||
// meldet keinen Fortschritt mehr und soll trotzdem sichtbar sein.
|
||||
setPhase(done ? 'init' : 'download');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'ready') {
|
||||
stopStallWatch();
|
||||
setStatus('ready');
|
||||
setPhase(null);
|
||||
setProgress(100);
|
||||
setProgressLabel('');
|
||||
loadWaiterRef.current?.resolve();
|
||||
loadWaiterRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'result') {
|
||||
jobsRef.current.get(message.id)?.resolve({ text: message.text, ms: message.ms });
|
||||
jobsRef.current.delete(message.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fehler
|
||||
if (message.id !== undefined) {
|
||||
jobsRef.current.get(message.id)?.reject(new Error(message.message));
|
||||
jobsRef.current.delete(message.id);
|
||||
} else {
|
||||
failLoad(message.message);
|
||||
}
|
||||
};
|
||||
|
||||
worker.onerror = (event) => {
|
||||
failLoad(event.message || 'Der Whisper-Worker ist abgestürzt.');
|
||||
};
|
||||
|
||||
workerRef.current = worker;
|
||||
return worker;
|
||||
}, [stopStallWatch]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (status === 'loading') return;
|
||||
if (status === 'ready') return;
|
||||
|
||||
setStatus('loading');
|
||||
setPhase('download');
|
||||
setError(null);
|
||||
setProgress(0);
|
||||
setProgressLabel(isCached ? 'aus dem Cache' : 'Download startet...');
|
||||
|
||||
// Vor dem Download um dauerhaften Speicher bitten, damit der Browser die
|
||||
// Dateien danach nicht bei Platznot wieder wegraeumt.
|
||||
void requestPersistentStorage();
|
||||
|
||||
const worker = ensureWorker();
|
||||
lastActivityRef.current = Date.now();
|
||||
|
||||
// Haenger-Erkennung: bleibt der Worker stumm - etwa weil das Anlegen der
|
||||
// Sitzung am Speicher scheitert - wird das Laden mit Meldung abgebrochen,
|
||||
// statt endlos "Laedt..." zu zeigen.
|
||||
stopStallWatch();
|
||||
stallTimerRef.current = window.setInterval(() => {
|
||||
if (Date.now() - lastActivityRef.current < STALL_TIMEOUT_MS) return;
|
||||
stopStallWatch();
|
||||
const message =
|
||||
'Das Modell reagiert seit zwei Minuten nicht. Bitte die Seite neu laden und es erneut versuchen.';
|
||||
setStatus('error');
|
||||
setPhase(null);
|
||||
setError(message);
|
||||
workerRef.current?.terminate();
|
||||
workerRef.current = null;
|
||||
loadWaiterRef.current?.reject(new Error(message));
|
||||
loadWaiterRef.current = null;
|
||||
}, 5000);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
loadWaiterRef.current = { resolve, reject };
|
||||
const request: WorkerRequest = {
|
||||
type: 'load',
|
||||
model: WHISPER_MODEL,
|
||||
device: resolvedDevice,
|
||||
dtype: WHISPER_DTYPE,
|
||||
};
|
||||
worker.postMessage(request);
|
||||
});
|
||||
}, [status, isCached, ensureWorker, resolvedDevice, stopStallWatch]);
|
||||
|
||||
const unload = useCallback(() => {
|
||||
stopStallWatch();
|
||||
workerRef.current?.terminate();
|
||||
workerRef.current = null;
|
||||
jobsRef.current.clear();
|
||||
setStatus('idle');
|
||||
setPhase(null);
|
||||
setProgress(0);
|
||||
setProgressLabel('');
|
||||
}, [stopStallWatch]);
|
||||
|
||||
const clearCache = useCallback(async () => {
|
||||
unload();
|
||||
try {
|
||||
const names = await caches.keys();
|
||||
await Promise.all(names.map((name) => caches.delete(name)));
|
||||
} catch {
|
||||
// Cache API gesperrt - dann bleibt der Download eben liegen.
|
||||
}
|
||||
await refreshCacheState();
|
||||
}, [unload, refreshCacheState]);
|
||||
|
||||
const transcribe = useCallback(
|
||||
async (blob: Blob) => {
|
||||
if (status !== 'ready') throw new Error('Das Spracherkennungsmodell ist nicht geladen.');
|
||||
|
||||
const { audio, seconds } = await toWhisperAudio(blob);
|
||||
const worker = ensureWorker();
|
||||
const id = nextJobId.current++;
|
||||
|
||||
const outcome = await new Promise<{ text: string; ms: number }>((resolve, reject) => {
|
||||
// Ohne Frist bliebe die Oberflaeche bei "Erkenne..." stehen, wenn die
|
||||
// Rechenumgebung nicht antwortet.
|
||||
const timer = window.setTimeout(() => {
|
||||
jobsRef.current.delete(id);
|
||||
const message =
|
||||
'Die Erkennung hat nicht geantwortet. Bitte die Seite neu laden und es erneut versuchen.';
|
||||
// Der Worker steckt fest; ein neuer Versuch braucht einen frischen.
|
||||
workerRef.current?.terminate();
|
||||
workerRef.current = null;
|
||||
setStatus('idle');
|
||||
setError(message);
|
||||
reject(new Error(message));
|
||||
}, transcribeTimeoutMs(seconds));
|
||||
|
||||
jobsRef.current.set(id, {
|
||||
resolve: (value) => {
|
||||
window.clearTimeout(timer);
|
||||
resolve(value);
|
||||
},
|
||||
reject: (err) => {
|
||||
window.clearTimeout(timer);
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
|
||||
const request: WorkerRequest = {
|
||||
type: 'transcribe',
|
||||
id,
|
||||
audio,
|
||||
language: WHISPER_LANGUAGE,
|
||||
};
|
||||
// Der Puffer wird uebergeben statt kopiert - bei langen Aufnahmen spart
|
||||
// das mehrere Megabyte.
|
||||
worker.postMessage(request, [audio.buffer]);
|
||||
});
|
||||
|
||||
return { ...outcome, seconds };
|
||||
},
|
||||
[status, ensureWorker]
|
||||
);
|
||||
|
||||
/*
|
||||
* Das Modell wird NICHT von selbst bereitgestellt, auch nicht aus dem Cache.
|
||||
*
|
||||
* Es hat sich anders gezeigt, als es gedacht war: das Anlegen der
|
||||
* ONNX-Sitzung uebersetzt hunderte Shader und legt dabei Zeiger und
|
||||
* Darstellung fuer eine Weile still. Wer das CRM oeffnet, um einen Lead zu
|
||||
* verschieben, haelt das - zu Recht - fuer einen Absturz der Anwendung.
|
||||
*
|
||||
* Deshalb: ein Klick, und zwar der des Benutzers. Liegt das Modell im Cache,
|
||||
* kostet "Diktat aktivieren" nur noch das Anlegen der Sitzung; der Download
|
||||
* faellt nur einmal je Browser an.
|
||||
*/
|
||||
useEffect(() => () => workerRef.current?.terminate(), []);
|
||||
|
||||
const value = useMemo<WhisperContextValue>(
|
||||
() => ({
|
||||
status,
|
||||
phase,
|
||||
progress,
|
||||
progressLabel,
|
||||
error,
|
||||
isCached,
|
||||
capabilities,
|
||||
resolvedDevice,
|
||||
load,
|
||||
unload,
|
||||
clearCache,
|
||||
transcribe,
|
||||
isSupported,
|
||||
}),
|
||||
[
|
||||
status,
|
||||
phase,
|
||||
progress,
|
||||
progressLabel,
|
||||
error,
|
||||
isCached,
|
||||
capabilities,
|
||||
resolvedDevice,
|
||||
load,
|
||||
unload,
|
||||
clearCache,
|
||||
transcribe,
|
||||
isSupported,
|
||||
]
|
||||
);
|
||||
|
||||
return <WhisperContext.Provider value={value}>{children}</WhisperContext.Provider>;
|
||||
};
|
||||
|
||||
export function useWhisper(): WhisperContextValue {
|
||||
const ctx = useContext(WhisperContext);
|
||||
if (!ctx) throw new Error('useWhisper muss innerhalb von <WhisperProvider> verwendet werden.');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Whisper laeuft im Worker, nicht im Hauptthread: die Erkennung rechnet je nach
|
||||
* Modell mehrere Sekunden, und solange darf die Oberflaeche nicht einfrieren.
|
||||
*
|
||||
* Das Modell wird beim ersten Laden vom Hugging-Face-CDN geholt und danach im
|
||||
* Browser-Cache gehalten. Die Aufnahme selbst verlaesst das Geraet nie.
|
||||
*/
|
||||
import { pipeline, env, type AutomaticSpeechRecognitionPipeline } from '@huggingface/transformers';
|
||||
|
||||
// Ohne das versucht transformers.js zuerst einen lokalen Pfad und laeuft in
|
||||
// unnoetige 404er.
|
||||
env.allowLocalModels = false;
|
||||
env.allowRemoteModels = true;
|
||||
|
||||
export type WorkerRequest =
|
||||
| { type: 'load'; model: string; device: 'webgpu' | 'wasm'; dtype: string }
|
||||
| { type: 'transcribe'; id: number; audio: Float32Array; language: string };
|
||||
|
||||
export type WorkerResponse =
|
||||
| { type: 'progress'; loaded: number; total: number; file: string }
|
||||
| { type: 'ready'; ms: number; downloadedBytes: number }
|
||||
| { type: 'result'; id: number; text: string; ms: number }
|
||||
| { type: 'error'; id?: number; message: string };
|
||||
|
||||
/**
|
||||
* `pipeline()` ist ueber alle Aufgabenarten hinweg typisiert; TypeScript kann
|
||||
* die entstehende Union nicht mehr darstellen ("union type too complex").
|
||||
* Deshalb hier eine schmale Signatur nur fuer die Spracherkennung.
|
||||
*/
|
||||
type CreatePipeline = (
|
||||
task: 'automatic-speech-recognition',
|
||||
model: string,
|
||||
options: Record<string, unknown>
|
||||
) => Promise<AutomaticSpeechRecognitionPipeline>;
|
||||
|
||||
const createPipeline = pipeline as unknown as CreatePipeline;
|
||||
|
||||
let transcriber: AutomaticSpeechRecognitionPipeline | null = null;
|
||||
/** Groesse je Datei, damit der Fortschritt ueber alle Dateien stimmt. */
|
||||
const files = new Map<string, { loaded: number; total: number }>();
|
||||
|
||||
const post = (message: WorkerResponse) => self.postMessage(message);
|
||||
|
||||
async function load(model: string, device: 'webgpu' | 'wasm', dtype: string) {
|
||||
files.clear();
|
||||
const started = performance.now();
|
||||
|
||||
transcriber = await createPipeline('automatic-speech-recognition', model, {
|
||||
device,
|
||||
dtype,
|
||||
progress_callback: (event: {
|
||||
status: string;
|
||||
file?: string;
|
||||
loaded?: number;
|
||||
total?: number;
|
||||
}) => {
|
||||
if (event.status !== 'progress' || !event.file) return;
|
||||
files.set(event.file, { loaded: event.loaded ?? 0, total: event.total ?? 0 });
|
||||
let loaded = 0;
|
||||
let total = 0;
|
||||
for (const entry of files.values()) {
|
||||
loaded += entry.loaded;
|
||||
total += entry.total;
|
||||
}
|
||||
post({ type: 'progress', loaded, total, file: event.file });
|
||||
},
|
||||
});
|
||||
|
||||
let downloadedBytes = 0;
|
||||
for (const entry of files.values()) downloadedBytes += entry.total;
|
||||
|
||||
post({ type: 'ready', ms: Math.round(performance.now() - started), downloadedBytes });
|
||||
}
|
||||
|
||||
async function transcribe(id: number, audio: Float32Array, language: string) {
|
||||
if (!transcriber) throw new Error('Das Modell ist nicht geladen.');
|
||||
|
||||
const started = performance.now();
|
||||
const output = await transcriber(audio, {
|
||||
language,
|
||||
task: 'transcribe',
|
||||
// Laengere Aufnahmen werden in Fenster zerlegt; der Ueberlappungsbereich
|
||||
// verhindert abgeschnittene Woerter an den Grenzen.
|
||||
chunk_length_s: 30,
|
||||
stride_length_s: 5,
|
||||
});
|
||||
|
||||
const text = Array.isArray(output)
|
||||
? output.map((part) => part.text ?? '').join(' ')
|
||||
: (output.text ?? '');
|
||||
|
||||
post({ type: 'result', id, text: text.trim(), ms: Math.round(performance.now() - started) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Beim Anlegen der ONNX-Sitzung koennen Fehler ausserhalb der await-Kette
|
||||
* auftreten - etwa "Array buffer allocation failed", wenn das Modell zu gross
|
||||
* fuer einen zusammenhaengenden Speicherblock ist. Ohne diese Handler bleibt
|
||||
* das Lade-Promise fuer immer offen und die Oberflaeche haengt bei "Laedt...".
|
||||
*/
|
||||
function reportFatal(reason: unknown) {
|
||||
const raw = reason instanceof Error ? reason.message : String(reason);
|
||||
const isMemory = /allocation failed|out of memory|Array buffer/i.test(raw);
|
||||
post({
|
||||
type: 'error',
|
||||
message: isMemory
|
||||
? `Der Speicher reicht für dieses Modell in dieser Genauigkeit nicht (${raw}). Bitte eine kleinere Genauigkeit wie q4 wählen.`
|
||||
: raw,
|
||||
});
|
||||
}
|
||||
|
||||
self.addEventListener('error', (event) => {
|
||||
event.preventDefault();
|
||||
reportFatal(event.error ?? event.message);
|
||||
});
|
||||
|
||||
self.addEventListener('unhandledrejection', (event) => {
|
||||
event.preventDefault();
|
||||
reportFatal(event.reason);
|
||||
});
|
||||
|
||||
self.onmessage = async (event: MessageEvent<WorkerRequest>) => {
|
||||
const request = event.data;
|
||||
try {
|
||||
if (request.type === 'load') {
|
||||
await load(request.model, request.device, request.dtype);
|
||||
} else {
|
||||
await transcribe(request.id, request.audio, request.language);
|
||||
}
|
||||
} catch (err) {
|
||||
post({
|
||||
type: 'error',
|
||||
id: request.type === 'transcribe' ? request.id : undefined,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Downloadgroesse des Sprachmodells bei Hugging Face erfragen, damit im Reiter
|
||||
* eine echte Zahl steht statt einer Schaetzung.
|
||||
*
|
||||
* Geladen werden zwei Dateien: der Encoder und der zusammengefuehrte Decoder,
|
||||
* jeweils in der Variante q4.
|
||||
*/
|
||||
const DTYPE_SUFFIX = '_q4';
|
||||
|
||||
export interface ModelSize {
|
||||
totalBytes: number;
|
||||
/** Groesste Einzeldatei - sie entscheidet, ob der Browser sie halten kann. */
|
||||
largestBytes: number;
|
||||
}
|
||||
|
||||
interface Sibling {
|
||||
rfilename: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const cache = new Map<string, ModelSize>();
|
||||
|
||||
export async function fetchModelSize(model: string): Promise<ModelSize | null> {
|
||||
const cached = cache.get(model);
|
||||
if (cached) return cached;
|
||||
|
||||
const response = await fetch(`https://huggingface.co/api/models/${model}?blobs=true`);
|
||||
if (!response.ok) throw new Error(`Modellinformationen nicht abrufbar (HTTP ${response.status}).`);
|
||||
|
||||
const data = (await response.json()) as { siblings?: Sibling[] };
|
||||
const files = new Map<string, number>();
|
||||
for (const sibling of data.siblings ?? []) {
|
||||
if (sibling.rfilename.endsWith('.onnx')) {
|
||||
files.set(sibling.rfilename.replace(/^onnx\//, '').replace(/\.onnx$/, ''), sibling.size ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
// transformers.js nimmt den zusammengefuehrten Decoder, wenn er vorliegt.
|
||||
const decoderName = files.has(`decoder_model_merged${DTYPE_SUFFIX}`)
|
||||
? `decoder_model_merged${DTYPE_SUFFIX}`
|
||||
: `decoder_model${DTYPE_SUFFIX}`;
|
||||
const encoder = files.get(`encoder_model${DTYPE_SUFFIX}`);
|
||||
const decoder = files.get(decoderName);
|
||||
if (encoder === undefined || decoder === undefined) return null;
|
||||
|
||||
const size: ModelSize = {
|
||||
totalBytes: encoder + decoder,
|
||||
largestBytes: Math.max(encoder, decoder),
|
||||
};
|
||||
cache.set(model, size);
|
||||
return size;
|
||||
}
|
||||
|
||||
/** "299 MB" bzw. "1,6 GB" */
|
||||
export function formatSize(bytes: number): string {
|
||||
if (bytes >= 1_000_000_000) return `${(bytes / 1e9).toFixed(1).replace('.', ',')} GB`;
|
||||
return `${Math.round(bytes / 1e6)} MB`;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import { App } from './App';
|
||||
import { AuthProvider } from './lib/auth';
|
||||
import { ThemeProvider } from './lib/theme';
|
||||
import { OptionsProvider } from './lib/options';
|
||||
import { ToastProvider } from './components/Toast';
|
||||
import { WhisperProvider } from './lib/whisper';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (!container) throw new Error('Element #root fehlt in index.html.');
|
||||
|
||||
createRoot(container).render(
|
||||
<React.StrictMode>
|
||||
<AuthProvider>
|
||||
{/* Das Theme haengt am Benutzer - deshalb innerhalb von AuthProvider. */}
|
||||
<ThemeProvider>
|
||||
<ToastProvider>
|
||||
{/* Die Stammdatenlisten hängen an der Anmeldung. */}
|
||||
<OptionsProvider>
|
||||
{/* Haelt den Whisper-Worker; das Diktat laeuft im Browser. */}
|
||||
<WhisperProvider>
|
||||
<App />
|
||||
</WhisperProvider>
|
||||
</OptionsProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
+415
@@ -0,0 +1,415 @@
|
||||
/** Gemeinsame Typen und Konstanten des ICON CRM. */
|
||||
|
||||
export type Role = 'admin' | 'user';
|
||||
|
||||
/*
|
||||
* Farbwelten. Die Liste muss mit THEMES in server/src/userFields.js
|
||||
* übereinstimmen - das Backend weist einen unbekannten Wert ab, damit ein
|
||||
* Tippfehler hier nicht still zum Standarddesign führt.
|
||||
*/
|
||||
export const THEME_PALETTES = ['icon', 'halflife', 'pixel'] as const;
|
||||
|
||||
export type ThemePalette = (typeof THEME_PALETTES)[number];
|
||||
|
||||
/** Beschriftung und Kurzbeschreibung für die Auswahl im Kopf der Anwendung. */
|
||||
export const THEME_PALETTE_LABELS: Record<ThemePalette, { name: string; hint: string }> = {
|
||||
icon: { name: 'ICON', hint: 'Das Design des Hauses – Gold auf neutralem Grau' },
|
||||
halflife: {
|
||||
name: 'Half-Life',
|
||||
hint: 'Black Mesa: HEV-Orange auf Beton, harte Kanten, Oswald',
|
||||
},
|
||||
pixel: {
|
||||
name: 'Pixel Art',
|
||||
hint: '8-bit: eckige Kanten, harte Schatten, Pixelschrift in den Überschriften',
|
||||
},
|
||||
};
|
||||
|
||||
export interface AppUser {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string | null;
|
||||
name: string;
|
||||
role: Role;
|
||||
color: string | null;
|
||||
avatar: string | null;
|
||||
theme: string | null;
|
||||
theme_mode: 'light' | 'dark' | null;
|
||||
is_active: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/** Abgespeckter Benutzer aus /users/directory - fuer Auswahl und Erwaehnungen. */
|
||||
export interface DirectoryUser {
|
||||
id: number;
|
||||
username: string;
|
||||
name: string;
|
||||
role: Role;
|
||||
color: string | null;
|
||||
avatar: string | null;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
/*
|
||||
* Die Kontaktart wird in den Einstellungen gepflegt (Stammdaten) und ist
|
||||
* deshalb kein fester Typ mehr. Die Vorgabewerte dienen nur als Rückfall,
|
||||
* solange die Liste noch nicht geladen ist.
|
||||
*/
|
||||
export type ContactKind = string;
|
||||
export const DEFAULT_CONTACT_KINDS = ['Experte', 'Ansprechpartner', 'Lead'];
|
||||
|
||||
/*
|
||||
* Der frueher vorhandene Verfuegbarkeitsstatus ist entfallen (Featureliste vom
|
||||
* 14.09.2026). Was bleibt, beschreibt den Stand der Zusammenarbeit.
|
||||
*/
|
||||
export type ContactStatus = 'Interessiert' | 'Interview' | 'Gebucht' | 'Inaktiv';
|
||||
export const CONTACT_STATUS: ContactStatus[] = ['Interessiert', 'Interview', 'Gebucht', 'Inaktiv'];
|
||||
|
||||
export type Tristate = 'unbekannt' | 'ja' | 'nein';
|
||||
export type ContactState = 'unbekannt' | 'persoenlich' | 'kein Kontakt';
|
||||
|
||||
export const CONTACT_STATE_LABELS: Record<ContactState, string> = {
|
||||
unbekannt: 'Unbekannt',
|
||||
persoenlich: 'Persönlicher Kontakt',
|
||||
'kein Kontakt': 'Noch kein Kontakt gehabt',
|
||||
};
|
||||
|
||||
export interface Contact {
|
||||
id: number;
|
||||
first_name: string | null;
|
||||
last_name: string;
|
||||
/** Vor- und Nachname, vom Backend zusammengesetzt. */
|
||||
name: string;
|
||||
position: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
linkedin: string | null;
|
||||
avatar: string | null;
|
||||
company_id: number | null;
|
||||
company_name?: string | null;
|
||||
company_industry?: string | null;
|
||||
kind: ContactKind;
|
||||
status: ContactStatus;
|
||||
lead_source: string | null;
|
||||
contact_state: ContactState;
|
||||
freelancers: Tristate;
|
||||
bio: string | null;
|
||||
tags: string[];
|
||||
rate_onsite: number | null;
|
||||
rate_remote: number | null;
|
||||
cv_url: string | null;
|
||||
/** Dateiname des hochgeladenen Lebenslaufs (aus dem CV-Import). */
|
||||
cv_file: string | null;
|
||||
owner_id: number | null;
|
||||
created_by: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
interview_count?: number;
|
||||
}
|
||||
|
||||
/*
|
||||
* Ein Kontakt aus dem perinco CRM.
|
||||
*
|
||||
* Er wird nur angezeigt, nie gespeichert. Die Kennung ist eine Zeichenkette
|
||||
* ("perinco-31"), weil sie in einer anderen Datenbank zaehlt und nie mit einer
|
||||
* ICON-Kennung verwechselt werden darf.
|
||||
*/
|
||||
export interface PerincoContact {
|
||||
id: string;
|
||||
first_name: string | null;
|
||||
last_name: string;
|
||||
name: string;
|
||||
position: string | null;
|
||||
company_name: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
linkedin: string | null;
|
||||
avatar: string | null;
|
||||
/** Drüben: Recruiter, Partner oder Endkunde - unverändert übernommen. */
|
||||
kind: string | null;
|
||||
lead_source: string | null;
|
||||
tags: string[];
|
||||
bio: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
source_system: 'perinco';
|
||||
}
|
||||
|
||||
export interface Company {
|
||||
id: number;
|
||||
name: string;
|
||||
group_name: string | null;
|
||||
industry: string | null;
|
||||
employees: number | null;
|
||||
website: string | null;
|
||||
address: string | null;
|
||||
logo: string | null;
|
||||
/** 0 = nicht bewertet, sonst 1 bis 5 Sterne. */
|
||||
rating: number;
|
||||
is_customer: boolean;
|
||||
customer_state: 'aktiv' | 'alumni';
|
||||
freelancers: Tristate;
|
||||
notes: string | null;
|
||||
owner_id: number | null;
|
||||
/** Hauptansprechpartner – Auswahl aus den Kontakten dieses Unternehmens. */
|
||||
primary_contact_id: number | null;
|
||||
created_by: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
contact_count?: number;
|
||||
former_count?: number;
|
||||
}
|
||||
|
||||
export interface Employment {
|
||||
id: number;
|
||||
contact_id: number;
|
||||
company_id: number;
|
||||
company?: string;
|
||||
industry?: string | null;
|
||||
position: string | null;
|
||||
started_on: string | null;
|
||||
ended_on: string | null;
|
||||
is_current: 0 | 1;
|
||||
}
|
||||
|
||||
export interface Interview {
|
||||
id: number;
|
||||
contact_id: number | null;
|
||||
company_id: number | null;
|
||||
user_id: number | null;
|
||||
user_name?: string | null;
|
||||
contact_name?: string | null;
|
||||
company?: string | null;
|
||||
held_on: string;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export type HistoryEntityType =
|
||||
| 'contact'
|
||||
| 'company'
|
||||
| 'tender'
|
||||
| 'project'
|
||||
| 'lead'
|
||||
| 'user'
|
||||
| 'system';
|
||||
|
||||
export interface HistoryEntry {
|
||||
id: number;
|
||||
entity_type: HistoryEntityType;
|
||||
entity_id: number | null;
|
||||
entity_label: string | null;
|
||||
action: string;
|
||||
text: string | null;
|
||||
changes: Record<string, [unknown, unknown]> | null;
|
||||
user_id: number | null;
|
||||
user_name: string | null;
|
||||
user_color?: string | null;
|
||||
user_avatar?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type TenderStatus = 'Aktiv' | 'Inaktiv' | 'Ausgelaufen' | 'Archiviert';
|
||||
export type TenderStage =
|
||||
| 'Ausschreibung'
|
||||
| 'Kontakte sichten'
|
||||
| 'Interview'
|
||||
| 'Kunden Interview'
|
||||
| 'Vertragsgestaltung'
|
||||
| 'Abschluss'
|
||||
| 'Geschlossen';
|
||||
|
||||
export const TENDER_STAGES: TenderStage[] = [
|
||||
'Ausschreibung',
|
||||
'Kontakte sichten',
|
||||
'Interview',
|
||||
'Kunden Interview',
|
||||
'Vertragsgestaltung',
|
||||
'Abschluss',
|
||||
'Geschlossen',
|
||||
];
|
||||
|
||||
export interface TenderContact {
|
||||
id: number;
|
||||
name: string;
|
||||
position: string | null;
|
||||
avatar: string | null;
|
||||
status: ContactStatus;
|
||||
}
|
||||
|
||||
export interface Tender {
|
||||
id: number;
|
||||
title: string;
|
||||
source: string | null;
|
||||
link: string | null;
|
||||
company_id: number | null;
|
||||
company_name?: string | null;
|
||||
company_logo?: string | null;
|
||||
status: TenderStatus;
|
||||
stage: TenderStage;
|
||||
/** Steuert die Anzeige auf der ICON Website. */
|
||||
online: boolean;
|
||||
/**
|
||||
* Der Ausschreibungstext als HTML.
|
||||
*
|
||||
* Er liegt in `notes` und nicht in `details` - so war es im ursprünglichen
|
||||
* ICON CRM, und die Website liest genau dieses Feld.
|
||||
*/
|
||||
notes: string | null;
|
||||
details: string | null;
|
||||
deadline: string | null;
|
||||
contacts?: TenderContact[];
|
||||
contact_count?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: number;
|
||||
title: string;
|
||||
company_id: number | null;
|
||||
company_name?: string | null;
|
||||
contact_id: number | null;
|
||||
contact_name?: string | null;
|
||||
contact_avatar?: string | null;
|
||||
started_on: string | null;
|
||||
ended_on: string | null;
|
||||
status: 'Aktiv' | 'Nicht aktiv' | 'Abgeschlossen';
|
||||
rate: number | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface Connection {
|
||||
id: number;
|
||||
contact_a: number;
|
||||
contact_b: number;
|
||||
a_name?: string;
|
||||
b_name?: string;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
/*
|
||||
* Die Netzwerkansicht ist bewusst kein eigener Menuepunkt: sie ist eine Ansicht
|
||||
* der Kontakte bzw. der Unternehmen - wie im perinco CRM. Ein eigener Eintrag
|
||||
* haette dieselben Daten ein zweites Mal aufgemacht, mit eigenen Filtern.
|
||||
*/
|
||||
/*
|
||||
* Lead-Phasen. In der Datenbank stehen sie ohne Umlaute (ENUM-Werte werden
|
||||
* verglichen, nicht angezeigt) - die Beschriftung kommt aus LEAD_STAGE_LABELS.
|
||||
*/
|
||||
export type LeadStage =
|
||||
| 'Fuer Spaeter'
|
||||
| 'Neuer Lead'
|
||||
| 'Erstkontakt'
|
||||
| '1. Gespraech'
|
||||
| 'Followup geplant'
|
||||
| 'Followup Gespraech'
|
||||
| 'Closed'
|
||||
| 'Absage';
|
||||
|
||||
export const LEAD_STAGES: LeadStage[] = [
|
||||
'Fuer Spaeter',
|
||||
'Neuer Lead',
|
||||
'Erstkontakt',
|
||||
'1. Gespraech',
|
||||
'Followup geplant',
|
||||
'Followup Gespraech',
|
||||
'Closed',
|
||||
'Absage',
|
||||
];
|
||||
|
||||
export const LEAD_STAGE_LABELS: Record<LeadStage, string> = {
|
||||
// In der Oberfläche heißt die erste Spalte Backlog; der Wert in der
|
||||
// Datenbank bleibt unverändert, sonst wäre es eine Migration.
|
||||
'Fuer Spaeter': 'Backlog',
|
||||
'Neuer Lead': 'Neuer Lead',
|
||||
Erstkontakt: 'Erstkontakt',
|
||||
'1. Gespraech': '1. Gespräch',
|
||||
'Followup geplant': 'Followup geplant',
|
||||
'Followup Gespraech': 'Followup Gespräch',
|
||||
Closed: 'Closed',
|
||||
Absage: 'Absage',
|
||||
};
|
||||
|
||||
export interface Lead {
|
||||
id: number;
|
||||
title: string;
|
||||
company_id: number | null;
|
||||
company_name: string | null;
|
||||
company_industry: string | null;
|
||||
company_logo: string | null;
|
||||
company_rating: number | null;
|
||||
contact_id: number | null;
|
||||
contact_name: string | null;
|
||||
contact_email: string | null;
|
||||
contact_phone: string | null;
|
||||
contact_avatar: string | null;
|
||||
stage: LeadStage;
|
||||
contact_state: ContactState;
|
||||
archived: boolean;
|
||||
position: number;
|
||||
notes: string | null;
|
||||
remind_at: string | null;
|
||||
reminder_days: number | null;
|
||||
last_contact_at: string | null;
|
||||
owner_id: number | null;
|
||||
owner_name: string | null;
|
||||
todo_count: number;
|
||||
todo_done: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface LeadTodo {
|
||||
id: number;
|
||||
lead_id: number;
|
||||
text: string;
|
||||
done: boolean;
|
||||
due_on: string | null;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export type View =
|
||||
| 'Dashboard'
|
||||
| 'Leads'
|
||||
| 'Leads · Archiviert'
|
||||
| 'Kontakte'
|
||||
| 'Unternehmen'
|
||||
| 'Ausschreibungen'
|
||||
| 'Live!'
|
||||
| 'Ausschreibungen · Archiv'
|
||||
| 'Laufende Projekte'
|
||||
| 'Historie'
|
||||
| 'Einstellungen';
|
||||
|
||||
/** Farbe eines Benutzers; ohne eigene Wahl aus der Kennung abgeleitet. */
|
||||
const USER_COLORS = ['#c1862f', '#8a6d3b', '#4f7d8c', '#7d5ba6', '#3f8f6b', '#b4623c'];
|
||||
|
||||
export function userColor(user: { id: number; color?: string | null } | null | undefined): string {
|
||||
if (!user) return '#93939b';
|
||||
return user.color || USER_COLORS[user.id % USER_COLORS.length];
|
||||
}
|
||||
|
||||
export function initials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return '?';
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
}
|
||||
|
||||
/** 14.09.'26 09:35 - das Format aus der Featureliste. */
|
||||
export function formatStamp(value: string | null | undefined): string {
|
||||
if (!value) return '';
|
||||
const date = new Date(value.replace(' ', 'T'));
|
||||
if (Number.isNaN(date.getTime())) return String(value);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.'${String(date.getFullYear()).slice(
|
||||
2
|
||||
)} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return '–';
|
||||
const date = new Date(value.length <= 10 ? `${value}T00:00:00` : value.replace(' ', 'T'));
|
||||
if (Number.isNaN(date.getTime())) return String(value);
|
||||
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"types": ["node", "vite/client"],
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"paths": { "@/*": ["./src/*"] },
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import path from 'path';
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 3101,
|
||||
host: '0.0.0.0',
|
||||
// Im Dev-Modus laeuft das Backend lokal auf 5007; im Betrieb uebernimmt
|
||||
// Apache das Weiterreichen von /api an 127.0.0.1:5007.
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:5007',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: { '@': path.resolve(__dirname, './src') },
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user