/**
* Rich URL & media rendering primitives.
*
* A URL is never shown raw. Depending on what it points at we render:
* - an IMAGE (jpg/png/gif/webp/… or a `link.asteby.com/storage/media/` path)
* → an inline clickable THUMBNAIL that opens the full image in a new tab,
* - a FILE (pdf/zip/docx/…) → a chip with a file icon and the file name,
* - anything else → a compact link CHIP: an external-link glyph plus a short
* smart label (hostname, e.g. "github.com", or the last path segment for a
* file-like URL), with the full URL in a native tooltip.
*
* These live here (not inline in the table/dialog) so the dynamic TABLE cell
* (`dynamic-columns.tsx`) and the read-only DETAIL DIALOG (`dynamic-record.tsx`)
* share the EXACT same rendering. Ecosystem rule: shared primitives, zero copy.
*
* No external requests are made to render a link (no favicon service — CSP /
* privacy): the icon is a local lucide glyph.
*/
import React from 'react'
import { cn } from '@asteby/metacore-ui/lib'
import { Dialog, DialogContent, DialogTitle } from '@asteby/metacore-ui/primitives'
import { DynamicIcon } from './dynamic-icon'
/** Image file extensions we render as an inline thumbnail. */
const IMAGE_EXT_RE = /\.(jpe?g|png|gif|webp|avif|svg|bmp|ico)(?:[?#].*)?$/i
/**
* Storage paths that serve images without a file extension (the link.asteby.com
* media CDN hands back opaque ids). Treated as images so uploaded photos still
* thumbnail.
*/
const MEDIA_PATH_RE = /\/storage\/media\//i
/** Non-image file extensions we render as a download-ish file chip. */
const FILE_EXT_RE =
/\.(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
export type UrlKind = 'image' | 'file' | 'link'
/** `true` when the string looks like an image URL/path. */
export function isImageUrl(url: string): boolean {
return IMAGE_EXT_RE.test(url) || MEDIA_PATH_RE.test(url)
}
/** `true` when the string looks like a non-image downloadable file. */
export function isFileUrl(url: string): boolean {
return FILE_EXT_RE.test(url)
}
/** Classify a URL so the right primitive (thumbnail / file chip / link) renders. */
export function classifyUrl(url: string): UrlKind {
if (isImageUrl(url)) return 'image'
if (isFileUrl(url)) return 'file'
return 'link'
}
/**
* Ensure a URL is safe to drop into `` / `new URL(..., base)`.
*
* Three shapes pass through unchanged: fully qualified (`http(s)://…`),
* protocol-relative (`//…`), and — the platform's own convention for every
* locally-served asset (uploads, hub-generated images, printable documents:
* see handlers.UploadFile, hub_image_provider_bridge.go, …) — ROOT-RELATIVE
* (`/storage/…`). A root-relative path already resolves correctly against
* the current origin; treating it as a bare host (the old behavior) produced
* "https:///storage/…" (scheme + empty host), a broken link. Anything else
* (`github.com/foo`) is assumed to be a bare host missing its scheme.
*/
export function ensureHref(url: string): string {
return /^(https?:)?\/\//i.test(url) ||
/^(mailto|tel):/i.test(url) ||
url.startsWith('/')
? url
: `https://${url}`
}
/**
* A short, human label for a URL. For a file-like URL (last segment carries an
* extension) the file name wins ("report.pdf"); otherwise the bare hostname
* ("github.com"), so a 120-char issue URL never shows raw. Falls back to the
* input when it can't be parsed.
*/
export function smartUrlLabel(url: string): string {
try {
const u = new URL(ensureHref(url))
const segs = u.pathname.split('/').filter(Boolean)
const last = segs[segs.length - 1]
if (last && /\.[a-z0-9]{1,8}$/i.test(last)) {
return decodeURIComponent(last)
}
return u.hostname.replace(/^www\./i, '')
} catch {
return url
}
}
/** Last path segment (decoded) — the file name for a file/image URL. */
export function fileNameFromUrl(url: string): string {
try {
const u = new URL(ensureHref(url))
const segs = u.pathname.split('/').filter(Boolean)
return decodeURIComponent(segs[segs.length - 1] || u.hostname)
} catch {
return url.split(/[?#]/)[0].split('/').pop() || url
}
}
/** Stops row-click / dialog-close when a link inside them is clicked. */
function stop(e: React.MouseEvent) {
e.stopPropagation()
}
/**
* Compact link chip: external-link glyph + smart label, full URL in the
* tooltip, opens in a new tab. The canonical render for a plain (non-media)
* URL, matching the table's `url`/`link` cell.
*/
export const UrlChip: React.FC<{
url: string
label?: string
icon?: string
/** Smaller label cap for a table/kanban cell. */
maxLabelWidth?: number
className?: string
}> = ({ url, label, icon, maxLabelWidth = 260, className }) => {
const href = ensureHref(url)
const text = label || smartUrlLabel(url)
return (
{text}
)
}
/**
* Chip for a downloadable non-image file: file-text glyph + the file name,
* opens the file in a new tab.
*/
export const FileChip: React.FC<{
url: string
maxLabelWidth?: number
className?: string
}> = ({ url, maxLabelWidth = 240, className }) => {
const href = ensureHref(url)
return (
{fileNameFromUrl(url)}
)
}
/**
* Full-size image preview — a Dialog overlay, not a new tab. Click-to-zoom is
* the platform-wide behavior for every inline thumbnail (table cell, detail
* dialog, linkified free text): the caller never builds its own lightbox.
* Download / "open original" stays one click away in the footer for the rare
* case someone wants the raw file.
*/
const ImageLightbox: React.FC<{
src: string
alt: string
open: boolean
onOpenChange: (open: boolean) => void
}> = ({ src, alt, open, onOpenChange }) => (
)
/**
* Inline image thumbnail — rounded, bordered, `object-cover` — that opens a
* full-size PREVIEW (a lightbox dialog, not a new tab). On load error it
* degrades to a normal link chip so a dead image URL is still reachable (and
* never a broken-image icon). `maxHeight` keeps a cell thumbnail small
* (~h-8) while the dialog shows a larger preview.
*/
export const ImageThumbnail: React.FC<{
url: string
getImageUrl?: (path: string) => string
/** Max rendered height in px. */
maxHeight?: number
className?: string
}> = ({ url, getImageUrl, maxHeight = 160, className }) => {
const [failed, setFailed] = React.useState(false)
const [open, setOpen] = React.useState(false)
// Absolute URLs are used verbatim; only relative storage paths go through
// the host's image resolver (which prefixes the media base). Anything
// that opens the raw file (download, the lightbox ) MUST resolve the
// same way — otherwise a relative "/storage/…" path (the platform's own
// convention: uploads, hub-generated assets, etc.) never matches
// ensureHref's "//"-prefixed check and gets misread as a bare host,
// producing "https:///storage/…".
const isAbsolute = /^(https?:)?\/\//i.test(url)
const src = isAbsolute ? url : getImageUrl ? getImageUrl(url) : url
const label = smartUrlLabel(url)
if (failed) return
return (
<>
>
)
}
/**
* One URL value → the right primitive (thumbnail / file chip / link chip).
* Single entry point used by both the table cell and the detail dialog so a URL
* renders identically everywhere.
*/
export const MediaValue: React.FC<{
url: string
getImageUrl?: (path: string) => string
/** Explicit label for the link chip (from a `label_field`). */
label?: string
/** Explicit icon for the link chip. */
icon?: string
/** Thumbnail max height (small in a cell, larger in the dialog). */
thumbHeight?: number
/** Link/file label truncation cap. */
maxLabelWidth?: number
className?: string
}> = ({ url, getImageUrl, label, icon, thumbHeight, maxLabelWidth, className }) => {
switch (classifyUrl(url)) {
case 'image':
return (
)
case 'file':
return
default:
return (
)
}
}
// URL / markdown-link matcher used to linkify free text. Captures either a
// markdown `[label](url)` or a bare http(s)/www URL. `[^\s<]+` is greedy on
// purpose — trailing punctuation is trimmed afterwards by `splitTrailingPunct`.
const LINK_IN_TEXT_RE =
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s<]+|www\.[^\s<]+)/gi
/**
* Peel trailing punctuation a URL shouldn't swallow (sentence period, closing
* bracket, quote). A closing paren is kept only when the URL itself opened one
* (Wikipedia-style `(…)` paths), so `(see https://x.com/a)` links `https://x.com/a`
* but `https://en.wikipedia.org/wiki/Foo_(bar)` keeps its paren.
*/
export function splitTrailingPunct(match: string): [string, string] {
const m = match.match(/[).,;:!?\]}'"»›]+$/)
if (!m) return [match, '']
let trail = m[0]
let core = match.slice(0, match.length - trail.length)
if (trail.startsWith(')')) {
const opens = (core.match(/\(/g) || []).length
const closes = (core.match(/\)/g) || []).length
if (opens > closes) {
core += ')'
trail = trail.slice(1)
}
}
return [core, trail]
}
export interface LinkifyOptions {
getImageUrl?: (path: string) => string
/** Max height for an inline image found in the text. */
imageHeight?: number
}
/**
* Turn free text into React nodes, replacing every URL (bare or markdown
* `[label](url)`) with the matching rich primitive: an inline thumbnail for an
* image URL, a file chip for a file, otherwise a link chip. Plain text between
* matches is preserved verbatim (caller keeps `whitespace-pre-wrap`).
*/
export function linkifyText(
text: string,
opts: LinkifyOptions = {}
): React.ReactNode[] {
if (!text) return []
const nodes: React.ReactNode[] = []
const re = new RegExp(LINK_IN_TEXT_RE.source, 'gi')
let lastIndex = 0
let key = 0
let m: RegExpExecArray | null
while ((m = re.exec(text)) !== null) {
const [full, mdLabel, mdUrl, bareUrl] = m
const start = m.index
if (start > lastIndex) nodes.push(text.slice(lastIndex, start))
if (mdUrl) {
// Markdown link: honor the author's label, keep media inline.
const kind = classifyUrl(mdUrl)
if (kind === 'image') {
nodes.push(
)
} else if (kind === 'file') {
nodes.push()
} else {
nodes.push()
}
lastIndex = start + full.length
} else {
const [core, trail] = splitTrailingPunct(bareUrl)
const kind = classifyUrl(core)
if (kind === 'image') {
nodes.push(
)
} else if (kind === 'file') {
nodes.push()
} else {
nodes.push()
}
if (trail) nodes.push(trail)
lastIndex = start + bareUrl.length
}
key++
}
if (lastIndex < text.length) nodes.push(text.slice(lastIndex))
return nodes
}
/**
* Linkified free text. Renders the raw string with every URL turned into a rich
* chip/thumbnail — the read view for a long-text / textarea / body field.
*/
export const RichText: React.FC<{
text: string
getImageUrl?: (path: string) => string
imageHeight?: number
}> = ({ text, getImageUrl, imageHeight }) => (
<>{linkifyText(text, { getImageUrl, imageHeight })}>
)