@hivemindhq/core 0.4.0 → 0.5.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.
Files changed (85) hide show
  1. package/README.md +16 -16
  2. package/dist/{chunk-2RGM3KJL.js → chunk-K2544PJ5.js} +42 -20
  3. package/dist/chunk-K2544PJ5.js.map +1 -0
  4. package/dist/{chunk-P5E2XNDI.js → chunk-K4XDMY2V.js} +3 -3
  5. package/dist/{chunk-P5E2XNDI.js.map → chunk-K4XDMY2V.js.map} +1 -1
  6. package/dist/{chunk-ERZSVDIB.js → chunk-RW4JXOAM.js} +11 -3
  7. package/dist/chunk-RW4JXOAM.js.map +1 -0
  8. package/dist/chunk-VU3OPG32.js +907 -0
  9. package/dist/chunk-VU3OPG32.js.map +1 -0
  10. package/dist/components/index.d.ts +28 -3
  11. package/dist/components/index.js +2 -2
  12. package/dist/components/ui/index.js +2 -2
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.js +6 -6
  15. package/dist/utils/index.d.ts +312 -6
  16. package/dist/utils/index.js +2 -2
  17. package/package.json +15 -11
  18. package/src/components/AtomIcon.tsx +21 -0
  19. package/src/components/CryptoAmount.tsx +447 -0
  20. package/src/components/ErrorBanner.tsx +35 -0
  21. package/src/components/IpfsImage.tsx +21 -0
  22. package/src/components/LoadingDots.tsx +55 -0
  23. package/src/components/TripleAreaChart.tsx +108 -0
  24. package/src/components/TriplePositionsTornadoMinGraph.tsx +71 -0
  25. package/src/components/UnknownImage.tsx +55 -0
  26. package/src/components/index.ts +24 -0
  27. package/src/components/ui/alert.tsx +59 -0
  28. package/src/components/ui/avatar.tsx +47 -0
  29. package/src/components/ui/badge.tsx +35 -0
  30. package/src/components/ui/breadcrumb.tsx +108 -0
  31. package/src/components/ui/button.tsx +56 -0
  32. package/src/components/ui/card.tsx +75 -0
  33. package/src/components/ui/carousel.tsx +239 -0
  34. package/src/components/ui/chart.tsx +350 -0
  35. package/src/components/ui/checkbox.tsx +28 -0
  36. package/src/components/ui/collapsible.tsx +10 -0
  37. package/src/components/ui/command.tsx +177 -0
  38. package/src/components/ui/dialog.tsx +119 -0
  39. package/src/components/ui/dropdown-menu.tsx +202 -0
  40. package/src/components/ui/form.tsx +175 -0
  41. package/src/components/ui/index.ts +183 -0
  42. package/src/components/ui/input.tsx +21 -0
  43. package/src/components/ui/label.tsx +25 -0
  44. package/src/components/ui/loader.tsx +20 -0
  45. package/src/components/ui/pagination.tsx +104 -0
  46. package/src/components/ui/popover.tsx +45 -0
  47. package/src/components/ui/progress.tsx +25 -0
  48. package/src/components/ui/radio-group.tsx +42 -0
  49. package/src/components/ui/scroll-area.tsx +45 -0
  50. package/src/components/ui/select.tsx +178 -0
  51. package/src/components/ui/separator.tsx +28 -0
  52. package/src/components/ui/sheet.tsx +139 -0
  53. package/src/components/ui/sidebar.tsx +723 -0
  54. package/src/components/ui/skeleton.tsx +15 -0
  55. package/src/components/ui/sonner.tsx +27 -0
  56. package/src/components/ui/spinner.tsx +67 -0
  57. package/src/components/ui/switch.tsx +26 -0
  58. package/src/components/ui/table.tsx +113 -0
  59. package/src/components/ui/tabs.tsx +63 -0
  60. package/src/components/ui/textarea.tsx +21 -0
  61. package/src/components/ui/toast.tsx +146 -0
  62. package/src/components/ui/toaster.tsx +33 -0
  63. package/src/components/ui/toggle-group.tsx +58 -0
  64. package/src/components/ui/toggle.tsx +44 -0
  65. package/src/components/ui/tooltip.tsx +61 -0
  66. package/src/hooks/index.ts +7 -0
  67. package/src/hooks/use-mobile.ts +20 -0
  68. package/src/hooks/use-toast.ts +190 -0
  69. package/src/index.ts +25 -0
  70. package/src/types/index.ts +17 -0
  71. package/src/utils/atom-label-detection.ts +689 -0
  72. package/src/utils/atom.ts +279 -0
  73. package/src/utils/cn.ts +18 -0
  74. package/src/utils/formatting.ts +624 -0
  75. package/src/utils/index.ts +11 -0
  76. package/src/utils/multivault-errors.ts +581 -0
  77. package/src/utils/search/formatting.tsx +95 -0
  78. package/src/utils/search/index.ts +28 -0
  79. package/src/utils/search/ranking.ts +203 -0
  80. package/src/utils/search/types.ts +114 -0
  81. package/tailwind.config.js +3 -3
  82. package/dist/chunk-2RGM3KJL.js.map +0 -1
  83. package/dist/chunk-ERZSVDIB.js.map +0 -1
  84. package/dist/chunk-H4RMZQ2Z.js +0 -213
  85. package/dist/chunk-H4RMZQ2Z.js.map +0 -1
