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>
This commit is contained in:
835
src/components/Widget/index.tsx
Normal file
835
src/components/Widget/index.tsx
Normal file
@@ -0,0 +1,835 @@
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user