Menüs, Dialoge, Schaltflächen, Platzhalter, Hinweise und die
Fehlermeldungen der API standen in Ersatzschreibweise (oe/ae/ue). Sie
erscheinen als Toast oder Beschriftung direkt in der Oberfläche und
gehören damit richtig geschrieben; sechs sichtbare Texte mit ss statt ß
sind gleich mit erledigt ("Schließen", "Straße", "zu groß").
Unangetastet bleibt bewusst:
- Kommentare im Quelltext - die sind durchgängig ASCII, und der Diff wäre
dreimal so groß ohne Nutzen für den Benutzer
- Protokollzeilen (console.*) - die landen im journal, nicht in der App
- Datenbankwerte und interne Kennungen: 'uebernommen', 'empfaenger',
'vorschlaege', 'laeuft-bereits' stecken in Enums, SQL und
Zustandsvergleichen. Ein Umlaut darin hätte still falsche Ergebnisse
geliefert
- server/src/mail/extract.js - die Muster decken absichtlich beide
Schreibweisen ab: /gr(ü|ue)(ß|ss)e/
- scanner/utils/crypto.js - der Ersatzschlüssel darf sich nicht ändern,
sonst sind gespeicherte Portal-Passwörter nicht mehr lesbar
Geprüft mit einem Rundgang durch die Ansichten und Dialoge: sichtbarer
Text plus title/aria-label/placeholder enthalten keine
Ersatzschreibweisen mehr.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
334 lines
11 KiB
TypeScript
334 lines
11 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
leadFullName,
|
|
userColor,
|
|
type AppUser,
|
|
type Lead,
|
|
} from '../../types';
|
|
|
|
/**
|
|
* Mentions werden als Token im Text gespeichert: `@[Anzeigename](lead:12)`
|
|
* bzw. `@[Anzeigename](user:3)`. Der Name steht mit im Token, damit der Text
|
|
* auch ohne geladene Listen lesbar bleibt; beim Rendern gewinnt aber der
|
|
* aktuelle Name aus den Daten, falls die Person noch existiert.
|
|
*/
|
|
const MENTION_TOKEN = /@\[([^\]\n]+)\]\((lead|user):(\d+)\)/g;
|
|
|
|
export type MentionKind = 'lead' | 'user';
|
|
|
|
export interface MentionCandidate {
|
|
kind: MentionKind;
|
|
id: number;
|
|
label: string;
|
|
hint: string;
|
|
color: string;
|
|
/** Foto des Kontakts bzw. Benutzers, falls hinterlegt. */
|
|
avatar: string | null;
|
|
}
|
|
|
|
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;
|
|
|
|
// Der Regex ist global, also vor jedem Durchlauf zuruecksetzen.
|
|
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, Tooltips und Suche. */
|
|
export function mentionPlainText(text: string): string {
|
|
return parseMentions(text)
|
|
.map((segment) => (segment.type === 'text' ? segment.value : `@${segment.label}`))
|
|
.join('');
|
|
}
|
|
|
|
export function buildCandidates(leads: Lead[], users: AppUser[]): MentionCandidate[] {
|
|
const fromUsers: MentionCandidate[] = users.map((user) => ({
|
|
kind: 'user',
|
|
id: user.id,
|
|
label: user.name,
|
|
hint: `Benutzer · @${user.username}`,
|
|
color: userColor(user),
|
|
avatar: user.avatar ?? null,
|
|
}));
|
|
|
|
const fromLeads: MentionCandidate[] = leads.map((lead) => ({
|
|
kind: 'lead',
|
|
id: lead.id,
|
|
label: leadFullName(lead),
|
|
hint: [lead.company, lead.position].filter(Boolean).join(' · ') || 'Kontakt',
|
|
color: '#6e6e6e',
|
|
avatar: lead.avatar,
|
|
}));
|
|
|
|
// Benutzer zuerst: im Teamkalender werden meist Kollegen markiert.
|
|
return [...fromUsers, ...fromLeads];
|
|
}
|
|
|
|
export function mentionToken(candidate: MentionCandidate): string {
|
|
// Eckige Klammern im Namen wuerden das Token zerlegen.
|
|
const label = candidate.label.replace(/[\[\]()]/g, '').trim() || 'Unbenannt';
|
|
return `@[${label}](${candidate.kind}:${candidate.id})`;
|
|
}
|
|
|
|
// ---------- Anzeige ----------
|
|
|
|
interface MentionTextProps {
|
|
text: string;
|
|
leads: Lead[];
|
|
users: AppUser[];
|
|
onOpenLead?: (leadId: number) => void;
|
|
onOpenUser?: (userId: number) => void;
|
|
className?: string;
|
|
}
|
|
|
|
/** Rendert Text mit klickbaren Mentions. Zeilenumbrueche bleiben erhalten. */
|
|
export const MentionText: React.FC<MentionTextProps> = ({
|
|
text,
|
|
leads,
|
|
users,
|
|
onOpenLead,
|
|
onOpenUser,
|
|
className = '',
|
|
}) => {
|
|
const leadById = useMemo(() => new Map(leads.map((lead) => [lead.id, lead])), [leads]);
|
|
const userById = useMemo(() => new Map(users.map((user) => [user.id, user])), [users]);
|
|
|
|
return (
|
|
<span className={`whitespace-pre-wrap break-words ${className}`}>
|
|
{parseMentions(text).map((segment, index) => {
|
|
if (segment.type === 'text') return <React.Fragment key={index}>{segment.value}</React.Fragment>;
|
|
|
|
const lead = segment.kind === 'lead' ? leadById.get(segment.id) : undefined;
|
|
const user = segment.kind === 'user' ? userById.get(segment.id) : undefined;
|
|
const exists = Boolean(lead || user);
|
|
const label = lead ? leadFullName(lead) : user ? user.name : segment.label;
|
|
|
|
if (!exists) {
|
|
// Person gibt es nicht mehr (oder ist deaktiviert): nur noch Text.
|
|
return (
|
|
<span
|
|
key={index}
|
|
title="Diese Person ist nicht mehr verfügbar."
|
|
className="rounded-md bg-ink-100 px-1.5 py-0.5 text-xs font-bold text-ink-400"
|
|
>
|
|
@{segment.label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
const tint = user ? userColor(user) : '#6ba04a';
|
|
return (
|
|
<button
|
|
key={index}
|
|
type="button"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
if (segment.kind === 'lead') onOpenLead?.(segment.id);
|
|
else onOpenUser?.(segment.id);
|
|
}}
|
|
title={segment.kind === 'lead' ? 'Kontakt öffnen' : 'Benutzer anzeigen'}
|
|
className="mx-0.5 inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 align-baseline text-xs font-bold transition hover:brightness-95"
|
|
style={{ backgroundColor: `${tint}1f`, color: tint }}
|
|
>
|
|
<span
|
|
className="inline-block h-1.5 w-1.5 shrink-0 rounded-full"
|
|
style={{ backgroundColor: tint }}
|
|
/>
|
|
@{label}
|
|
</button>
|
|
);
|
|
})}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
// ---------- Eingabe ----------
|
|
|
|
interface MentionTextareaProps {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
candidates: MentionCandidate[];
|
|
placeholder?: string;
|
|
rows?: number;
|
|
className?: string;
|
|
id?: string;
|
|
}
|
|
|
|
const MAX_SUGGESTIONS = 8;
|
|
|
|
/** Was steht direkt vor dem Cursor gerade nach einem @? */
|
|
function activeQuery(text: string, caret: number): { start: number; query: string } | null {
|
|
const before = text.slice(0, caret);
|
|
// @ am Wortanfang, danach keine Leerzeichen und kein weiteres @
|
|
const match = /(^|[\s(,;])@([^\s@\[\]()]{0,40})$/.exec(before);
|
|
if (!match) return null;
|
|
return { start: caret - match[2].length - 1, query: match[2] };
|
|
}
|
|
|
|
export const MentionTextarea: React.FC<MentionTextareaProps> = ({
|
|
value,
|
|
onChange,
|
|
candidates,
|
|
placeholder,
|
|
rows = 4,
|
|
className = '',
|
|
id,
|
|
}) => {
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const [query, setQuery] = useState<{ start: number; query: string } | null>(null);
|
|
const [highlighted, setHighlighted] = useState(0);
|
|
|
|
const suggestions = useMemo(() => {
|
|
if (!query) return [];
|
|
const needle = query.query.toLowerCase();
|
|
const matches = needle
|
|
? candidates.filter(
|
|
(candidate) =>
|
|
candidate.label.toLowerCase().includes(needle) ||
|
|
candidate.hint.toLowerCase().includes(needle)
|
|
)
|
|
: candidates;
|
|
return matches.slice(0, MAX_SUGGESTIONS);
|
|
}, [candidates, query]);
|
|
|
|
useEffect(() => {
|
|
setHighlighted(0);
|
|
}, [query?.query]);
|
|
|
|
const isOpen = query !== null && suggestions.length > 0;
|
|
|
|
const syncQuery = (text: string, caret: number) => setQuery(activeQuery(text, caret));
|
|
|
|
const insert = (candidate: MentionCandidate) => {
|
|
if (!query) return;
|
|
const token = `${mentionToken(candidate)} `;
|
|
const next = value.slice(0, query.start) + token + value.slice(query.start + 1 + query.query.length);
|
|
onChange(next);
|
|
setQuery(null);
|
|
|
|
// Cursor hinter das eingefuegte Token setzen.
|
|
const caret = query.start + token.length;
|
|
requestAnimationFrame(() => {
|
|
const element = textareaRef.current;
|
|
if (!element) return;
|
|
element.focus();
|
|
element.setSelectionRange(caret, caret);
|
|
});
|
|
};
|
|
|
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (!isOpen) return;
|
|
|
|
if (event.key === 'ArrowDown') {
|
|
event.preventDefault();
|
|
setHighlighted((index) => (index + 1) % suggestions.length);
|
|
} else if (event.key === 'ArrowUp') {
|
|
event.preventDefault();
|
|
setHighlighted((index) => (index - 1 + suggestions.length) % suggestions.length);
|
|
} else if (event.key === 'Enter' || event.key === 'Tab') {
|
|
event.preventDefault();
|
|
insert(suggestions[highlighted]);
|
|
} else if (event.key === 'Escape') {
|
|
// Nicht das Modal schliessen, nur die Vorschlagsliste.
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
setQuery(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="relative">
|
|
<textarea
|
|
id={id}
|
|
ref={textareaRef}
|
|
rows={rows}
|
|
value={value}
|
|
placeholder={placeholder}
|
|
onChange={(event) => {
|
|
onChange(event.target.value);
|
|
syncQuery(event.target.value, event.target.selectionStart ?? 0);
|
|
}}
|
|
onClick={(event) =>
|
|
syncQuery(event.currentTarget.value, event.currentTarget.selectionStart ?? 0)
|
|
}
|
|
onKeyUp={(event) => {
|
|
if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
|
|
syncQuery(event.currentTarget.value, event.currentTarget.selectionStart ?? 0);
|
|
}
|
|
}}
|
|
onKeyDown={handleKeyDown}
|
|
onBlur={() => window.setTimeout(() => setQuery(null), 120)}
|
|
className={className}
|
|
/>
|
|
|
|
{isOpen && (
|
|
<ul
|
|
role="listbox"
|
|
className="scrollbar-slim absolute left-0 right-0 z-20 mt-1 max-h-64 overflow-y-auto rounded-xl border border-ink-200 bg-surface py-1 shadow-lift"
|
|
>
|
|
{suggestions.map((candidate, index) => (
|
|
<li key={`${candidate.kind}-${candidate.id}`}>
|
|
<button
|
|
type="button"
|
|
role="option"
|
|
aria-selected={index === highlighted}
|
|
// MouseDown statt Click: das Blur der Textarea darf die Liste
|
|
// nicht vorher schliessen.
|
|
onMouseDown={(event) => {
|
|
event.preventDefault();
|
|
insert(candidate);
|
|
}}
|
|
onMouseEnter={() => setHighlighted(index)}
|
|
className={`flex w-full items-center gap-3 px-3 py-2 text-left transition ${
|
|
index === highlighted ? 'bg-brand-50' : 'hover:bg-ink-50'
|
|
}`}
|
|
>
|
|
{candidate.avatar ? (
|
|
<img
|
|
src={candidate.avatar}
|
|
alt=""
|
|
className="h-7 w-7 shrink-0 rounded-lg object-cover"
|
|
/>
|
|
) : (
|
|
<span
|
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-[10px] font-extrabold text-white"
|
|
style={{ backgroundColor: candidate.color }}
|
|
>
|
|
{candidate.kind === 'user' ? 'U' : 'K'}
|
|
</span>
|
|
)}
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-sm font-bold text-ink-900">
|
|
{candidate.label}
|
|
</span>
|
|
<span className="block truncate text-xs text-ink-400">{candidate.hint}</span>
|
|
</span>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|