@traky-app/ui 1.6.0 → 1.6.3

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,233 @@
1
+ import type { Icon } from "@tabler/icons-react";
2
+
3
+ export type InternalChatMentionKind = "guide" | "package" | "ai";
4
+
5
+ export type InternalChatMentionItem = {
6
+ id: string;
7
+ kind: InternalChatMentionKind;
8
+ label: string;
9
+ subtitle: string;
10
+ insertLabel: string;
11
+ icon: Icon;
12
+ };
13
+
14
+ export type InternalChatMentionCategoryMeta = {
15
+ label: string;
16
+ hint: string;
17
+ keywords: string[];
18
+ /** Visible en el menú de tipo pero no seleccionable (p. ej. próximamente). */
19
+ disabled?: boolean;
20
+ };
21
+
22
+ export type InternalChatMentionListEntry =
23
+ | {
24
+ type: "category";
25
+ kind: InternalChatMentionKind;
26
+ label: string;
27
+ hint: string;
28
+ icon: Icon;
29
+ disabled?: boolean;
30
+ }
31
+ | { type: "item"; data: InternalChatMentionItem };
32
+
33
+ export type InternalChatMentionCatalogState = {
34
+ open: boolean;
35
+ phase: "category" | "item";
36
+ kind?: InternalChatMentionKind;
37
+ filter: string;
38
+ };
39
+
40
+ export type BuildMentionListOptions = {
41
+ /** Si true, los ítems ya vienen filtrados del servidor (sin filtro local por texto). */
42
+ serverFilteredItems?: boolean;
43
+ };
44
+
45
+ export const DEFAULT_MENTION_KIND_ORDER: InternalChatMentionKind[] = [
46
+ "guide",
47
+ "package",
48
+ "ai",
49
+ ];
50
+
51
+ export const DEFAULT_MENTION_KIND_LABEL: Record<
52
+ InternalChatMentionKind,
53
+ string
54
+ > = {
55
+ guide: "Guías",
56
+ package: "Paquetes",
57
+ ai: "Asistencia de IA",
58
+ };
59
+
60
+ export type InternalChatMentionConfig = {
61
+ kindOrder: InternalChatMentionKind[];
62
+ categoryMeta: Record<
63
+ InternalChatMentionKind,
64
+ InternalChatMentionCategoryMeta
65
+ >;
66
+ kindIcons: Record<InternalChatMentionKind, Icon>;
67
+ kindAliases?: Record<string, InternalChatMentionKind>;
68
+ };
69
+
70
+ function resolveKindAlias(
71
+ raw: string,
72
+ config: InternalChatMentionConfig,
73
+ ): InternalChatMentionKind | null {
74
+ const s = raw.trim().toLowerCase();
75
+ if (!s) return null;
76
+
77
+ const aliases: Record<string, InternalChatMentionKind> = {
78
+ guide: "guide",
79
+ guía: "guide",
80
+ guia: "guide",
81
+ paquete: "package",
82
+ paquetes: "package",
83
+ package: "package",
84
+ ia: "ai",
85
+ ai: "ai",
86
+ asistencia: "ai",
87
+ ...config.kindAliases,
88
+ };
89
+
90
+ const direct = aliases[s];
91
+ if (direct) return direct;
92
+
93
+ for (const kind of config.kindOrder) {
94
+ const meta = config.categoryMeta[kind];
95
+ if (kind === s) return kind;
96
+ if (meta.label.toLowerCase() === s) return kind;
97
+ if (meta.keywords.some((k) => k === s)) return kind;
98
+ }
99
+
100
+ return null;
101
+ }
102
+
103
+ export function parseMentionSegment(
104
+ query: string,
105
+ config: InternalChatMentionConfig,
106
+ ):
107
+ | { phase: "category"; filter: string }
108
+ | { phase: "item"; kind: InternalChatMentionKind; filter: string } {
109
+ const colon = query.indexOf(":");
110
+ if (colon === -1) {
111
+ return { phase: "category", filter: query };
112
+ }
113
+ const head = query.slice(0, colon).trim().toLowerCase();
114
+ const tail = query.slice(colon + 1);
115
+ if (head === "") {
116
+ return { phase: "category", filter: query.replace(":", "") };
117
+ }
118
+ const kind = resolveKindAlias(head, config);
119
+ if (!kind) {
120
+ return { phase: "category", filter: query };
121
+ }
122
+ return { phase: "item", kind, filter: tail };
123
+ }
124
+
125
+ export function parseSlashMention(
126
+ draft: string,
127
+ cursor: number,
128
+ ): { start: number; query: string } | null {
129
+ const before = draft.slice(0, cursor);
130
+ const lastSlash = before.lastIndexOf("/");
131
+ if (lastSlash === -1) return null;
132
+ const charBefore = lastSlash === 0 ? " " : draft[lastSlash - 1];
133
+ if (charBefore !== " " && charBefore !== "\n" && lastSlash !== 0) {
134
+ return null;
135
+ }
136
+ const afterSlash = before.slice(lastSlash + 1);
137
+ if (afterSlash.includes("\n")) return null;
138
+ return { start: lastSlash, query: afterSlash };
139
+ }
140
+
141
+ function filterCategoryEntries(
142
+ filterText: string,
143
+ config: InternalChatMentionConfig,
144
+ ): InternalChatMentionListEntry[] {
145
+ const q = filterText.trim().toLowerCase();
146
+ const rows: InternalChatMentionListEntry[] = config.kindOrder.map((kind) => ({
147
+ type: "category",
148
+ kind,
149
+ label: config.categoryMeta[kind].label,
150
+ hint: config.categoryMeta[kind].hint,
151
+ icon: config.kindIcons[kind],
152
+ disabled: config.categoryMeta[kind].disabled,
153
+ }));
154
+ if (!q) return rows;
155
+ const byAlias = resolveKindAlias(q, config);
156
+ return rows.filter((row) => {
157
+ if (row.type !== "category") return false;
158
+ if (byAlias && row.kind === byAlias) return true;
159
+ return (
160
+ row.label.toLowerCase().includes(q) ||
161
+ row.hint.toLowerCase().includes(q) ||
162
+ config.categoryMeta[row.kind].keywords.some(
163
+ (k) => k.includes(q) || q.includes(k),
164
+ )
165
+ );
166
+ });
167
+ }
168
+
169
+ function filterItemsByKind(
170
+ kind: InternalChatMentionKind,
171
+ filterText: string,
172
+ catalog: InternalChatMentionItem[],
173
+ skipTextFilter = false,
174
+ ): InternalChatMentionListEntry[] {
175
+ const q = filterText.trim().toLowerCase();
176
+ let items = catalog.filter((i) => i.kind === kind);
177
+ if (!skipTextFilter && q) {
178
+ items = items.filter(
179
+ (item) =>
180
+ item.label.toLowerCase().includes(q) ||
181
+ item.subtitle.toLowerCase().includes(q) ||
182
+ item.insertLabel.toLowerCase().includes(q),
183
+ );
184
+ }
185
+ return items.map((data) => ({ type: "item", data }));
186
+ }
187
+
188
+ export function buildMentionList(
189
+ query: string,
190
+ guideItems: InternalChatMentionItem[],
191
+ packageItems: InternalChatMentionItem[],
192
+ config: InternalChatMentionConfig,
193
+ options?: BuildMentionListOptions,
194
+ ): InternalChatMentionListEntry[] {
195
+ const seg = parseMentionSegment(query, config);
196
+ if (seg.phase === "category") {
197
+ return filterCategoryEntries(seg.filter, config);
198
+ }
199
+ if (seg.kind === "ai" || config.categoryMeta[seg.kind]?.disabled) {
200
+ return [];
201
+ }
202
+ const pool = seg.kind === "guide" ? guideItems : packageItems;
203
+ return filterItemsByKind(
204
+ seg.kind,
205
+ seg.filter,
206
+ pool,
207
+ Boolean(options?.serverFilteredItems),
208
+ );
209
+ }
210
+
211
+ export function roomInitialLetter(name: string): string {
212
+ const t = name.trim();
213
+ if (!t) return "?";
214
+ const ch = [...t][0];
215
+ return ch && /\p{L}/u.test(ch) ? ch.toUpperCase() : "?";
216
+ }
217
+
218
+ export function initialsFromDisplayName(name: string): string {
219
+ const t = name.trim();
220
+ if (!t) return "?";
221
+ const parts = t.split(/\s+/).filter(Boolean);
222
+ if (parts.length === 0) return "?";
223
+ if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase();
224
+ return `${parts[0]![0]!}${parts[parts.length - 1]![0]!}`.toUpperCase();
225
+ }
226
+
227
+ export function formatMessageTime(iso: string, locale = "es"): string {
228
+ return new Intl.DateTimeFormat(locale, {
229
+ hour: "2-digit",
230
+ minute: "2-digit",
231
+ hour12: false,
232
+ }).format(new Date(iso));
233
+ }