@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.
- package/CHANGELOG.md +33 -0
- package/dist/action-modal-dispatcher.js +4 -4
- package/dist/dialogs/dynamic-record.d.ts +5 -0
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +155 -34
- package/dist/display-value.d.ts +56 -0
- package/dist/display-value.d.ts.map +1 -0
- package/dist/display-value.js +74 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +13 -72
- package/dist/dynamic-row-actions.d.ts.map +1 -1
- package/dist/dynamic-row-actions.js +5 -3
- package/dist/dynamic-table.js +2 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/rich-url.d.ts +119 -0
- package/dist/rich-url.d.ts.map +1 -0
- package/dist/rich-url.js +224 -0
- package/package.json +1 -1
- package/src/__tests__/readonly-fields.test.tsx +97 -0
- package/src/__tests__/record-detail-display.test.tsx +113 -0
- package/src/__tests__/rich-url.test.tsx +196 -0
- package/src/action-modal-dispatcher.tsx +4 -4
- package/src/dialogs/dynamic-record.tsx +244 -48
- package/src/display-value.tsx +134 -0
- package/src/dynamic-columns.tsx +24 -128
- package/src/dynamic-row-actions.tsx +5 -3
- package/src/dynamic-table.tsx +2 -2
- package/src/index.ts +17 -0
- package/src/rich-url.tsx +359 -0
package/src/dynamic-columns.tsx
CHANGED
|
@@ -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
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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
|
-
<
|
|
959
|
-
|
|
960
|
-
{
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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('
|
|
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)
|
package/src/dynamic-table.tsx
CHANGED
|
@@ -628,8 +628,8 @@ export function DynamicTable({
|
|
|
628
628
|
setBulkDeleteProgress(0)
|
|
629
629
|
setBulkDeleteTotal(0)
|
|
630
630
|
setRowSelection({})
|
|
631
|
-
if (successCount > 0) toast.success(
|
|
632
|
-
if (errorCount > 0) toast.error(
|
|
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,
|
package/src/rich-url.tsx
ADDED
|
@@ -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
|
+
)
|