import { APP_API_BASE_URL } from '../../api/client' import { RECORD_VIEW_APP_ID, isDateType, toISODate } from '../../api/recordView' import { formatDate } from '../../utils/format' const ISO_DATE = /^\d{4}-\d{2}-\d{2}(T|\s|$)/ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const IMAGE_URL = /^https?:\/\/\S+\.(png|jpe?g|gif|webp|svg)(\?\S*)?$/i const isImageSrc = (s) => typeof s === 'string' && (IMAGE_URL.test(s.trim()) || s.startsWith('data:image/')) /** * A viewable URL for an uploaded file object ({ uuid, blob_path, … }), built * the way the Zino app does. Both preview routes are public, so they work as * a plain : * GET {APP_API_BASE_URL}/app/{APP_ID}/view/files/{uuid}/preview * GET {APP_API_BASE_URL}/app/{APP_ID}/view/files/preview?path= */ export function fileUrl(file) { if (!file || typeof file !== 'object') return null if (file.preview_url) return file.preview_url const base = `${APP_API_BASE_URL}/app/${RECORD_VIEW_APP_ID}/view/files` const uuid = file.uuid || file.repo_file_uuid if (uuid) return `${base}/${encodeURIComponent(uuid)}/preview` if (file.blob_path) return `${base}/preview?path=${encodeURIComponent(file.blob_path)}` return file.url || null } /** Parse a value that may arrive as JSON text ('[{…}]', '{…}'). */ function parseJson(v) { if (typeof v !== 'string' || !/^[[{]/.test(v.trim())) return v try { return JSON.parse(v) } catch { return v } } /** * The image sources in an image value: one URL or uploaded file object, a * list of them (photo slots), or that list as JSON text. */ export function imageSources(value) { const v = parseJson(value) return toList(v) .map(parseJson) .flatMap(toList) .map((s) => (typeof s === 'string' ? s.trim() : fileUrl(s))) .filter(Boolean) } /** The documents in a file value: [{ url, name }], from file objects or URLs. */ export function fileLinks(value) { return toList(parseJson(value)) .map(parseJson) .flatMap(toList) .map((f) => typeof f === 'string' ? { url: f.trim(), name: f.trim().split('/').pop() } : { url: fileUrl(f), name: f?.original_name || 'File' }, ) .filter((f) => f.url) } /** Links that open each document in a new tab. Clicks don't reach a clickable table row. */ export function FileLinks({ value, label }) { const links = fileLinks(value) if (!links.length) return — return ( {links.map((f, i) => ( e.stopPropagation()} > 📄 {label ?? f.name} ))} ) } const isFile = (o) => ['uuid', 'repo_file_uuid', 'blob_path', 'preview_url', 'url'].some((k) => o[k]) /** A list as-is; photo slots ({ Front: {…}, Other: [...] }) as their photos; else one item. */ function toList(v) { if (Array.isArray(v)) return v if (v && typeof v === 'object' && !isFile(v)) return Object.values(v).flat() return [v] } /** * Renders one detail field ({ value, type, slots?, otherSlot? }) by its type, * falling back to what the value looks like. Shared by every details page. * `slots` names the photos of an image list (Front, Back…), in order; any * photos past them are named `otherSlot`. */ export default function FieldValue({ field }) { const { value, type, slots, otherSlot } = field const caption = (i) => (slots ? slots[i] ?? otherSlot : undefined) if (value === undefined || value === null || value === '') { return — } const s = typeof value === 'string' ? value.trim() : value if (type === 'file') return const images = type === 'image' || isImageSrc(s) || (Array.isArray(s) && s.every(isImageSrc)) if (images) { return (
{imageSources(s).map((src, i) => (
{caption(i) {caption(i) &&
{caption(i)}
}
))}
) } if (isDateType(type) || (typeof s === 'string' && ISO_DATE.test(s))) { return formatDate(toISODate(s)) } if (type === 'email' || (typeof s === 'string' && EMAIL.test(s))) { return {s} } if (typeof s === 'boolean') return s ? 'Yes' : 'No' if (type === 'geolocation' && typeof s === 'object') { const lat = s.lat ?? s.latitude const lng = s.lng ?? s.lon ?? s.longitude if (lat != null && lng != null) return `${lat}, ${lng}` } if (Array.isArray(s) && s.every((v) => typeof v !== 'object')) return s.join(', ') if (typeof s === 'object') return {JSON.stringify(s)} return String(s) } /** Timestamps read as dates; everything else as-is. */ export function formatMeta(value) { const s = String(value) return ISO_DATE.test(s) ? `${formatDate(toISODate(s))}${s.length > 10 ? ` ${s.slice(11, 16)}` : ''}` : s }