refactor: major architecture improvements
Security: - Remove exposed NocoDB token from client code - Add .env.example for environment variables Shared Components: - Carousel: touch/keyboard navigation, accessibility - ChartCard: reusable chart container - EmptyState: for no-data scenarios - FilterControls: collapsible filter panel with reset button - StatCard: metric display with change indicator - ToggleSwitch: accessible radio-style toggle Architecture: - Create src/config/chartConfig.js for shared chart options - Extract ChartJS registration to single location - Reduce code duplication in Dashboard and Comparison UX Improvements: - Add empty state when filters return no data - Add Reset Filters button to filter controls - Add skeleton loader CSS utilities - Improve focus states for accessibility - Use shared components in Dashboard and Comparison
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import React, { useRef, useCallback } from 'react';
|
||||
|
||||
function Carousel({
|
||||
children,
|
||||
activeIndex,
|
||||
setActiveIndex,
|
||||
labels = [],
|
||||
showLabels = true,
|
||||
className = ''
|
||||
}) {
|
||||
const touchStart = useRef(null);
|
||||
const itemCount = React.Children.count(children);
|
||||
|
||||
const handleTouchStart = useCallback((e) => {
|
||||
touchStart.current = e.touches[0].clientX;
|
||||
}, []);
|
||||
|
||||
const handleTouchEnd = useCallback((e) => {
|
||||
if (!touchStart.current) return;
|
||||
const diff = touchStart.current - e.changedTouches[0].clientX;
|
||||
if (Math.abs(diff) > 50) {
|
||||
if (diff > 0 && activeIndex < itemCount - 1) {
|
||||
setActiveIndex(activeIndex + 1);
|
||||
} else if (diff < 0 && activeIndex > 0) {
|
||||
setActiveIndex(activeIndex - 1);
|
||||
}
|
||||
}
|
||||
touchStart.current = null;
|
||||
}, [activeIndex, setActiveIndex, itemCount]);
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'ArrowLeft' && activeIndex > 0) {
|
||||
setActiveIndex(activeIndex - 1);
|
||||
} else if (e.key === 'ArrowRight' && activeIndex < itemCount - 1) {
|
||||
setActiveIndex(activeIndex + 1);
|
||||
}
|
||||
}, [activeIndex, setActiveIndex, itemCount]);
|
||||
|
||||
return (
|
||||
<div className={`carousel ${className}`} onKeyDown={handleKeyDown} tabIndex={0}>
|
||||
<div className="carousel-container">
|
||||
<div className="carousel-viewport">
|
||||
<div
|
||||
className="carousel-track"
|
||||
style={{ transform: `translateX(-${activeIndex * 100}%)` }}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{React.Children.map(children, (child, i) => (
|
||||
<div className="carousel-slide" key={i}>
|
||||
{child}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`carousel-dots ${showLabels ? 'labeled' : ''}`} role="tablist">
|
||||
{Array.from({ length: itemCount }).map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`carousel-dot ${activeIndex === i ? 'active' : ''}`}
|
||||
onClick={() => setActiveIndex(i)}
|
||||
role="tab"
|
||||
aria-selected={activeIndex === i}
|
||||
aria-label={labels[i] || `Slide ${i + 1}`}
|
||||
>
|
||||
{showLabels && labels[i] && (
|
||||
<span className="dot-label">{labels[i]}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Carousel;
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
|
||||
function ChartCard({
|
||||
title,
|
||||
children,
|
||||
className = '',
|
||||
headerRight = null,
|
||||
fullWidth = false,
|
||||
halfWidth = false
|
||||
}) {
|
||||
const sizeClass = fullWidth ? 'full-width' : halfWidth ? 'half-width' : '';
|
||||
|
||||
return (
|
||||
<div className={`chart-card ${sizeClass} ${className}`}>
|
||||
{(title || headerRight) && (
|
||||
<div className="chart-card-header">
|
||||
{title && <h2>{title}</h2>}
|
||||
{headerRight && <div className="chart-card-actions">{headerRight}</div>}
|
||||
</div>
|
||||
)}
|
||||
<div className="chart-container">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChartCard;
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
|
||||
function EmptyState({
|
||||
icon = '📊',
|
||||
title = 'No data found',
|
||||
message = 'Try adjusting your filters',
|
||||
action,
|
||||
actionLabel = 'Reset Filters'
|
||||
}) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon">{icon}</div>
|
||||
<h3 className="empty-state-title">{title}</h3>
|
||||
<p className="empty-state-message">{message}</p>
|
||||
{action && (
|
||||
<button className="empty-state-action" onClick={action}>
|
||||
{actionLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EmptyState;
|
||||
@@ -0,0 +1,53 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
function FilterControls({
|
||||
children,
|
||||
title = 'Filters',
|
||||
defaultExpanded = true,
|
||||
onReset = null,
|
||||
className = ''
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
|
||||
return (
|
||||
<div className={`controls ${expanded ? 'expanded' : 'collapsed'} ${className}`}>
|
||||
<div className="controls-header" onClick={() => setExpanded(!expanded)}>
|
||||
<h3>{title}</h3>
|
||||
<div className="controls-header-actions">
|
||||
{onReset && expanded && (
|
||||
<button
|
||||
className="controls-reset"
|
||||
onClick={(e) => { e.stopPropagation(); onReset(); }}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
<button className="controls-toggle">
|
||||
{expanded ? '▲ Hide' : '▼ Show'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="controls-body">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterGroup({ label, children }) {
|
||||
return (
|
||||
<div className="control-group">
|
||||
<label>{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterRow({ children }) {
|
||||
return <div className="control-row">{children}</div>;
|
||||
}
|
||||
|
||||
FilterControls.Group = FilterGroup;
|
||||
FilterControls.Row = FilterRow;
|
||||
|
||||
export default FilterControls;
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
|
||||
function StatCard({ title, value, change = null, changeLabel = 'YoY' }) {
|
||||
const isPositive = change !== null && change >= 0;
|
||||
|
||||
return (
|
||||
<div className="stat-card">
|
||||
<h3>{title}</h3>
|
||||
<div className="stat-value">{value}</div>
|
||||
{change !== null && (
|
||||
<div className={`stat-change ${isPositive ? 'positive' : 'negative'}`}>
|
||||
{isPositive ? '↑' : '↓'} {Math.abs(change).toFixed(1)}% {changeLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StatCard;
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
function ToggleSwitch({ options, value, onChange, className = '' }) {
|
||||
return (
|
||||
<div className={`toggle-switch ${className}`} role="radiogroup">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
className={value === option.value ? 'active' : ''}
|
||||
onClick={() => onChange(option.value)}
|
||||
role="radio"
|
||||
aria-checked={value === option.value}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ToggleSwitch;
|
||||
@@ -0,0 +1,6 @@
|
||||
export { default as Carousel } from './Carousel';
|
||||
export { default as ChartCard } from './ChartCard';
|
||||
export { default as EmptyState } from './EmptyState';
|
||||
export { default as FilterControls } from './FilterControls';
|
||||
export { default as StatCard } from './StatCard';
|
||||
export { default as ToggleSwitch } from './ToggleSwitch';
|
||||
Reference in New Issue
Block a user