/** * Shared display-value primitives. * * These are the building blocks the dynamic TABLE (`dynamic-columns.tsx`) uses * to render "pro" cells — colored option badges, relation thumbnails, semantic * status colors, dark-mode detection. They live here (instead of inline in the * table) so the read-only DETAIL DIALOG (`dialogs/dynamic-record.tsx`) can reuse * the EXACT same rendering. Ecosystem rule: shared primitives, zero copy-paste — * table and dialog must not drift. */ import React from 'react' import { Avatar, AvatarImage, AvatarFallback, Badge } from '@asteby/metacore-ui' import { generateBadgeStyles, getInitials, optionColor, } from '@asteby/metacore-ui/lib' import { DynamicIcon } from './dynamic-icon' /** * `true` when the host document is in dark mode. Observes the `` class so * badge colors re-derive on theme toggle. SSR/no-DOM safe (starts light). */ export function useIsDarkTheme(): boolean { const [isDark, setIsDark] = React.useState( () => typeof document !== 'undefined' && document.documentElement.classList.contains('dark') ) React.useEffect(() => { if (typeof document === 'undefined') return const sync = () => setIsDark(document.documentElement.classList.contains('dark')) sync() const observer = new MutationObserver(sync) observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'], }) return () => observer.disconnect() }, []) return isDark } /** * Semantic status → badge color. Used by the `status` cell/value when no * explicit `options` color is declared. Generic, value-driven mapping. */ export function statusColorFor(value: string): string { const v = value.toLowerCase() if ( ['active', 'enabled', 'paid', 'completed', 'done', 'success', 'approved', 'open'] .includes(v) ) return '#22c55e' if (['pending', 'draft', 'processing', 'in_progress', 'review', 'waiting'].includes(v)) return '#eab308' if ( ['inactive', 'disabled', 'cancelled', 'canceled', 'failed', 'rejected', 'error', 'closed'] .includes(v) ) return '#ef4444' return '#6b7280' } /** * Tiny square thumbnail for a resolved relation/option that carries an `image` * (brand logo, product photo, customer/user avatar). Uses the same Avatar * primitive as the `avatar`/`creator` cells so a broken/loading image * gracefully falls back to the record's initials. Sized small (inline style so * an addon-arbitrary Tailwind class never gets dropped by a consuming app's * class scan). Rendered inline alongside a label — never alone. */ export const RelationThumbnail: React.FC<{ src: string alt: string getImageUrl?: (path: string) => string size?: number }> = ({ src, alt, getImageUrl, size = 20 }) => ( {/* object-contain: brand logos are typically wide/rectangular, so object-cover (fills the square, cropping the sides) was cutting them off. contain shows the whole mark without a padded plate. */} {getInitials(alt)} ) /** * Landscape-friendly identity block: wide image ON TOP, label (and optional * subtitle) UNDERNEATH. Use for brand logos, product photos and any mark that * is wider than it is tall — a square thumb next to text crops logos and * wastes the cell. Declared via `display: "image_stack"` on a column (image * URL or FK relation with a logo/photo sibling). * * No padded card / muted plate behind the mark — that ate table row height. * The image is sized with max-width/max-height and `object-contain` only. * * Sizes (CSS, not Tailwind arbitrary — host safelists vary): * sm — compact table (~88×32) * md — default table (~112×40) * lg — detail/modal (~180×72) */ export const ImageStack: React.FC<{ src?: string | null label?: string | null subtitle?: string | null getImageUrl?: (path: string) => string size?: 'sm' | 'md' | 'lg' className?: string }> = ({ src, label, subtitle, getImageUrl, size = 'md', className }) => { const dims = size === 'lg' ? { w: 180, h: 72, text: 'text-sm' } : size === 'sm' ? { w: 88, h: 32, text: 'text-[11px]' } : { w: 112, h: 40, text: 'text-xs' } const alt = label || '' const resolved = src ? getImageUrl ? getImageUrl(src) : src : undefined const [broken, setBroken] = React.useState(false) React.useEffect(() => { setBroken(false) }, [resolved]) return ( {resolved && !broken ? ( {alt} setBroken(true)} /> ) : ( {getInitials(alt || '?')} )} {(label || subtitle) && ( {label ? ( {label} ) : null} {subtitle ? ( {subtitle} ) : null} )} ) } export interface DisplayOption { value: string label: string icon?: string color?: string image?: string /** * Secondary identifier shown UNDER the label (muted, smaller) — a product's * SKU/barcode, a user's email, etc. Lets a reference chip read like * "Camiseta / SKU-001" instead of a bare name, so a resolved record is * unambiguous. Populated declaratively: the backend projects it as the * option/relation `description` (SearchConfig/FieldOptionsConfig.Description, * or a column's `label_description`). Absent → single-line label as before. */ subtitle?: string } /** * Colored option badge — the canonical "pro" pill for a select/status/badge * value. Explicit backend `color` wins; otherwise a stable, cohesive color is * derived from the option value so "dead" gray badges come alive. Inline style * (hex-derived) so it works regardless of the host's tailwind safelist — * addon-arbitrary classes aren't in the host scan. */ export const OptionBadge: React.FC<{ option: DisplayOption getImageUrl?: (path: string) => string /** Accepted for call-site compatibility with the table; unused (option.label wins). */ fallback?: string }> = ({ option, getImageUrl }) => { const isDark = useIsDarkTheme() const colorSource = option.color || optionColor(option.value || option.label) const colorStyles = generateBadgeStyles(colorSource, { isDark }) return ( {option.image ? ( ) : ( option.icon && )} {option.subtitle ? ( // Two-line identity: label on top, the secondary identifier // (SKU/email/…) muted underneath. `leading-tight` keeps the pill // compact; the subtitle inherits the badge's fg at reduced opacity // so it reads as secondary on any color. {option.label} {option.subtitle} ) : ( {option.label} )} ) }