@asteby/metacore-runtime-react 23.2.0 → 23.4.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.
@@ -38,11 +38,17 @@ import {
38
38
  import {
39
39
  generateBadgeStyles,
40
40
  getInitials,
41
- optionColor,
42
41
  relationChipStyles,
43
42
  } from '@asteby/metacore-ui/lib'
44
43
  import { Progress } from './dialogs/_primitives'
45
44
  import { humanizeToken } from './dynamic-columns-helpers'
45
+ import {
46
+ OptionBadge,
47
+ RelationThumbnail,
48
+ statusColorFor,
49
+ useIsDarkTheme,
50
+ } from './display-value'
51
+ import { MediaValue } from './rich-url'
46
52
  import { OptionsContext } from './options-context'
47
53
  import { DynamicIcon, isLucideIconName } from './dynamic-icon'
48
54
  import { CollectionCell } from './collection-cell'
@@ -158,27 +164,6 @@ export const formatAggregateTotal = (
158
164
  )
159
165
  }
160
166
 
161
- /**
162
- * Semantic status → badge color. Used by the `status` cell when no explicit
163
- * `options` color is declared. Generic, value-driven mapping.
164
- */
165
- const statusColorFor = (value: string): string => {
166
- const v = value.toLowerCase()
167
- if (
168
- ['active', 'enabled', 'paid', 'completed', 'done', 'success', 'approved', 'open']
169
- .includes(v)
170
- )
171
- return '#22c55e'
172
- if (['pending', 'draft', 'processing', 'in_progress', 'review', 'waiting'].includes(v))
173
- return '#eab308'
174
- if (
175
- ['inactive', 'disabled', 'cancelled', 'canceled', 'failed', 'rejected', 'error', 'closed']
176
- .includes(v)
177
- )
178
- return '#ef4444'
179
- return '#6b7280'
180
- }
181
-
182
167
  /** Copyable monospaced text cell (code/IDs/hashes). */
