Files
limitless-ui/src/components/Widget/index.tsx
Claude cf068ce4ec chore: initialize @limitless/ui git repo + add AppShell
This brings the long-untracked @limitless/ui source tree under version
control. Until now /srv/k8s/templates/limitless-ui has been a plain
file: dependency consumed by gscChronos / gscCRM / gscAdmin, with
copies scattered across web/gsc{Portal,WWW,Aether,Register}/ and
apps/gsc{Meet,Share}/. None were git-tracked.

Treating /srv/k8s/templates/limitless-ui as the canonical going
forward; secondary copies should be replaced with this version
in their consumers' Dockerfiles when they next get touched.

Changes in this initial commit beyond the snapshot:
- Add src/layout/AppShell.tsx — runtime-loaded chrome (header,
  sidebar, footer) backed by gsc-shell-api. Public surface:
    AppShell, ShellProvider, useShell, ShellConfig types
  Framework-agnostic (no Next.js dep). Apps pass appKey + apiUrl +
  getToken; AppShell composes the existing PageShell / Navbar /
  Sidebar / Footer primitives with API data.
- Re-export AppShell from src/index.ts.
- Fix build script: `tsc -p tsconfig.json --noEmit false`. The bare
  `tsc` command was a no-op because tsconfig.json sets noEmit:true
  for typecheck speed. Existing dist/ only existed because of an
  earlier emit; clean rebuilds were silently broken.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 09:42:57 +02:00

836 lines
26 KiB
TypeScript

