@kahitsan/ksui 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,466 @@
1
+ // Source: KahitSan/kserp src/components/MarkdownNotes.tsx (vendored into the plugin remote).
2
+ //
3
+ // Renders transaction `notes` with a restricted markdown subset and inline
4
+ // client-mention chips (@[Name](client:N)). Adapted for the remote: routing is
5
+ // host-owned so mention chips link via a plain <a href> instead of @solidjs/
6
+ // router's <A>; usePermissions + highlightMatch come from the host UI kit. The
7
+ // hover card fetches the SIBLING clients plugin at /api/clients/:id and
8
+ // degrades to a non-hovering chip when that 404s.
9
+
10
+ import { For, Show, createMemo, createSignal, createUniqueId, onCleanup, type JSX } from "solid-js";
11
+ import { usePermissions, highlightMatch } from "@kserp/host-ui";
12
+
13
+ const MENTION_RE = /@\[([^\]]+)\](?:\(client:(\d+)\))?/g;
14
+
15
+ type InlineToken =
16
+ | { kind: "text"; value: string }
17
+ | { kind: "bold"; children: InlineToken[] }
18
+ | { kind: "italic"; children: InlineToken[] }
19
+ | { kind: "code"; value: string }
20
+ | { kind: "link"; href: string; children: InlineToken[] }
21
+ | { kind: "mention"; name: string; clientId: number | null };
22
+
23
+ type BlockToken =
24
+ | { kind: "p"; children: InlineToken[] }
25
+ | { kind: "ul"; items: InlineToken[][] }
26
+ | { kind: "ol"; items: InlineToken[][] };
27
+
28
+ function tokenizeMentions(input: string): InlineToken[] {
29
+ const out: InlineToken[] = [];
30
+ let lastIndex = 0;
31
+ let m: RegExpExecArray | null;
32
+ MENTION_RE.lastIndex = 0;
33
+ while ((m = MENTION_RE.exec(input)) !== null) {
34
+ if (m.index > lastIndex) {
35
+ out.push({ kind: "text", value: input.slice(lastIndex, m.index) });
36
+ }
37
+ const name = m[1];
38
+ const id = m[2] ? Number.parseInt(m[2], 10) : null;
39
+ out.push({ kind: "mention", name, clientId: Number.isFinite(id) ? id : null });
40
+ lastIndex = m.index + m[0].length;
41
+ }
42
+ if (lastIndex < input.length) {
43
+ out.push({ kind: "text", value: input.slice(lastIndex) });
44
+ }
45
+ return out;
46
+ }
47
+
48
+ function parseInlineMarkdown(text: string): InlineToken[] {
49
+ const tokens: InlineToken[] = [];
50
+ let i = 0;
51
+ let buf = "";
52
+ const flush = () => {
53
+ if (buf) {
54
+ tokens.push({ kind: "text", value: buf });
55
+ buf = "";
56
+ }
57
+ };
58
+
59
+ while (i < text.length) {
60
+ const ch = text[i];
61
+
62
+ if (ch === "`") {
63
+ const close = text.indexOf("`", i + 1);
64
+ if (close > i) {
65
+ flush();
66
+ tokens.push({ kind: "code", value: text.slice(i + 1, close) });
67
+ i = close + 1;
68
+ continue;
69
+ }
70
+ }
71
+
72
+ if (ch === "*" && text[i + 1] === "*") {
73
+ const close = text.indexOf("**", i + 2);
74
+ if (close > i + 1) {
75
+ flush();
76
+ tokens.push({ kind: "bold", children: parseInlineMarkdown(text.slice(i + 2, close)) });
77
+ i = close + 2;
78
+ continue;
79
+ }
80
+ }
81
+
82
+ if (ch === "_" || ch === "*") {
83
+ const prev = i > 0 ? text[i - 1] : "";
84
+ const opensWord = prev === "" || /[\s(.,;:!?[]/.test(prev);
85
+ if (opensWord) {
86
+ const close = text.indexOf(ch, i + 1);
87
+ if (close > i + 1) {
88
+ const inner = text.slice(i + 1, close);
89
+ if (!inner.includes("\n") && inner.trim()) {
90
+ flush();
91
+ tokens.push({ kind: "italic", children: parseInlineMarkdown(inner) });
92
+ i = close + 1;
93
+ continue;
94
+ }
95
+ }
96
+ }
97
+ }
98
+
99
+ if (ch === "[") {
100
+ const labelEnd = text.indexOf("]", i + 1);
101
+ if (labelEnd > i && text[labelEnd + 1] === "(") {
102
+ const urlEnd = text.indexOf(")", labelEnd + 2);
103
+ if (urlEnd > labelEnd) {
104
+ const label = text.slice(i + 1, labelEnd);
105
+ const url = text.slice(labelEnd + 2, urlEnd).trim();
106
+ if (/^https?:\/\//i.test(url)) {
107
+ flush();
108
+ tokens.push({ kind: "link", href: url, children: parseInlineMarkdown(label) });
109
+ i = urlEnd + 1;
110
+ continue;
111
+ }
112
+ }
113
+ }
114
+ }
115
+
116
+ buf += ch;
117
+ i += 1;
118
+ }
119
+ flush();
120
+ return tokens;
121
+ }
122
+
123
+ function parseBlocks(input: string): BlockToken[] {
124
+ const blocks: BlockToken[] = [];
125
+ const lines = input.split(/\r?\n/);
126
+
127
+ let i = 0;
128
+ while (i < lines.length) {
129
+ const line = lines[i];
130
+
131
+ if (!line.trim()) {
132
+ i += 1;
133
+ continue;
134
+ }
135
+
136
+ if (/^\s*[-*]\s+/.test(line)) {
137
+ const items: InlineToken[][] = [];
138
+ while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
139
+ const itemText = lines[i].replace(/^\s*[-*]\s+/, "");
140
+ items.push(parseInlineWithMentions(itemText));
141
+ i += 1;
142
+ }
143
+ blocks.push({ kind: "ul", items });
144
+ continue;
145
+ }
146
+
147
+ if (/^\s*\d+\.\s+/.test(line)) {
148
+ const items: InlineToken[][] = [];
149
+ while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
150
+ const itemText = lines[i].replace(/^\s*\d+\.\s+/, "");
151
+ items.push(parseInlineWithMentions(itemText));
152
+ i += 1;
153
+ }
154
+ blocks.push({ kind: "ol", items });
155
+ continue;
156
+ }
157
+
158
+ const paraLines: string[] = [];
159
+ while (
160
+ i < lines.length &&
161
+ lines[i].trim() &&
162
+ !/^\s*[-*]\s+/.test(lines[i]) &&
163
+ !/^\s*\d+\.\s+/.test(lines[i])
164
+ ) {
165
+ paraLines.push(lines[i]);
166
+ i += 1;
167
+ }
168
+ blocks.push({ kind: "p", children: parseInlineWithMentions(paraLines.join("\n")) });
169
+ }
170
+
171
+ return blocks;
172
+ }
173
+
174
+ function parseInlineWithMentions(text: string): InlineToken[] {
175
+ const out: InlineToken[] = [];
176
+ for (const token of tokenizeMentions(text)) {
177
+ if (token.kind === "text") {
178
+ out.push(...parseInlineMarkdown(token.value));
179
+ } else {
180
+ out.push(token);
181
+ }
182
+ }
183
+ return out;
184
+ }
185
+
186
+ interface MentionClientLite {
187
+ id: number;
188
+ name_raw: string;
189
+ email: string | null;
190
+ phone: string | null;
191
+ }
192
+ interface MentionClientCacheEntry {
193
+ value: MentionClientLite | null;
194
+ ts: number;
195
+ }
196
+ const MENTION_CLIENT_CACHE_TTL_MS = 5 * 60 * 1000;
197
+ const mentionClientCache = new Map<number, MentionClientCacheEntry>();
198
+
199
+ function readMentionClientCache(id: number): MentionClientCacheEntry | undefined {
200
+ const entry = mentionClientCache.get(id);
201
+ if (!entry) return undefined;
202
+ if (Date.now() - entry.ts > MENTION_CLIENT_CACHE_TTL_MS) {
203
+ mentionClientCache.delete(id);
204
+ return undefined;
205
+ }
206
+ return entry;
207
+ }
208
+
209
+ function writeMentionClientCache(id: number, value: MentionClientLite | null): void {
210
+ mentionClientCache.set(id, { value, ts: Date.now() });
211
+ }
212
+
213
+ function MentionHoverCard(props: { clientId: number; name: string }): JSX.Element {
214
+ const hoverCardId = createUniqueId();
215
+ const [open, setOpen] = createSignal(false);
216
+ const [data, setData] = createSignal<MentionClientLite | null | undefined>(
217
+ readMentionClientCache(props.clientId)?.value,
218
+ );
219
+ const [loading, setLoading] = createSignal(false);
220
+ let openTimer: ReturnType<typeof setTimeout> | undefined;
221
+ let closeTimer: ReturnType<typeof setTimeout> | undefined;
222
+
223
+ function clearTimers() {
224
+ if (openTimer) clearTimeout(openTimer);
225
+ if (closeTimer) clearTimeout(closeTimer);
226
+ openTimer = undefined;
227
+ closeTimer = undefined;
228
+ }
229
+
230
+ async function ensureFetched() {
231
+ const cached = readMentionClientCache(props.clientId);
232
+ if (cached) {
233
+ setData(cached.value);
234
+ return;
235
+ }
236
+ setLoading(true);
237
+ try {
238
+ const res = await fetch(`/api/clients/${props.clientId}`, { credentials: "include" });
239
+ if (res.status === 200) {
240
+ const c = (await res.json()) as MentionClientLite;
241
+ const lite: MentionClientLite = {
242
+ id: c.id,
243
+ name_raw: c.name_raw,
244
+ email: c.email ?? null,
245
+ phone: c.phone ?? null,
246
+ };
247
+ writeMentionClientCache(props.clientId, lite);
248
+ setData(lite);
249
+ } else {
250
+ writeMentionClientCache(props.clientId, null);
251
+ setData(null);
252
+ }
253
+ } catch {
254
+ writeMentionClientCache(props.clientId, null);
255
+ setData(null);
256
+ } finally {
257
+ setLoading(false);
258
+ }
259
+ }
260
+
261
+ function handleEnter() {
262
+ clearTimers();
263
+ openTimer = setTimeout(() => {
264
+ setOpen(true);
265
+ void ensureFetched();
266
+ }, 120);
267
+ }
268
+ function handleLeave() {
269
+ clearTimers();
270
+ closeTimer = setTimeout(() => setOpen(false), 100);
271
+ }
272
+ function handleFocus() {
273
+ clearTimers();
274
+ setOpen(true);
275
+ void ensureFetched();
276
+ }
277
+ function handleBlur() {
278
+ clearTimers();
279
+ closeTimer = setTimeout(() => setOpen(false), 100);
280
+ }
281
+
282
+ onCleanup(clearTimers);
283
+
284
+ return (
285
+ <span class="relative inline-block" onMouseEnter={handleEnter} onMouseLeave={handleLeave}>
286
+ <a
287
+ href={`/clients/${props.clientId}`}
288
+ class="inline-flex items-center rounded bg-zinc-800/50 px-1.5 py-0.5 text-[0.85em] text-emerald-400 hover:text-emerald-300 hover:bg-zinc-800/70 transition-colors"
289
+ data-testid="mention-chip"
290
+ aria-describedby={open() && data() !== null ? hoverCardId : undefined}
291
+ onFocus={handleFocus}
292
+ onBlur={handleBlur}
293
+ >
294
+ @{props.name}
295
+ </a>
296
+ <Show when={open() && data() !== null}>
297
+ <span
298
+ id={hoverCardId}
299
+ role="tooltip"
300
+ data-testid="mention-hover-card"
301
+ class="absolute left-0 top-full z-50 mt-1 inline-block min-w-[14rem] max-w-xs rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-xs text-zinc-100 shadow-lg"
302
+ onMouseEnter={() => clearTimers()}
303
+ onMouseLeave={handleLeave}
304
+ >
305
+ <Show when={!loading() && data()} fallback={<span class="text-zinc-400">Loading…</span>}>
306
+ {(c) => (
307
+ <span class="block">
308
+ <span class="block font-medium text-zinc-100">{c().name_raw}</span>
309
+ <Show when={c().email}>
310
+ <span class="mt-1 block text-zinc-400" data-testid="mention-hover-email">
311
+ {c().email}
312
+ </span>
313
+ </Show>
314
+ <Show when={c().phone}>
315
+ <span class="block text-zinc-400" data-testid="mention-hover-phone">
316
+ {c().phone}
317
+ </span>
318
+ </Show>
319
+ <Show when={!c().email && !c().phone}>
320
+ <span class="mt-1 block text-zinc-500">No contact details on file.</span>
321
+ </Show>
322
+ </span>
323
+ )}
324
+ </Show>
325
+ </span>
326
+ </Show>
327
+ </span>
328
+ );
329
+ }
330
+
331
+ function MentionChip(props: { name: string; clientId: number | null }): JSX.Element {
332
+ let canViewClients = () => false;
333
+ try {
334
+ const perms = usePermissions();
335
+ canViewClients = () => perms.has("clients.view");
336
+ } catch {
337
+ /* no provider in context — leave canViewClients() returning false */
338
+ }
339
+
340
+ return (
341
+ <Show
342
+ when={props.clientId !== null}
343
+ fallback={
344
+ <span
345
+ class="inline-flex items-center rounded bg-zinc-800/40 px-1.5 py-0.5 text-[0.85em] text-zinc-500"
346
+ data-testid="mention-chip-unresolved"
347
+ >
348
+ @{props.name}
349
+ </span>
350
+ }
351
+ >
352
+ <Show
353
+ when={canViewClients()}
354
+ fallback={
355
+ <a
356
+ href={`/clients/${props.clientId}`}
357
+ class="inline-flex items-center rounded bg-zinc-800/50 px-1.5 py-0.5 text-[0.85em] text-emerald-400 hover:text-emerald-300 hover:bg-zinc-800/70 transition-colors"
358
+ data-testid="mention-chip"
359
+ >
360
+ @{props.name}
361
+ </a>
362
+ }
363
+ >
364
+ <MentionHoverCard clientId={props.clientId as number} name={props.name} />
365
+ </Show>
366
+ </Show>
367
+ );
368
+ }
369
+
370
+ function RenderInline(props: { tokens: InlineToken[]; searchQuery?: string }): JSX.Element {
371
+ return (
372
+ <For each={props.tokens}>
373
+ {(t) => {
374
+ if (t.kind === "text")
375
+ return <>{props.searchQuery ? highlightMatch(t.value, props.searchQuery) : t.value}</>;
376
+ if (t.kind === "bold")
377
+ return (
378
+ <strong>
379
+ <RenderInline tokens={t.children} searchQuery={props.searchQuery} />
380
+ </strong>
381
+ );
382
+ if (t.kind === "italic")
383
+ return (
384
+ <em>
385
+ <RenderInline tokens={t.children} searchQuery={props.searchQuery} />
386
+ </em>
387
+ );
388
+ if (t.kind === "code")
389
+ return (
390
+ <code class="px-1 py-0.5 rounded bg-zinc-800/60 text-[0.9em] text-zinc-200">
391
+ {props.searchQuery ? highlightMatch(t.value, props.searchQuery) : t.value}
392
+ </code>
393
+ );
394
+ if (t.kind === "link")
395
+ return (
396
+ <a
397
+ href={t.href}
398
+ target="_blank"
399
+ rel="noopener noreferrer"
400
+ class="text-emerald-400 hover:text-emerald-300 underline"
401
+ >
402
+ <RenderInline tokens={t.children} searchQuery={props.searchQuery} />
403
+ </a>
404
+ );
405
+ if (t.kind === "mention") return <MentionChip name={t.name} clientId={t.clientId} />;
406
+ return null;
407
+ }}
408
+ </For>
409
+ );
410
+ }
411
+
412
+ export interface MarkdownNotesProps {
413
+ value: string | null | undefined;
414
+ class?: string;
415
+ searchQuery?: string;
416
+ }
417
+
418
+ export default function MarkdownNotes(props: MarkdownNotesProps): JSX.Element {
419
+ const blocks = createMemo<BlockToken[]>(() => {
420
+ const v = props.value;
421
+ if (!v) return [];
422
+ return parseBlocks(v);
423
+ });
424
+
425
+ return (
426
+ <Show when={blocks().length > 0}>
427
+ <div class={props.class} data-testid="markdown-notes">
428
+ <For each={blocks()}>
429
+ {(b, idx) => {
430
+ if (b.kind === "p")
431
+ return (
432
+ <p class={idx() === 0 ? "" : "mt-2"}>
433
+ <RenderInline tokens={b.children} searchQuery={props.searchQuery} />
434
+ </p>
435
+ );
436
+ if (b.kind === "ul")
437
+ return (
438
+ <ul class={`list-disc pl-5 ${idx() === 0 ? "" : "mt-2"}`}>
439
+ <For each={b.items}>
440
+ {(item) => (
441
+ <li>
442
+ <RenderInline tokens={item} searchQuery={props.searchQuery} />
443
+ </li>
444
+ )}
445
+ </For>
446
+ </ul>
447
+ );
448
+ if (b.kind === "ol")
449
+ return (
450
+ <ol class={`list-decimal pl-5 ${idx() === 0 ? "" : "mt-2"}`}>
451
+ <For each={b.items}>
452
+ {(item) => (
453
+ <li>
454
+ <RenderInline tokens={item} searchQuery={props.searchQuery} />
455
+ </li>
456
+ )}
457
+ </For>
458
+ </ol>
459
+ );
460
+ return null;
461
+ }}
462
+ </For>
463
+ </div>
464
+ </Show>
465
+ );
466
+ }