@@ -0,0 +1,624 @@
1
+ /**
2
+ * Formatting utilities for display purposes
3
+ */
4
+
5
+ // ============================================================================
6
+ // Crypto Amount Formatting
7
+ // ============================================================================
8
+
9
+ /**
10
+ * Options for formatting crypto amounts.
11
+ */
12
+ export interface FormatCryptoAmountOptions {
13
+ /** Maximum significant digits before truncation (default: 10) */
14
+ maxSignificantDigits?: number
15
+ /** Use compact notation for large numbers (default: false) */
16
+ compact?: boolean
17
+ /** Locale for number formatting (default: 'en-US') */
18
+ locale?: string
19
+ /** Minimum decimal places to show (default: 0) */
20
+ minDecimals?: number
21
+ /** Maximum decimal places to show (default: 6) */
22
+ maxDecimals?: number
23
+ }
24
+
25
+ /**
26
+ * Result of formatting a crypto amount.
27
+ */
28
+ export interface FormattedCryptoAmount {
29
+ /** The display string (may be truncated) */
30
+ display: string
31
+ /** The full value string (for tooltips) */
32
+ fullValue: string
33
+ /** Whether the display was truncated */
34
+ isTruncated: boolean
35
+ /** The numeric value (for calculations) */
36
+ numericValue: number
37
+ }
38
+
39
+ /**
40
+ * Formats a raw crypto amount (in wei/smallest unit) for display.
41
+ *
42
+ * Handles:
43
+ * - Conversion from smallest unit to readable format
44
+ * - Truncation of excessive precision with tooltip support
45
+ * - Compact notation for large amounts (1.5M, 2.3B)
46
+ * - Locale-aware number formatting
47
+ *
48
+ * @param value - Raw amount as string or bigint (e.g., "1500000000000000000" for 1.5 tokens)
49
+ * @param decimals - Token decimals (default: 18)
50
+ * @param options - Formatting options
51
+ * @returns Formatted amount with display string and metadata
52
+ *
53
+ * @example
54
+ * formatCryptoAmount("1500000000000000000", 18)
55
+ * // Returns: { display: "1.5", fullValue: "1.5", isTruncated: false, numericValue: 1.5 }
56
+ *
57
+ * formatCryptoAmount("12345678901234567890123", 18, { maxSignificantDigits: 6 })
58
+ * // Returns: { display: "12345.6...", fullValue: "12345.678901234567890123", isTruncated: true, ... }
59
+ */
60
+ export function formatCryptoAmount(
61
+ value: string | bigint,
62
+ decimals: number = 18,
63
+ options: FormatCryptoAmountOptions = {}
64
+ ): FormattedCryptoAmount {
65
+ const {
66
+ maxSignificantDigits = 10,
67
+ compact = false,
68
+ locale = 'en-US',
69
+ minDecimals = 0,
70
+ maxDecimals = 6,
71
+ } = options
72
+
73
+ // Convert to bigint if string
74
+ const bigIntValue = typeof value === 'string' ? BigInt(value) : value
75
+
76
+ // Handle zero case
77
+ if (bigIntValue === 0n) {
78
+ return {
79
+ display: '0',
80
+ fullValue: '0',
81
+ isTruncated: false,
82
+ numericValue: 0,
83
+ }
84
+ }
85
+
86
+ // Convert from smallest unit to readable format
87
+ const divisor = 10n ** BigInt(decimals)
88
+ const wholePart = bigIntValue / divisor
89
+ const fractionalPart = bigIntValue % divisor
90
+
91
+ // Build the full decimal string
92
+ const fractionalStr = fractionalPart.toString().padStart(decimals, '0')
93
+ const trimmedFractional = fractionalStr.replace(/0+$/, '') // Remove trailing zeros
94
+
95
+ const fullValue = trimmedFractional
96
+ ? `${wholePart}.${trimmedFractional}`
97
+ : wholePart.toString()
98
+
99
+ const numericValue = Number(fullValue)
100
+
101
+ // Handle compact notation
102
+ if (compact && Math.abs(numericValue) >= 1000) {
103
+ const compactDisplay = formatCompact(numericValue)
104
+ return {
105
+ display: compactDisplay,
106
+ fullValue,
107
+ isTruncated: true, // Compact is always "truncated" conceptually
108
+ numericValue,
109
+ }
110
+ }
111
+
112
+ // Step 1: Check if decimals will be truncated (for tooltip purposes)
113
+ const originalDecimalCount = trimmedFractional.length
114
+ const decimalsWillBeTruncated = originalDecimalCount > maxDecimals
115
+
116
+ // Step 2: Apply maxDecimals limit FIRST (this is the primary display constraint)
117
+ let decimalLimitedValue: string
118
+ try {
119
+ decimalLimitedValue = new Intl.NumberFormat(locale, {
120
+ minimumFractionDigits: minDecimals,
121
+ maximumFractionDigits: maxDecimals,
122
+ useGrouping: false, // Disable commas for significant digit counting
123
+ }).format(numericValue)
124
+ } catch {
125
+ decimalLimitedValue = fullValue
126
+ }
127
+
128
+ // Step 3: Check if significant digits truncation is STILL needed after decimal limiting
129
+ const { truncated, isTruncated: sigDigitsTruncated } = truncateSignificantDigits(
130
+ decimalLimitedValue,
131
+ maxSignificantDigits
132
+ )
133
+
134
+ // isTruncated is true if EITHER decimals were limited OR significant digits were truncated
135
+ const isTruncated = decimalsWillBeTruncated || sigDigitsTruncated
136
+
137
+ // Step 4: Format for display
138
+ let display: string
139
+ if (sigDigitsTruncated) {
140
+ // Extract numeric part (without ellipsis) and apply locale formatting for grouping separators
141
+ const numericPart = truncated.replace('…', '')
142
+ try {
143
+ const formattedPart = new Intl.NumberFormat(locale, {
144
+ maximumFractionDigits: maxDecimals,
145
+ }).format(parseFloat(numericPart))
146
+ display = `${formattedPart}…`
147
+ } catch {
148
+ display = truncated
149
+ }
150
+ } else {
151
+ // Re-apply locale formatting with grouping (commas) for final display
152
+ try {
153
+ display = new Intl.NumberFormat(locale, {
154
+ minimumFractionDigits: minDecimals,
155
+ maximumFractionDigits: maxDecimals,
156
+ }).format(numericValue)
157
+ } catch {
158
+ display = decimalLimitedValue
159
+ }
160
+ }
161
+
162
+ return {
163
+ display,
164
+ fullValue,
165
+ isTruncated,
166
+ numericValue,
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Truncates a numeric string to a maximum number of significant digits.
172
+ * Leading zeros in decimals don't count toward the limit.
173
+ *
174
+ * @param value - The numeric string to truncate
175
+ * @param maxDigits - Maximum significant digits to keep
176
+ * @returns Object with truncated string and whether truncation occurred
177
+ *
178
+ * @example
179
+ * truncateSignificantDigits("123456789012", 10)
180
+ * // Returns: { truncated: "1234567890…", isTruncated: true }
181
+ *
182
+ * truncateSignificantDigits("0.0000123456789", 10)
183
+ * // Returns: { truncated: "0.0000123456789", isTruncated: false }
184
+ * // (leading zeros don't count, only 9 significant digits)
185
+ */
186
+ export function truncateSignificantDigits(
187
+ value: string,
188
+ maxDigits: number
189
+ ): { truncated: string; isTruncated: boolean } {
190
+ if (!value) {
191
+ return { truncated: '0', isTruncated: false }
192
+ }
193
+
194
+ // Handle negative numbers
195
+ const isNegative = value.startsWith('-')
196
+ const absValue = isNegative ? value.slice(1) : value
197
+
198
+ // Split into whole and fractional parts
199
+ const [wholePart, fractionalPart] = absValue.split('.')
200
+
201
+ // Count significant digits (skip leading zeros in fractional part)
202
+ let significantCount = 0
203
+ let truncatedWhole = ''
204
+ let truncatedFractional = ''
205
+ let isTruncated = false
206
+
207
+ // Process whole part (all digits are significant unless the whole thing is "0")
208
+ if (wholePart !== '0') {
209
+ for (const char of wholePart) {
210
+ if (significantCount >= maxDigits) {
211
+ isTruncated = true
212
+ break
213
+ }
214
+ truncatedWhole += char
215
+ significantCount++
216
+ }
217
+
218
+ // If we truncated in whole part, we're done (no fractional)
219
+ if (isTruncated) {
220
+ const result = isNegative ? `-${truncatedWhole}` : truncatedWhole
221
+ return { truncated: `${result}…`, isTruncated: true }
222
+ }
223
+ } else {
224
+ truncatedWhole = '0'
225
+ }
226
+
227
+ // Process fractional part if exists
228
+ if (fractionalPart) {
229
+ let foundNonZero = wholePart !== '0' // If whole is non-zero, all fractional digits count
230
+
231
+ for (const char of fractionalPart) {
232
+ if (!foundNonZero && char === '0') {
233
+ // Leading zeros in fractional don't count
234
+ truncatedFractional += char
235
+ continue
236
+ }
237
+
238
+ foundNonZero = true
239
+
240
+ if (significantCount >= maxDigits) {
241
+ isTruncated = true
242
+ break
243
+ }
244
+
245
+ truncatedFractional += char
246
+ significantCount++
247
+ }
248
+ }
249
+
250
+ // Build final result
251
+ let result = truncatedWhole
252
+ if (truncatedFractional) {
253
+ result += `.${truncatedFractional}`
254
+ }
255
+
256
+ if (isNegative) {
257
+ result = `-${result}`
258
+ }
259
+
260
+ return { truncated: result, isTruncated }
261
+ }
262
+
263
+ /**
264
+ * Truncates an address or ID for display.
265
+ * Shows first N characters after optional prefix.
266
+ *
267
+ * @param value - The full address or ID string
268
+ * @param chars - Number of characters to show (default: 5)
269
+ * @param prefix - Prefix to skip (default: '0x')
270
+ * @returns Truncated string
271
+ *
272
+ * @example
273
+ * truncateId('0x123456789098765432') // Returns: '12345'
274
+ * truncateId('0x123456789098765432', 8) // Returns: '12345678'
275
+ */
276
+ export function truncateId(
277
+ value: string,
278
+ chars: number = 5,
279
+ prefix: string = '0x'
280
+ ): string {
281
+ if (!value) return ''
282
+ const start = value.startsWith(prefix) ? prefix.length : 0
283
+ return value.slice(start, start + chars)
284
+ }
285
+
286
+ /**
287
+ * Converts an IPFS URI to an HTTP gateway URL.
288
+ *
289
+ * @param uri - The IPFS URI (ipfs://... format)
290
+ * @param gateway - The gateway to use (default: ipfs.io)
291
+ * @returns HTTP URL for the content
292
+ *
293
+ * @example
294
+ * ipfsToHttp('ipfs://QmHash123') // Returns: 'https://ipfs.io/ipfs/QmHash123'
295
+ */
296
+ export function ipfsToHttp(
297
+ uri: string | undefined | null,
298
+ gateway: string = 'https://ipfs.io/ipfs/'
299
+ ): string | undefined {
300
+ if (!uri) return undefined
301
+ if (uri.startsWith('ipfs://')) {
302
+ return uri.replace('ipfs://', gateway)
303
+ }
304
+ return uri
305
+ }
306
+
307
+ /**
308
+ * Formats a number with compact notation (K, M, B, etc.)
309
+ *
310
+ * @param value - The number to format
311
+ * @param decimals - Maximum decimal places (default: 2)
312
+ * @returns Formatted string
313
+ *
314
+ * @example
315
+ * formatCompact(1500) // Returns: '1.5K'
316
+ * formatCompact(2500000) // Returns: '2.5M'
317
+ */
318
+ export function formatCompact(
319
+ value: number,
320
+ decimals: number = 2
321
+ ): string {
322
+ if (value === 0) return '0'
323
+
324
+ const formatter = new Intl.NumberFormat('en-US', {
325
+ notation: 'compact',
326
+ maximumFractionDigits: decimals,
327
+ })
328
+
329
+ return formatter.format(value)
330
+ }
331
+
332
+ /**
333
+ * Strengthens/normalizes an image URL to ensure it's displayable.
334
+ * Handles IPFS URIs, raw IPFS hashes, and validates HTTP URLs.
335
+ *
336
+ * @param url - The image URL to process
337
+ * @returns A normalized HTTP(S) URL
338
+ *
339
+ * @example
340
+ * strengthenImageUrl('ipfs://QmHash123') // Returns: 'https://ipfs.io/ipfs/QmHash123'
341
+ * strengthenImageUrl('QmHash123') // Returns: 'https://ipfs.io/ipfs/QmHash123'
342
+ * strengthenImageUrl('https://example.com/img.png') // Returns: 'https://example.com/img.png'
343
+ */
344
+ export function strengthenImageUrl(url: string = ''): string {
345
+ // Validate input
346
+ if (!url || typeof url !== 'string' || url.trim() === '') {
347
+ return url
348
+ }
349
+
350
+ const trimmedUrl = url.trim()
351
+
352
+ // Handle IPFS protocol
353
+ if (trimmedUrl.startsWith('ipfs://')) {
354
+ return trimmedUrl.replace('ipfs://', 'https://ipfs.io/ipfs/')
355
+ }
356
+
357
+ // Already a valid HTTP(S) URL
358
+ if (trimmedUrl.startsWith('https://') || trimmedUrl.startsWith('http://')) {
359
+ return trimmedUrl
360
+ }
361
+
362
+ // Handle IPFS hashes without protocol (Qm... or baf...)
363
+ if (trimmedUrl.startsWith('Qm') || trimmedUrl.startsWith('baf')) {
364
+ return 'https://ipfs.io/ipfs/' + trimmedUrl
365
+ }
366
+
367
+ return trimmedUrl
368
+ }
369
+
370
+ // ============================================================================
371
+ // String Manipulation Utilities
372
+ // ============================================================================
373
+
374
+ /**
375
+ * Ellipsizes a string by showing characters from start and end with '...' in middle.
376
+ *
377
+ * @param str - The string to ellipsize
378
+ * @param length - Total visible characters (split between start and end)
379
+ * @returns Ellipsized string or original if shorter than length
380
+ *
381
+ * @example
382
+ * ellipsizeString('0x1234567890abcdef', 8) // Returns: '0x12...cdef'
383
+ */
384
+ export function ellipsizeString(
385
+ str: string | undefined | null,
386
+ length: number
387
+ ): string {
388
+ if (!str) return ''
389
+ if (str.length < length) return str
390
+ const first = str.substring(0, length / 2)
391
+ const second = str.substring(str.length - length / 2, str.length)
392
+ return [first, second].join('...')
393
+ }
394
+
395
+ /**
396
+ * Ellipsizes a hex string (0x-prefixed) preserving the prefix.
397
+ *
398
+ * @param hex - The hex string to ellipsize (must start with 0x)
399
+ * @param length - Visible characters in the payload (default: 12)
400
+ * @returns Ellipsized hex string
401
+ * @throws Error if hex doesn't start with 0x
402
+ *
403
+ * @example
404
+ * ellipsizeHex('0x1234567890abcdef1234567890abcdef') // Returns: '0x123456...abcdef'
405
+ */
406
+ export function ellipsizeHex(hex: string, length: number = 12): string {
407
+ if (!hex || !hex.startsWith('0x')) throw new Error('Invalid hex')
408
+ const prefix = '0x'
409
+ const payload = hex.replace('0x', '')
410
+ const shortenedPayload = ellipsizeString(payload, length)
411
+ return `${prefix}${shortenedPayload}`
412
+ }
413
+
414
+ /**
415
+ * Truncates a string to a specified length, adding '...' at the end.
416
+ *
417
+ * @param str - The string to truncate
418
+ * @param length - Maximum length before truncation
419
+ * @returns Truncated string with '...' or original if shorter
420
+ *
421
+ * @example
422
+ * truncateString('Hello World', 5) // Returns: 'Hello...'
423
+ */
424
+ export function truncateString(
425
+ str: string | undefined | null = '',
426
+ length: number
427
+ ): string {
428
+ if (!str) return ''
429
+ if (str.length < length) return str
430
+ return `${str.substring(0, length)}...`
431
+ }
432
+
433
+ /**
434
+ * Intelligently shortens a label based on its type (hex, URL, IPFS, DID, etc.)
435
+ * This is the main utility for displaying atom/entity labels in the UI.
436
+ *
437
+ * @param label - The label to shorten
438
+ * @param length - Maximum length for generic strings (default: 50)
439
+ * @returns Shortened label appropriate for its type
440
+ *
441
+ * @example
442
+ * shortenLabel('0x1234567890abcdef...') // Returns: '0x1234...cdef'
443
+ * shortenLabel('https://example.com/page') // Returns: 'example.com/page'
444
+ * shortenLabel('ipfs://bafkre...') // Returns: 'bafkre...xyz'
445
+ */
446
+ export function shortenLabel(
447
+ label: string | null | undefined,
448
+ length: number = 50
449
+ ): string {
450
+ if (!label) return ''
451
+
452
+ // Handle hex addresses
453
+ if (label.startsWith('0x')) {
454
+ return ellipsizeHex(label, 12)
455
+ }
456
+
457
+ // Handle IPFS
458
+ if (label.startsWith('bafkre')) {
459
+ return ellipsizeString(label, 12)
460
+ }
461
+ if (label.startsWith('ipfs://bafkre')) {
462
+ return ellipsizeString(label.replace('ipfs://', ''), 12)
463
+ }
464
+
465
+ // Handle URLs - remove protocol and truncate if needed
466
+ if (label.startsWith('https://') || label.startsWith('http://')) {
467
+ const cleanUrl = label.replace(/^https?:\/\//, '')
468
+ return cleanUrl.length > length ? cleanUrl.slice(0, length) + '...' : cleanUrl
469
+ }
470
+
471
+ // Handle DID URIs
472
+ if (label.startsWith('did:')) {
473
+ // Extract meaningful parts of DID
474
+ if (label.includes('eip155:') && label.includes('|discord:')) {
475
+ const parts = label.split('|')
476
+ const ethPart = parts[0] // did:eip155:84532:0x...
477
+ const discordPart = parts[1] // discord:391378559945670667
478
+
479
+ // Extract just the address from the first part
480
+ const addressMatch = ethPart.match(/0x[a-fA-F0-9]+/)
481
+ const address = addressMatch ? ellipsizeHex(addressMatch[0], 8) : ethPart
482
+
483
+ return `${address} | ${discordPart}`
484
+ }
485
+
486
+ // Fallback for other DID formats
487
+ return ellipsizeString(label, length)
488
+ }
489
+
490
+ // Handle CAIP-10 identifiers
491
+ if (label.startsWith('caip10:eip155:')) {
492
+ const parsedLabel = label.split(':')
493
+ const addressIndex = parsedLabel.findIndex(part => part.startsWith('0x'))
494
+ if (addressIndex !== -1) {
495
+ const address = parsedLabel[addressIndex]
496
+ parsedLabel[addressIndex] = ellipsizeHex(address, 12)
497
+ }
498
+ return parsedLabel.join(':')
499
+ }
500
+
501
+ // Default: truncate to specified length
502
+ return label.length > length ? label.slice(0, length) + '...' : label
503
+ }
504
+
505
+ // ============================================================================
506
+ // Capitalization Utilities
507
+ // ============================================================================
508
+
509
+ /**
510
+ * Capitalizes the first letter of a string.
511
+ *
512
+ * @param str - The string to capitalize
513
+ * @returns String with first letter capitalized
514
+ *
515
+ * @example
516
+ * capitalizeFirstLetter('hello') // Returns: 'Hello'
517
+ */
518
+ export function capitalizeFirstLetter(str: string): string {
519
+ return str.charAt(0).toUpperCase() + str.slice(1)
520
+ }
521
+
522
+ /**
523
+ * Capitalizes the first letter of each word in a phrase.
524
+ *
525
+ * @param phrase - The phrase to capitalize
526
+ * @returns Phrase with each word capitalized
527
+ *
528
+ * @example
529
+ * capitalize('hello world') // Returns: 'Hello World'
530
+ */
531
+ export function capitalize(phrase: string): string {
532
+ if (!phrase) return ''
533
+ return phrase
534
+ .split(' ')
535
+ .map(word => word ? word.charAt(0).toUpperCase() + word.slice(1) : '')
536
+ .join(' ')
537
+ }
538
+
539
+ // ============================================================================
540
+ // Time Utilities
541
+ // ============================================================================
542
+
543
+ /**
544
+ * Converts seconds to a compact human-readable format (e.g., "5d", "3h", "10m").
545
+ *
546
+ * @param seconds - Number of seconds
547
+ * @returns Compact time string
548
+ *
549
+ * @example
550
+ * secondsToHms(3700) // Returns: '1h'
551
+ * secondsToHms(90) // Returns: '1m'
552
+ */
553
+ export function secondsToHms(seconds: number): string {
554
+ seconds = Number(seconds)
555
+ const d = Math.floor(seconds / (3600 * 24))
556
+ if (d > 0) return `${d}d`
557
+ const h = Math.floor(seconds / 3600)
558
+ if (h > 0) return `${h}h`
559
+ const m = Math.floor((seconds % 3600) / 60)
560
+ if (m > 0) return `${m}m`
561
+ const s = Math.floor((seconds % 3600) % 60)
562
+ if (s > 0) return `${s}s`
563
+ return ''
564
+ }
565
+
566
+ /**
567
+ * Formats a date as a human-readable "time ago" string.
568
+ * Handles various date input formats including Unix timestamps.
569
+ *
570
+ * @param date - Date object, ISO string, or Unix timestamp (10 or 13 digits)
571
+ * @param ago - Whether to append "ago" suffix (default: true)
572
+ * @returns Human-readable time difference string
573
+ *
574
+ * @example
575
+ * timeAgo(new Date(Date.now() - 3600000)) // Returns: '1h ago'
576
+ * timeAgo('1703001600', false) // Returns: '5d'
577
+ */
578
+ export function timeAgo(date: Date | string, ago: boolean = true): string {
579
+ let output: Date
580
+ if (typeof date === 'string') {
581
+ if (date.length === 10) {
582
+ // Unix timestamp in seconds
583
+ output = new Date(parseInt(date) * 1000)
584
+ } else if (date.length === 13) {
585
+ // Unix timestamp in milliseconds
586
+ output = new Date(parseInt(date))
587
+ } else {
588
+ // ISO string or other format
589
+ output = new Date(date)
590
+ }
591
+ } else {
592
+ output = date
593
+ }
594
+
595
+ const dateTimestamp = output.getTime() / 1000
596
+ const nowTimestamp = Date.now() / 1000
597
+ const timeGap = secondsToHms(nowTimestamp - dateTimestamp)
598
+
599
+ return ago ? `${timeGap} ago` : timeGap
600
+ }
601
+
602
+ /**
603
+ * Converts seconds to a descriptive duration string (e.g., "5 d", "3 h").
604
+ *
605
+ * @param inputVar - Seconds as number or string
606
+ * @returns Duration string with space before unit
607
+ *
608
+ * @example
609
+ * secondsToDhms(90000) // Returns: '1 d'
610
+ */
611
+ export function secondsToDhms(inputVar: string | number): string {
612
+ const inputNumber = Number(inputVar)
613
+ const days = Math.floor(inputNumber / (3600 * 24))
614
+ const hours = Math.floor((inputNumber % (3600 * 24)) / 3600)
615
+ const minutes = Math.floor(((inputNumber % (3600 * 24)) % 3600) / 60)
616
+ const seconds = Math.floor(((inputNumber % (3600 * 24)) % 3600) % 60)
617
+
618
+ if (days) return `${days} d`
619
+ if (hours) return `${hours} h`
620
+ if (minutes) return `${minutes} m`
621
+ if (seconds) return `${seconds} s`
622
+ return ''
623
+ }
624
+
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Utility functions shared across Hive Mind applications
3
+ */
4
+
5
+ export { cn } from './cn'
6
+ export * from './formatting'
7
+ export * from './atom'
8
+ export * from './atom-label-detection'
9
+ export * from './search'
10
+ export * from './multivault-errors'
11
+