183
168
  const CodeCell: React.FC<{ text: string; maxLength?: number }> = ({ text, maxLength }) => {
184
169
  const [copied, setCopied] = React.useState(false)
@@ -293,26 +278,6 @@ const getValueFromPathVariants = (obj: any, path?: string) => {
293
278
  return undefined
294
279
  }
295
280
 
296
- const useIsDarkTheme = () => {
297
- const [isDark, setIsDark] = React.useState(() =>
298
- typeof document !== 'undefined' &&
299
- document.documentElement.classList.contains('dark')
300
- )
301
- React.useEffect(() => {
302
- if (typeof document === 'undefined') return
303
- const sync = () =>
304
- setIsDark(document.documentElement.classList.contains('dark'))
305
- sync()
306
- const observer = new MutationObserver(sync)
307
- observer.observe(document.documentElement, {
308
- attributes: true,
309
- attributeFilter: ['class'],
310
- })
311
- return () => observer.disconnect()
312
- }, [])
313
- return isDark
314
- }
315
-
316
281
  const renderRelationBadges = (items: any, col: ColumnDefinition) => {
317
282
  if (!Array.isArray(items) || items.length === 0) {
318
283
  return <span className="text-muted-foreground">-</span>
@@ -359,66 +324,6 @@ const renderRelationBadges = (items: any, col: ColumnDefinition) => {
359
324
  )
360
325
  }
361
326
 
362
- /**
363
- * Tiny square thumbnail for a resolved relation/option that carries an `image`
364
- * (brand logo, product photo, customer/user avatar). Uses the same Avatar
365
- * primitive as the `avatar`/`creator` cells so a broken/loading image
366
- * gracefully falls back to the record's initials. Sized small (the box is an
367
- * inline style so an addon-arbitrary Tailwind class never gets dropped by a
368
- * consuming app's class scan). Rendered inline alongside a label — never alone.
369
- */
370
- const RelationThumbnail: React.FC<{
371
- src: string
372
- alt: string
373
- getImageUrl?: (path: string) => string
374
- size?: number
375
- }> = ({ src, alt, getImageUrl, size = 18 }) => (
376
- <Avatar
377
- className="shrink-0 rounded-sm ring-1 ring-border/40"
378
- style={{ width: size, height: size }}
379
- >
380
- <AvatarImage
381
- src={getImageUrl ? getImageUrl(src) : src}
382
- alt={alt}
383
- className="object-cover"
384
- />
385
- <AvatarFallback className="rounded-sm bg-primary/10 text-[8px] font-bold text-primary">
386
- {getInitials(alt)}
387
- </AvatarFallback>
388
- </Avatar>
389
- )
390
-
391
- interface OptionBadgeProps {
392
- option: { value: string; label: string; icon?: string; color?: string; image?: string }
393
- fallback: string
394
- getImageUrl?: (path: string) => string
395
- }
396
-
397
- const OptionBadge: React.FC<OptionBadgeProps> = ({ option, getImageUrl }) => {
398
- const isDark = useIsDarkTheme()
399
- // Explicit backend color wins; otherwise derive a stable, cohesive color
400
- // from the option's value (fallback label) so "dead" gray badges come
401
- // alive. Inline style (hex-derived) so it works regardless of the host's
402
- // tailwind safelist — addon-arbitrary classes aren't in the host scan.
403
- const colorSource = option.color || optionColor(option.value || option.label)
404
- const colorStyles = generateBadgeStyles(colorSource, { isDark })
405
- return (
406
- <Badge variant="outline" className="flex items-center gap-1 border-0" style={colorStyles}>
407
- {option.image ? (
408
- <RelationThumbnail
409
- src={option.image}
410
- alt={option.label}
411
- getImageUrl={getImageUrl}
412
- size={16}
413
- />
414
- ) : (
415
- option.icon && <DynamicIcon name={option.icon} className="h-3.5 w-3.5" />
416
- )}
417
- <span>{option.label}</span>
418
- </Badge>
419
- )
420
- }
421
-
422
327
  const BadgeWithEndpointOptions: React.FC<{
423
328
  endpoint: string
424
329
  value: any
@@ -939,33 +844,24 @@ export function makeDefaultGetDynamicColumns(
939
844
  : value
940
845
  if (!rawUrl) return <EmptyCell />
941
846
  const urlStr = String(rawUrl)
942
- const href = /^https?:\/\//i.test(urlStr) ? urlStr : `https://${urlStr}`
943
- let label: string
944
- if (labelField) {
945
- label = String(getNestedValue(row.original, labelField) ?? href)
946
- } else {
947
- try {
948
- label = new URL(href).hostname
949
- } catch {
950
- label = urlStr
951
- }
952
- }
953
- const isExternal = !/^https?:\/\/(localhost|127\.)/i.test(href)
954
- const newTab =
955
- styleCfg(col, 'new_tab', 'newTab') === true || isExternal
956
- const iconName = styleCfg(col, 'icon') || 'ExternalLink'
847
+ // Explicit label from a `label_field` wins; otherwise
848
+ // the shared primitive derives a smart label (hostname
849
+ // / file name). Images render as a small (~h-8) inline
850
+ // thumbnail, files as a chip, else a link chip. Same
851
+ // renderer as the detail dialog.
852
+ const label = labelField
853
+ ? String(getNestedValue(row.original, labelField) ?? '')
854
+ : undefined
855
+ const iconName = styleCfg(col, 'icon')
957
856
  return (
958
- <a
959
- href={href}
960
- {...(newTab
961
- ? { target: '_blank', rel: 'noopener noreferrer' }
962
- : {})}
963
- className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
964
- onClick={(e) => e.stopPropagation()}
965
- >
966
- <DynamicIcon name={iconName} className="h-3.5 w-3.5 shrink-0" />
967
- <span className="truncate max-w-[260px]">{label}</span>
968
- </a>
857
+ <MediaValue
858
+ url={urlStr}
859
+ getImageUrl={getImageUrl}
860
+ label={label || undefined}
861
+ icon={iconName || undefined}
862
+ thumbHeight={32}
863
+ maxLabelWidth={260}
864
+ />
969
865
  )
970
866
  }
971
867
 
@@ -139,11 +139,13 @@ export function useDynamicRowActions({
139
139
  try {
140
140
  const deleteEndpoint = endpoint ? `${endpoint}/${rowToDelete.id}` : `/data/${model}/${rowToDelete.id}`
141
141
  const res = await api.delete(deleteEndpoint)
142
- if (res.data.success) { toast.success(res.data.message || 'Eliminado correctamente'); onRefresh() }
143
- else toast.error(res.data.message || 'Error al eliminar')
142
+ // CRUD estándar: no usar res.data.message (el endpoint dinámico
143
+ // devuelve texto en inglés que se filtraría al toast). String localizado.
144
+ if (res.data.success) { toast.success(t('dynamic.delete_success', { defaultValue: 'Registro eliminado correctamente' })); onRefresh() }
145
+ else toast.error(t('dynamic.delete_error', { defaultValue: 'No se pudo eliminar el registro' }))
144
146
  } catch (error) {
145
147
  console.error('Error al eliminar', error)
146
- toast.error('Error al eliminar el registro')
148
+ toast.error(t('dynamic.delete_error', { defaultValue: 'No se pudo eliminar el registro' }))
147
149
  } finally {
148
150
  setIsDeleting(false)
149
151
  setRowToDelete(null)
@@ -628,8 +628,8 @@ export function DynamicTable({
628
628
  setBulkDeleteProgress(0)
629
629
  setBulkDeleteTotal(0)
630
630
  setRowSelection({})
631
- if (successCount > 0) toast.success(`${successCount} registro(s) eliminado(s) correctamente`)
632
- if (errorCount > 0) toast.error(`${errorCount} registro(s) no pudieron ser eliminados`)
631
+ if (successCount > 0) toast.success(t('dynamic.bulk_delete_success', { count: successCount, defaultValue: '{{count}} registro(s) eliminado(s) correctamente' }))
632
+ if (errorCount > 0) toast.error(t('dynamic.bulk_delete_error', { count: errorCount, defaultValue: '{{count}} registro(s) no pudieron ser eliminados' }))
633
633
  handleRefresh()
634
634
  }
635
635
 
package/src/index.ts CHANGED
@@ -129,6 +129,23 @@ export {
129
129
  type DynamicColumnsHelpers,
130
130
  } from './dynamic-columns'
131
131
  export { humanizeToken } from './dynamic-columns-helpers'
132
+ export {
133
+ UrlChip,
134
+ FileChip,
135
+ ImageThumbnail,
136
+ MediaValue,
137
+ RichText,
138
+ linkifyText,
139
+ classifyUrl,
140
+ isImageUrl,
141
+ isFileUrl,
142
+ ensureHref,
143
+ smartUrlLabel,
144
+ fileNameFromUrl,
145
+ splitTrailingPunct,
146
+ type UrlKind,
147
+ type LinkifyOptions,
148
+ } from './rich-url'
132
149
  export {
133
150
  CollectionCell,
134
151
  formatScalar,
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Rich URL & media rendering primitives.
3
+ *
4
+ * A URL is never shown raw. Depending on what it points at we render:
5
+ * - an IMAGE (jpg/png/gif/webp/… or a `link.asteby.com/storage/media/` path)
6
+ * → an inline clickable THUMBNAIL that opens the full image in a new tab,
7
+ * - a FILE (pdf/zip/docx/…) → a chip with a file icon and the file name,
8
+ * - anything else → a compact link CHIP: an external-link glyph plus a short
9
+ * smart label (hostname, e.g. "github.com", or the last path segment for a
10
+ * file-like URL), with the full URL in a native tooltip.
11
+ *
12
+ * These live here (not inline in the table/dialog) so the dynamic TABLE cell
13
+ * (`dynamic-columns.tsx`) and the read-only DETAIL DIALOG (`dynamic-record.tsx`)
14
+ * share the EXACT same rendering. Ecosystem rule: shared primitives, zero copy.
15
+ *
16
+ * No external requests are made to render a link (no favicon service — CSP /
17
+ * privacy): the icon is a local lucide glyph.
18
+ */
19
+ import React from 'react'
20
+ import { cn } from '@asteby/metacore-ui/lib'
21
+ import { DynamicIcon } from './dynamic-icon'
22
+
23
+ /** Image file extensions we render as an inline thumbnail. */
24
+ const IMAGE_EXT_RE = /\.(jpe?g|png|gif|webp|avif|svg|bmp|ico)(?:[?#].*)?$/i
25
+
26
+ /**
27
+ * Storage paths that serve images without a file extension (the link.asteby.com
28
+ * media CDN hands back opaque ids). Treated as images so uploaded photos still
29
+ * thumbnail.
30
+ */
31
+ const MEDIA_PATH_RE = /\/storage\/media\//i
32
+
33
+ /** Non-image file extensions we render as a download-ish file chip. */
34
+ const FILE_EXT_RE =
35
+ /\.(pdf|zip|rar|7z|tar|t?gz|docx?|xlsx?|pptx?|csv|txt|json|xml|md|rtf|odt|mp4|mov|avi|mkv|webm|mp3|wav|ogg|flac|apk|dmg|exe|pkg)(?:[?#].*)?$/i
36
+
37
+ export type UrlKind = 'image' | 'file' | 'link'
38
+
39
+ /** `true` when the string looks like an image URL/path. */
40
+ export function isImageUrl(url: string): boolean {
41
+ return IMAGE_EXT_RE.test(url) || MEDIA_PATH_RE.test(url)
42
+ }
43
+
44
+ /** `true` when the string looks like a non-image downloadable file. */
45
+ export function isFileUrl(url: string): boolean {
46
+ return FILE_EXT_RE.test(url)
47
+ }
48
+
49
+ /** Classify a URL so the right primitive (thumbnail / file chip / link) renders. */
50
+ export function classifyUrl(url: string): UrlKind {
51
+ if (isImageUrl(url)) return 'image'
52
+ if (isFileUrl(url)) return 'file'
53
+ return 'link'
54
+ }
55
+
56
+ /** Ensure a URL is absolute so `<a href>` and `new URL()` behave. */
57
+ export function ensureHref(url: string): string {
58
+ return /^(https?:)?\/\//i.test(url) || /^(mailto|tel):/i.test(url)
59
+ ? url
60
+ : `https://${url}`
61
+ }
62
+
63
+ /**
64
+ * A short, human label for a URL. For a file-like URL (last segment carries an
65
+ * extension) the file name wins ("report.pdf"); otherwise the bare hostname
66
+ * ("github.com"), so a 120-char issue URL never shows raw. Falls back to the
67
+ * input when it can't be parsed.
68
+ */
69
+ export function smartUrlLabel(url: string): string {
70
+ try {
71
+ const u = new URL(ensureHref(url))
72
+ const segs = u.pathname.split('/').filter(Boolean)
73
+ const last = segs[segs.length - 1]
74
+ if (last && /\.[a-z0-9]{1,8}$/i.test(last)) {
75
+ return decodeURIComponent(last)
76
+ }
77
+ return u.hostname.replace(/^www\./i, '')
78
+ } catch {
79
+ return url
80
+ }
81
+ }
82
+
83
+ /** Last path segment (decoded) — the file name for a file/image URL. */
84
+ export function fileNameFromUrl(url: string): string {
85
+ try {
86
+ const u = new URL(ensureHref(url))
87
+ const segs = u.pathname.split('/').filter(Boolean)
88
+ return decodeURIComponent(segs[segs.length - 1] || u.hostname)
89
+ } catch {
90
+ return url.split(/[?#]/)[0].split('/').pop() || url
91
+ }
92
+ }
93
+
94
+ /** Stops row-click / dialog-close when a link inside them is clicked. */
95
+ function stop(e: React.MouseEvent) {
96
+ e.stopPropagation()
97
+ }
98
+
99
+ /**
100
+ * Compact link chip: external-link glyph + smart label, full URL in the
101
+ * tooltip, opens in a new tab. The canonical render for a plain (non-media)
102
+ * URL, matching the table's `url`/`link` cell.
103
+ */
104
+ export const UrlChip: React.FC<{
105
+ url: string
106
+ label?: string
107
+ icon?: string
108
+ /** Smaller label cap for a table/kanban cell. */
109
+ maxLabelWidth?: number
110
+ className?: string
111
+ }> = ({ url, label, icon, maxLabelWidth = 260, className }) => {
112
+ const href = ensureHref(url)
113
+ const text = label || smartUrlLabel(url)
114
+ return (
115
+ <a
116
+ href={href}
117
+ target="_blank"
118
+ rel="noopener noreferrer"
119
+ title={url}
120
+ onClick={stop}
121
+ className={cn(
122
+ 'inline-flex max-w-full items-center gap-1.5 align-middle text-sm font-medium text-primary hover:underline',
123
+ className
124
+ )}
125
+ >
126
+ <DynamicIcon name={icon || 'ExternalLink'} className="h-3.5 w-3.5 shrink-0" />
127
+ <span className="truncate" style={{ maxWidth: maxLabelWidth }}>
128
+ {text}
129
+ </span>
130
+ </a>
131
+ )
132
+ }
133
+
134
+ /**
135
+ * Chip for a downloadable non-image file: file-text glyph + the file name,
136
+ * opens the file in a new tab.
137
+ */
138
+ export const FileChip: React.FC<{
139
+ url: string
140
+ maxLabelWidth?: number
141
+ className?: string
142
+ }> = ({ url, maxLabelWidth = 240, className }) => {
143
+ const href = ensureHref(url)
144
+ return (
145
+ <a
146
+ href={href}
147
+ target="_blank"
148
+ rel="noopener noreferrer"
149
+ title={url}
150
+ onClick={stop}
151
+ className={cn(
152
+ 'inline-flex max-w-full items-center gap-1.5 rounded-md border bg-muted/40 px-2 py-1 align-middle text-sm font-medium text-foreground hover:bg-muted',
153
+ className
154
+ )}
155
+ >
156
+ <DynamicIcon name="FileText" className="h-3.5 w-3.5 shrink-0 opacity-70" />
157
+ <span className="truncate" style={{ maxWidth: maxLabelWidth }}>
158
+ {fileNameFromUrl(url)}
159
+ </span>
160
+ </a>
161
+ )
162
+ }
163
+
164
+ /**
165
+ * Inline image thumbnail — rounded, bordered, `object-cover` — that opens the
166
+ * full image in a new tab. On load error it degrades to a normal link chip so a
167
+ * dead image URL is still reachable (and never a broken-image icon). `maxHeight`
168
+ * keeps a cell thumbnail small (~h-8) while the dialog shows a larger preview.
169
+ */
170
+ export const ImageThumbnail: React.FC<{
171
+ url: string
172
+ getImageUrl?: (path: string) => string
173
+ /** Max rendered height in px. */
174
+ maxHeight?: number
175
+ className?: string
176
+ }> = ({ url, getImageUrl, maxHeight = 160, className }) => {
177
+ const [failed, setFailed] = React.useState(false)
178
+ const href = ensureHref(url)
179
+ // Absolute URLs are used verbatim; only relative storage paths go through
180
+ // the host's image resolver (which prefixes the media base).
181
+ const src =
182
+ /^(https?:)?\/\//i.test(url) ? url : getImageUrl ? getImageUrl(url) : url
183
+ if (failed) return <UrlChip url={url} icon="Image" />
184
+ return (
185
+ <a
186
+ href={href}
187
+ target="_blank"
188
+ rel="noopener noreferrer"
189
+ title={url}
190
+ onClick={stop}
191
+ className="inline-block max-w-full align-middle"
192
+ >
193
+ <img
194
+ src={src}
195
+ alt={smartUrlLabel(url)}
196
+ onError={() => setFailed(true)}
197
+ style={{ maxHeight }}
198
+ className={cn(
199
+ 'max-w-full rounded-md border object-cover',
200
+ className
201
+ )}
202
+ />
203
+ </a>
204
+ )
205
+ }
206
+
207
+ /**
208
+ * One URL value → the right primitive (thumbnail / file chip / link chip).
209
+ * Single entry point used by both the table cell and the detail dialog so a URL
210
+ * renders identically everywhere.
211
+ */
212
+ export const MediaValue: React.FC<{
213
+ url: string
214
+ getImageUrl?: (path: string) => string
215
+ /** Explicit label for the link chip (from a `label_field`). */
216
+ label?: string
217
+ /** Explicit icon for the link chip. */
218
+ icon?: string
219
+ /** Thumbnail max height (small in a cell, larger in the dialog). */
220
+ thumbHeight?: number
221
+ /** Link/file label truncation cap. */
222
+ maxLabelWidth?: number
223
+ className?: string
224
+ }> = ({ url, getImageUrl, label, icon, thumbHeight, maxLabelWidth, className }) => {
225
+ switch (classifyUrl(url)) {
226
+ case 'image':
227
+ return (
228
+ <ImageThumbnail
229
+ url={url}
230
+ getImageUrl={getImageUrl}
231
+ maxHeight={thumbHeight}
232
+ className={className}
233
+ />
234
+ )
235
+ case 'file':
236
+ return <FileChip url={url} maxLabelWidth={maxLabelWidth} className={className} />
237
+ default:
238
+ return (
239
+ <UrlChip
240
+ url={url}
241
+ label={label}
242
+ icon={icon}
243
+ maxLabelWidth={maxLabelWidth}
244
+ className={className}
245
+ />
246
+ )
247
+ }
248
+ }
249
+
250
+ // URL / markdown-link matcher used to linkify free text. Captures either a
251
+ // markdown `[label](url)` or a bare http(s)/www URL. `[^\s<]+` is greedy on
252
+ // purpose — trailing punctuation is trimmed afterwards by `splitTrailingPunct`.
253
+ const LINK_IN_TEXT_RE =
254
+ /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s<]+|www\.[^\s<]+)/gi
255
+
256
+ /**
257
+ * Peel trailing punctuation a URL shouldn't swallow (sentence period, closing
258
+ * bracket, quote). A closing paren is kept only when the URL itself opened one
259
+ * (Wikipedia-style `(…)` paths), so `(see https://x.com/a)` links `https://x.com/a`
260
+ * but `https://en.wikipedia.org/wiki/Foo_(bar)` keeps its paren.
261
+ */
262
+ export function splitTrailingPunct(match: string): [string, string] {
263
+ const m = match.match(/[).,;:!?\]}'"»›]+$/)
264
+ if (!m) return [match, '']
265
+ let trail = m[0]
266
+ let core = match.slice(0, match.length - trail.length)
267
+ if (trail.startsWith(')')) {
268
+ const opens = (core.match(/\(/g) || []).length
269
+ const closes = (core.match(/\)/g) || []).length
270
+ if (opens > closes) {
271
+ core += ')'
272
+ trail = trail.slice(1)
273
+ }
274
+ }
275
+ return [core, trail]
276
+ }
277
+
278
+ export interface LinkifyOptions {
279
+ getImageUrl?: (path: string) => string
280
+ /** Max height for an inline image found in the text. */
281
+ imageHeight?: number
282
+ }
283
+
284
+ /**
285
+ * Turn free text into React nodes, replacing every URL (bare or markdown
286
+ * `[label](url)`) with the matching rich primitive: an inline thumbnail for an
287
+ * image URL, a file chip for a file, otherwise a link chip. Plain text between
288
+ * matches is preserved verbatim (caller keeps `whitespace-pre-wrap`).
289
+ */
290
+ export function linkifyText(
291
+ text: string,
292
+ opts: LinkifyOptions = {}
293
+ ): React.ReactNode[] {
294
+ if (!text) return []
295
+ const nodes: React.ReactNode[] = []
296
+ const re = new RegExp(LINK_IN_TEXT_RE.source, 'gi')
297
+ let lastIndex = 0
298
+ let key = 0
299
+ let m: RegExpExecArray | null
300
+ while ((m = re.exec(text)) !== null) {
301
+ const [full, mdLabel, mdUrl, bareUrl] = m
302
+ const start = m.index
303
+ if (start > lastIndex) nodes.push(text.slice(lastIndex, start))
304
+
305
+ if (mdUrl) {
306
+ // Markdown link: honor the author's label, keep media inline.
307
+ const kind = classifyUrl(mdUrl)
308
+ if (kind === 'image') {
309
+ nodes.push(
310
+ <ImageThumbnail
311
+ key={key}
312
+ url={mdUrl}
313
+ getImageUrl={opts.getImageUrl}
314
+ maxHeight={opts.imageHeight ?? 200}
315
+ />
316
+ )
317
+ } else if (kind === 'file') {
318
+ nodes.push(<FileChip key={key} url={mdUrl} />)
319
+ } else {
320
+ nodes.push(<UrlChip key={key} url={mdUrl} label={mdLabel} />)
321
+ }
322
+ lastIndex = start + full.length
323
+ } else {
324
+ const [core, trail] = splitTrailingPunct(bareUrl)
325
+ const kind = classifyUrl(core)
326
+ if (kind === 'image') {
327
+ nodes.push(
328
+ <ImageThumbnail
329
+ key={key}
330
+ url={core}
331
+ getImageUrl={opts.getImageUrl}
332
+ maxHeight={opts.imageHeight ?? 200}
333
+ />
334
+ )
335
+ } else if (kind === 'file') {
336
+ nodes.push(<FileChip key={key} url={core} />)
337
+ } else {
338
+ nodes.push(<UrlChip key={key} url={core} />)
339
+ }
340
+ if (trail) nodes.push(trail)
341
+ lastIndex = start + bareUrl.length
342
+ }
343
+ key++
344
+ }
345
+ if (lastIndex < text.length) nodes.push(text.slice(lastIndex))
346
+ return nodes
347
+ }
348
+
349
+ /**
350
+ * Linkified free text. Renders the raw string with every URL turned into a rich
351
+ * chip/thumbnail — the read view for a long-text / textarea / body field.
352
+ */
353
+ export const RichText: React.FC<{
354
+ text: string
355
+ getImageUrl?: (path: string) => string
356
+ imageHeight?: number
357
+ }> = ({ text, getImageUrl, imageHeight }) => (
358
+ <>{linkifyText(text, { getImageUrl, imageHeight })}</>
359
+ )