import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
// ============================================================================
// TYPES
// ============================================================================
export type WidgetVariant = 'default' | 'primary' | 'success' | 'danger' | 'warning' | 'info' | 'indigo' | 'blue' | 'teal' | 'purple' | 'pink' | 'orange';
export interface StatWidgetProps {
/** Widget title/label */
title: string;
/** Main value to display */
value: string | number;
/** Icon class name (e.g., 'icon-pointer') */
icon?: string;
/** Icon element (alternative to icon class) */
iconElement?: React.ReactNode;
/** Color variant */
variant?: WidgetVariant;
/** Icon position */
iconPosition?: 'left' | 'right';
/** Whether to use solid background color */
solid?: boolean;
/** Optional trend indicator */
trend?: {
value: number;
direction: 'up' | 'down';
label?: string;
};
/** Click handler */
onClick?: () => void;
/** Additional CSS class */
className?: string;
}
export interface ProgressWidgetProps {
/** Widget title */
title: string;
/** Subtitle/description */
subtitle?: string;
/** Progress value (0-100) */
progress: number;
/** Progress label */
progressLabel?: string;
/** Progress sublabel */
progressSublabel?: string;
/** Icon class name */
icon?: string;
/** Icon element (alternative to icon class) */
iconElement?: React.ReactNode;
/** Color variant */
variant?: WidgetVariant;
/** Progress bar height */
progressHeight?: number;
/** Additional CSS class */
className?: string;
}
export interface ChartWidgetProps {
/** Widget title */
title?: string;
/** Subtitle/description */
subtitle?: string;
/** Chart type */
type: 'area' | 'bar' | 'line' | 'donut' | 'pie' | 'sparkline';
/** Chart data */
data: ChartDataPoint[];
/** Chart height in pixels */
height?: number;
/** Color for the chart */
color?: string;
/** Multiple colors for pie/donut charts */
colors?: string[];
/** Whether to animate on load */
animate?: boolean;
/** Animation duration in ms */
animationDuration?: number;
/** Show tooltip on hover */
showTooltip?: boolean;
/** Tooltip formatter */
tooltipFormatter?: (value: number, label?: string) => string;
/** Header content */
header?: React.ReactNode;
/** Footer content */
footer?: React.ReactNode;
/** Additional CSS class */
className?: string;
}
export interface ChartDataPoint {
label?: string;
value: number;
date?: string | Date;
color?: string;
}
export interface ContentWidgetProps {
/** Widget title */
title?: string;
/** Header actions */
headerActions?: React.ReactNode;
/** Widget content */
children: React.ReactNode;
/** Footer content */
footer?: React.ReactNode;
/** Whether to remove padding */
noPadding?: boolean;
/** Additional CSS class */
className?: string;
}
export interface UserWidgetProps {
/** User name */
name: string;
/** User role/title */
role?: string;
/** User avatar URL */
avatar?: string;
/** Background image or color */
background?: string;
/** Background color variant */
variant?: WidgetVariant;
/** User details */
details?: Array<{ label: string; value: string | React.ReactNode }>;
/** Social links */
socialLinks?: Array<{ icon: string; href: string }>;
/** Action buttons */
actions?: React.ReactNode;
/** Additional CSS class */
className?: string;
}
export interface MessageListItem {
id: string;
avatar?: string;
name: string;
message: string;
time: string;
badge?: number;
online?: boolean;
}
export interface MessageListWidgetProps {
/** Widget title */
title?: string;
/** Header extra content (e.g., trend indicator) */
headerExtra?: React.ReactNode;
/** Messages to display */
messages: MessageListItem[];
/** Tab configuration for multiple views */
tabs?: Array<{ id: string; label: string; messages: MessageListItem[] }>;
/** Chart to display above messages */
chart?: ChartWidgetProps;
/** Message click handler */
onMessageClick?: (message: MessageListItem) => void;
/** Additional CSS class */
className?: string;
}
// ============================================================================
// UTILITY HOOKS
// ============================================================================
/**
* Hook for responsive container width tracking
*/
export function useContainerWidth(ref: React.RefObject<HTMLElement | null>): number {
const [width, setWidth] = useState(0);
useEffect(() => {
if (!ref.current) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
setWidth(entry.contentRect.width);
}
});
observer.observe(ref.current);
setWidth(ref.current.getBoundingClientRect().width);
return () => observer.disconnect();
}, [ref]);
return width;
}
/**
* Format large numbers with abbreviations
*/
export function formatNumber(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
}
if (num >= 1000) {
return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
}
return num.toLocaleString();
}
// ============================================================================
// STAT WIDGET
// ============================================================================
export const StatWidget: React.FC<StatWidgetProps> = ({
title,
value,
icon,
iconElement,
variant = 'default',
iconPosition = 'left',
solid = false,
trend,
onClick,
className = '',
}) => {
const variantClass = variant !== 'default' ? `ll-stat-widget--${variant}` : '';
const solidClass = solid ? 'll-stat-widget--solid' : '';
const clickableClass = onClick ? 'll-stat-widget--clickable' : '';
const positionClass = iconPosition === 'right' ? 'll-stat-widget--icon-right' : '';
const iconContent = iconElement || (icon && <i className={`${icon} icon-3x`} />);
return (
<div
className={`ll-stat-widget ${variantClass} ${solidClass} ${clickableClass} ${positionClass} ${className}`.trim()}
onClick={onClick}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
>
<div className="ll-stat-widget__content">
{iconPosition === 'left' && iconContent && (
<div className="ll-stat-widget__icon">{iconContent}</div>
)}
<div className="ll-stat-widget__info">
<h3 className="ll-stat-widget__value">
{typeof value === 'number' ? formatNumber(value) : value}
</h3>
<span className="ll-stat-widget__title">{title}</span>
</div>
{iconPosition === 'right' && iconContent && (
<div className="ll-stat-widget__icon">{iconContent}</div>
)}
</div>
{trend && (
<div className={`ll-stat-widget__trend ll-stat-widget__trend--${trend.direction}`}>
<i className={trend.direction === 'up' ? 'icon-arrow-up22' : 'icon-arrow-down22'} />
<span>{trend.value}%</span>
{trend.label && <span className="ll-stat-widget__trend-label">{trend.label}</span>}
</div>
)}
</div>
);
};
// ============================================================================
// PROGRESS WIDGET
// ============================================================================
export const ProgressWidget: React.FC<ProgressWidgetProps> = ({
title,
subtitle,
progress,
progressLabel,
progressSublabel,
icon,
iconElement,
variant = 'primary',
progressHeight = 2,
className = '',
}) => {
const variantClass = `ll-progress-widget--${variant}`;
const iconContent = iconElement || (icon && <i className={`${icon} icon-2x`} />);
return (
<div className={`ll-progress-widget ${variantClass} ${className}`.trim()}>
<div className="ll-progress-widget__header">
<div className="ll-progress-widget__info">
<h6 className="ll-progress-widget__title">{title}</h6>
{subtitle && <span className="ll-progress-widget__subtitle">{subtitle}</span>}
</div>
{iconContent && <div className="ll-progress-widget__icon">{iconContent}</div>}
</div>
<div className="ll-progress-widget__bar" style={{ height: `${progressHeight}px` }}>
<div
className="ll-progress-widget__fill"
style={{ width: `${Math.min(100, Math.max(0, progress))}%` }}
/>
</div>
{(progressLabel || progressSublabel) && (
<div className="ll-progress-widget__footer">
{progressSublabel && <span>{progressSublabel}</span>}
{progressLabel && <span className="ll-progress-widget__progress-value">{progressLabel}</span>}
</div>
)}
</div>
);
};
// ============================================================================
// CHART WIDGET - Pure React/SVG Charts (No jQuery)
// ============================================================================
export const ChartWidget: React.FC<ChartWidgetProps> = ({
title,
subtitle,
type,
data,
height = 200,
color = '#26A69A',
colors,
animate = true,
animationDuration = 1000,
showTooltip = true,
tooltipFormatter,
header,
footer,
className = '',
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const width = useContainerWidth(containerRef);
const [animationProgress, setAnimationProgress] = useState(animate ? 0 : 1);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 });
// Animation effect
useEffect(() => {
if (!animate) return;
const startTime = Date.now();
let animationFrame: number;
const updateAnimation = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(1, elapsed / animationDuration);
setAnimationProgress(progress);
if (progress < 1) {
animationFrame = requestAnimationFrame(updateAnimation);
}
};
animationFrame = requestAnimationFrame(updateAnimation);
return () => cancelAnimationFrame(animationFrame);
}, [animate, animationDuration, data]);
// Calculate scales
const maxValue = useMemo(() => Math.max(...data.map((d) => d.value)), [data]);
const minValue = useMemo(() => Math.min(...data.map((d) => d.value)), [data]);
// Default tooltip formatter
const defaultFormatter = useCallback(
(value: number, label?: string) => `${label ? label + ': ' : ''}${formatNumber(value)}`,
[]
);
const formatter = tooltipFormatter || defaultFormatter;
// Render different chart types
const renderChart = () => {
if (width === 0) return null;
switch (type) {
case 'area':
return renderAreaChart();
case 'line':
return renderLineChart();
case 'bar':
return renderBarChart();
case 'sparkline':
return renderSparklineChart();
case 'donut':
case 'pie':
return renderPieChart();
default:
return null;
}
};
const renderAreaChart = () => {
const padding = { top: 10, right: 10, bottom: 10, left: 10 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
const xScale = (i: number) => padding.left + (i / (data.length - 1)) * chartWidth;
const yScale = (v: number) => padding.top + chartHeight - ((v - minValue) / (maxValue - minValue || 1)) * chartHeight;
const pathData = data
.map((d, i) => {
const x = xScale(i);
const y = yScale(d.value * animationProgress);
return `${i === 0 ? 'M' : 'L'} ${x} ${y}`;
})
.join(' ');
const areaPath = `${pathData} L ${xScale(data.length - 1)} ${height - padding.bottom} L ${padding.left} ${height - padding.bottom} Z`;
return (
<svg width={width} height={height} className="ll-chart-widget__svg">
<defs>
<linearGradient id={`area-gradient-${color.replace('#', '')}`} x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor={color} stopOpacity="0.8" />
<stop offset="100%" stopColor={color} stopOpacity="0.1" />
</linearGradient>
</defs>
<path
d={areaPath}
fill={`url(#area-gradient-${color.replace('#', '')})`}
className="ll-chart-widget__area"
/>
<path d={pathData} fill="none" stroke={color} strokeWidth="2" className="ll-chart-widget__line" />
{showTooltip &&
data.map((d, i) => (
<circle
key={i}
cx={xScale(i)}
cy={yScale(d.value * animationProgress)}
r={hoveredIndex === i ? 6 : 4}
fill={color}
className="ll-chart-widget__point"
onMouseEnter={(e) => {
setHoveredIndex(i);
setTooltipPosition({ x: e.clientX, y: e.clientY });
}}
onMouseLeave={() => setHoveredIndex(null)}
/>
))}
</svg>
);
};
const renderLineChart = () => {
const padding = { top: 10, right: 10, bottom: 10, left: 10 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
const xScale = (i: number) => padding.left + (i / (data.length - 1)) * chartWidth;
const yScale = (v: number) => padding.top + chartHeight - ((v - minValue) / (maxValue - minValue || 1)) * chartHeight;
const pathData = data
.map((d, i) => {
const x = xScale(i);
const y = yScale(d.value * animationProgress);
return `${i === 0 ? 'M' : 'L'} ${x} ${y}`;
})
.join(' ');
return (
<svg width={width} height={height} className="ll-chart-widget__svg">
<path d={pathData} fill="none" stroke={color} strokeWidth="2" className="ll-chart-widget__line" />
{showTooltip &&
data.map((d, i) => (
<circle
key={i}
cx={xScale(i)}
cy={yScale(d.value * animationProgress)}
r={hoveredIndex === i ? 6 : 4}
fill={color}
className="ll-chart-widget__point"
onMouseEnter={(e) => {
setHoveredIndex(i);
setTooltipPosition({ x: e.clientX, y: e.clientY });
}}
onMouseLeave={() => setHoveredIndex(null)}
/>
))}
</svg>
);
};
const renderBarChart = () => {
const padding = { top: 10, right: 10, bottom: 10, left: 10 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
const barGap = 0.3;
const barWidth = (chartWidth / data.length) * (1 - barGap);
const barSpacing = chartWidth / data.length;
return (
<svg width={width} height={height} className="ll-chart-widget__svg">
{data.map((d, i) => {
const barHeight = ((d.value / maxValue) * chartHeight * animationProgress);
const x = padding.left + i * barSpacing + (barSpacing - barWidth) / 2;
const y = padding.top + chartHeight - barHeight;
return (
<rect
key={i}
x={x}
y={y}
width={barWidth}
height={barHeight}
fill={d.color || color}
rx={2}
className={`ll-chart-widget__bar ${hoveredIndex === i ? 'll-chart-widget__bar--hovered' : ''}`}
onMouseEnter={(e) => {
setHoveredIndex(i);
setTooltipPosition({ x: e.clientX, y: e.clientY });
}}
onMouseLeave={() => setHoveredIndex(null)}
/>
);
})}
</svg>
);
};
const renderSparklineChart = () => {
const chartHeight = height;
const xScale = (i: number) => (i / (data.length - 1)) * width;
const yScale = (v: number) => chartHeight - ((v - minValue) / (maxValue - minValue || 1)) * chartHeight;
const pathData = data
.map((d, i) => {
const x = xScale(i);
const y = yScale(d.value * animationProgress);
return `${i === 0 ? 'M' : 'L'} ${x} ${y}`;
})
.join(' ');
return (
<svg width={width} height={height} className="ll-chart-widget__svg ll-chart-widget__svg--sparkline">
<path d={pathData} fill="none" stroke={color} strokeWidth="1.5" />
</svg>
);
};
const renderPieChart = () => {
const centerX = width / 2;
const centerY = height / 2;
const radius = Math.min(centerX, centerY) - 10;
const innerRadius = type === 'donut' ? radius * 0.6 : 0;
const total = data.reduce((sum, d) => sum + d.value, 0);
let currentAngle = -Math.PI / 2;
const defaultColors = [
'#2196F3', '#4CAF50', '#FF9800', '#E91E63', '#9C27B0',
'#00BCD4', '#FF5722', '#795548', '#607D8B', '#3F51B5',
];
const chartColors = colors || defaultColors;
const slices = data.map((d, i) => {
const sliceAngle = (d.value / total) * 2 * Math.PI * animationProgress;
const startAngle = currentAngle;
const endAngle = currentAngle + sliceAngle;
currentAngle = endAngle;
const x1 = centerX + radius * Math.cos(startAngle);
const y1 = centerY + radius * Math.sin(startAngle);
const x2 = centerX + radius * Math.cos(endAngle);
const y2 = centerY + radius * Math.sin(endAngle);
const ix1 = centerX + innerRadius * Math.cos(startAngle);
const iy1 = centerY + innerRadius * Math.sin(startAngle);
const ix2 = centerX + innerRadius * Math.cos(endAngle);
const iy2 = centerY + innerRadius * Math.sin(endAngle);
const largeArc = sliceAngle > Math.PI ? 1 : 0;
const path =
type === 'donut'
? `M ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2} L ${ix2} ${iy2} A ${innerRadius} ${innerRadius} 0 ${largeArc} 0 ${ix1} ${iy1} Z`
: `M ${centerX} ${centerY} L ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2} Z`;
return (
<path
key={i}
d={path}
fill={d.color || chartColors[i % chartColors.length]}
className={`ll-chart-widget__slice ${hoveredIndex === i ? 'll-chart-widget__slice--hovered' : ''}`}
onMouseEnter={(e) => {
setHoveredIndex(i);
setTooltipPosition({ x: e.clientX, y: e.clientY });
}}
onMouseLeave={() => setHoveredIndex(null)}
/>
);
});
return (
<svg width={width} height={height} className="ll-chart-widget__svg">
{slices}
</svg>
);
};
return (
<div className={`ll-chart-widget ${className}`.trim()}>
{(title || subtitle || header) && (
<div className="ll-chart-widget__header">
{header || (
<>
{title && <h6 className="ll-chart-widget__title">{title}</h6>}
{subtitle && <span className="ll-chart-widget__subtitle">{subtitle}</span>}
</>
)}
</div>
)}
<div ref={containerRef} className="ll-chart-widget__container">
{renderChart()}
{showTooltip && hoveredIndex !== null && (
<div
className="ll-chart-widget__tooltip"
style={{
position: 'fixed',
left: tooltipPosition.x + 10,
top: tooltipPosition.y - 30,
}}
>
{formatter(data[hoveredIndex].value, data[hoveredIndex].label)}
</div>
)}
</div>
{footer && <div className="ll-chart-widget__footer">{footer}</div>}
</div>
);
};
// ============================================================================
// CONTENT WIDGET
// ============================================================================
export const ContentWidget: React.FC<ContentWidgetProps> = ({
title,
headerActions,
children,
footer,
noPadding = false,
className = '',
}) => {
return (
<div className={`ll-content-widget ${className}`.trim()}>
{(title || headerActions) && (
<div className="ll-content-widget__header">
{title && <h6 className="ll-content-widget__title">{title}</h6>}
{headerActions && <div className="ll-content-widget__actions">{headerActions}</div>}
</div>
)}
<div className={`ll-content-widget__body ${noPadding ? 'll-content-widget__body--no-padding' : ''}`}>
{children}
</div>
{footer && <div className="ll-content-widget__footer">{footer}</div>}
</div>
);
};
// ============================================================================
// USER WIDGET
// ============================================================================
export const UserWidget: React.FC<UserWidgetProps> = ({
name,
role,
avatar,
background,
variant = 'primary',
details,
socialLinks,
actions,
className = '',
}) => {
const variantClass = `ll-user-widget--${variant}`;
const hasBackground = background?.startsWith('http') || background?.startsWith('/') || background?.startsWith('data:');
return (
<div className={`ll-user-widget ${variantClass} ${className}`.trim()}>
<div
className="ll-user-widget__header"
style={hasBackground ? { backgroundImage: `url(${background})` } : undefined}
>
<div className="ll-user-widget__avatar-wrapper">
{avatar ? (
<img src={avatar} alt={name} className="ll-user-widget__avatar" />
) : (
<div className="ll-user-widget__avatar ll-user-widget__avatar--placeholder">
{name.charAt(0).toUpperCase()}
</div>
)}
{actions && <div className="ll-user-widget__avatar-actions">{actions}</div>}
</div>
<h6 className="ll-user-widget__name">{name}</h6>
{role && <span className="ll-user-widget__role">{role}</span>}
{socialLinks && socialLinks.length > 0 && (
<div className="ll-user-widget__social">
{socialLinks.map((link, i) => (
<a key={i} href={link.href} className="ll-user-widget__social-link">
<i className={link.icon} />
</a>
))}
</div>
)}
</div>
{details && details.length > 0 && (
<div className="ll-user-widget__details">
{details.map((detail, i) => (
<div key={i} className="ll-user-widget__detail">
<span className="ll-user-widget__detail-label">{detail.label}</span>
<span className="ll-user-widget__detail-value">{detail.value}</span>
</div>
))}
</div>
)}
</div>
);
};
// ============================================================================
// MESSAGE LIST WIDGET
// ============================================================================
export const MessageListWidget: React.FC<MessageListWidgetProps> = ({
title,
headerExtra,
messages,
tabs,
chart,
onMessageClick,
className = '',
}) => {
const [activeTab, setActiveTab] = useState(tabs?.[0]?.id || '');
const currentMessages = tabs ? tabs.find((t) => t.id === activeTab)?.messages || [] : messages;
return (
<div className={`ll-message-list-widget ${className}`.trim()}>
{(title || headerExtra) && (
<div className="ll-message-list-widget__header">
{title && <h6 className="ll-message-list-widget__title">{title}</h6>}
{headerExtra && <div className="ll-message-list-widget__header-extra">{headerExtra}</div>}
</div>
)}
{chart && (
<div className="ll-message-list-widget__chart">
<ChartWidget {...chart} />
</div>
)}
{tabs && tabs.length > 0 && (
<div className="ll-message-list-widget__tabs">
{tabs.map((tab) => (
<button
key={tab.id}
className={`ll-message-list-widget__tab ${activeTab === tab.id ? 'll-message-list-widget__tab--active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
)}
<div className="ll-message-list-widget__list">
{currentMessages.map((msg) => (
<div
key={msg.id}
className={`ll-message-list-widget__item ${onMessageClick ? 'll-message-list-widget__item--clickable' : ''}`}
onClick={() => onMessageClick?.(msg)}
>
<div className="ll-message-list-widget__avatar-container">
{msg.avatar ? (
<img src={msg.avatar} alt={msg.name} className="ll-message-list-widget__avatar" />
) : (
<div className="ll-message-list-widget__avatar ll-message-list-widget__avatar--placeholder">
{msg.name.charAt(0).toUpperCase()}
</div>
)}
{msg.badge !== undefined && msg.badge > 0 && (
<span className="ll-message-list-widget__badge">{msg.badge}</span>
)}
{msg.online !== undefined && (
<span className={`ll-message-list-widget__status ${msg.online ? 'll-message-list-widget__status--online' : ''}`} />
)}
</div>
<div className="ll-message-list-widget__content">
<div className="ll-message-list-widget__meta">
<span className="ll-message-list-widget__name">{msg.name}</span>
<span className="ll-message-list-widget__time">{msg.time}</span>
</div>
<p className="ll-message-list-widget__message">{msg.message}</p>
</div>
</div>
))}
</div>
</div>
);
};
// ============================================================================
// QUICK STATS GRID
// ============================================================================
export interface QuickStatsProps {
stats: Array<{
label: string;
value: string | number;
icon?: string;
variant?: WidgetVariant;
trend?: { value: number; direction: 'up' | 'down' };
}>;
columns?: 2 | 3 | 4;
className?: string;
}
export const QuickStats: React.FC<QuickStatsProps> = ({
stats,
columns = 4,
className = '',
}) => {
return (
<div className={`ll-quick-stats ll-quick-stats--cols-${columns} ${className}`.trim()}>
{stats.map((stat, i) => (
<StatWidget
key={i}
title={stat.label}
value={stat.value}
icon={stat.icon}
variant={stat.variant}
trend={stat.trend}
/>
))}
</div>
);
};
// Default export
export default {
StatWidget,
ProgressWidget,
ChartWidget,
ContentWidget,
UserWidget,
MessageListWidget,
QuickStats,
useContainerWidth,
formatNumber,
};