Home Pulse is the result of curiosity, collaboration,
diff --git a/src/components/RoomCard.tsx b/src/components/RoomCard.tsx
index 28692f5..f023676 100644
--- a/src/components/RoomCard.tsx
+++ b/src/components/RoomCard.tsx
@@ -34,6 +34,10 @@ export default function RoomCard({ room, homeId, onDelete }: RoomCardProps) {
navigate(`/homes/${homeId}/rooms/${room._id}`);
}
+ function handleClick() {
+ navigate(`/ImmersiveView/${room._id}`);
+ }
+
return (
Devices: {room.devices ? room.devices.length : 0}
-
diff --git a/src/components/SparkComponent.tsx b/src/components/SparkComponent.tsx
index 6b6447a..e5add0f 100644
--- a/src/components/SparkComponent.tsx
+++ b/src/components/SparkComponent.tsx
@@ -6,12 +6,12 @@ import Dock from './ui/Dock';
import { FaHome, FaKeyboard, FaLightbulb, FaSun, FaThermometerHalf, FaWater } from 'react-icons/fa';
import Instructions from './ui/Instructions/Instructions';
import Devices, { deviceState } from './Devices';
-import { mockServer } from '../services/localDBService';
import { type DeviceData } from '@/types/device';
import SensorControlPanel from './ui/SensorControlPanel';
import { useSnapshot } from 'valtio';
import type { SensorData } from '@/types/sensorsDataTypes';
import { Progress } from '@/components/ui/Progress';
+import { useRoomStore } from '@/store/roomStore';
//* Export deviceState so other components can check transformation status
export { deviceState };
@@ -31,6 +31,8 @@ function SparkComponent() {
const [deviceToSpawn, setDeviceToSpawn] = useState
(null);
const [initialDevices, setInitialDevices] = useState([]);
const [devices, setDevices] = useState([]);
+ const { viewDevices, fetchRoom, updateRoom } = useRoomStore();
+ const roomId = location.state?.roomId;
const snap = useSnapshot(deviceState);
@@ -42,8 +44,10 @@ function SparkComponent() {
}
//* Check for initial devices passed from Load Session
- if (location.state?.devices) {
- setInitialDevices(location.state.devices);
+ //! BIG TODO: Refactor to work with more than one device
+ if (roomId) {
+ fetchRoom(roomId);
+ setInitialDevices(viewDevices);
}
}, [location.state, navigate]);
@@ -76,8 +80,8 @@ function SparkComponent() {
const handleDevicesChange = useCallback((devices: DeviceData[]) => {
setDevices(devices);
- //TODO: Manage real userID when connecting to the backend
- mockServer.saveDevices('default-user', devices);
+ const updatedRoom = { viewDevices: devices };
+ updateRoom(roomId, updatedRoom);
}, []);
const handleSensorDataUpdate = useCallback(async (deviceId: string, sensorData: SensorData) => {
@@ -85,8 +89,8 @@ function SparkComponent() {
const updatedDevices = prev.map(d =>
d.id === deviceId ? { ...d, sensorData } : d,
);
- //TODO: Manage real userID when connecting to the backend
- mockServer.saveDevices('default-user', updatedDevices);
+ const updatedRoom = { viewDevices: updatedDevices };
+ updateRoom(roomId, updatedRoom);
return updatedDevices;
});
}, []);
@@ -186,6 +190,7 @@ function SparkComponent() {
initialDevices={initialDevices}
onDevicesChange={handleDevicesChange}
onSensorDataUpdate={handleSensorDataUpdate}
+ roomId={roomId}
/>
)}
diff --git a/src/components/TextType.tsx b/src/components/TextType.tsx
index 53e2ea7..c7b7abd 100644
--- a/src/components/TextType.tsx
+++ b/src/components/TextType.tsx
@@ -119,7 +119,7 @@ const TextType = ({
setCurrentTextIndex(prev => (prev + 1) % textArray.length);
setCurrentCharIndex(0);
- timeout = setTimeout(() => {}, pauseDuration);
+ timeout = setTimeout(() => { }, pauseDuration);
} else {
timeout = setTimeout(() => {
setDisplayedText(prev => prev.slice(0, -1));
diff --git a/src/components/chart-area-interactive.tsx b/src/components/chart-area-interactive.tsx
new file mode 100644
index 0000000..ea7a740
--- /dev/null
+++ b/src/components/chart-area-interactive.tsx
@@ -0,0 +1,259 @@
+'use client';
+
+import * as React from 'react';
+import { Area, AreaChart, CartesianGrid, XAxis } from 'recharts';
+
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import {
+ type ChartConfig,
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+} from '@/components/ui/chart';
+import type { DeviceReading } from '@/types/sensorsDataTypes';
+
+export const description = 'An interactive area chart';
+
+const chartConfig = {
+ temperature: {
+ label: 'Temperature',
+ color: '#4447efff',
+ },
+ humidity: {
+ label: 'Humidity',
+ color: '#f6573bff',
+ },
+ light: {
+ label: 'Light',
+ color: '#fbbf24',
+ },
+} satisfies ChartConfig;
+
+interface ChartAreaInteractiveProps {
+ data: DeviceReading[];
+}
+
+type TimeRange = '1h' | '24h' | '7d' | '30d' | 'all';
+type SensorType = 'temperature' | 'humidity' | 'light';
+
+const timeRangeLabels: Record = {
+ '1h': '1 Hour',
+ '24h': '24 Hours',
+ '7d': '7 Days',
+ '30d': '30 Days',
+ 'all': 'All Time',
+};
+
+const getTimeRangeInMs = (range: TimeRange): number | null => {
+ switch (range) {
+ case '1h': return 60 * 60 * 1000;
+ case '24h': return 24 * 60 * 60 * 1000;
+ case '7d': return 7 * 24 * 60 * 60 * 1000;
+ case '30d': return 30 * 24 * 60 * 60 * 1000;
+ case 'all': return null;
+ }
+};
+
+export function ChartAreaInteractive({ data }: ChartAreaInteractiveProps) {
+ const [timeRange, setTimeRange] = React.useState('24h');
+ const [visibleSensors, setVisibleSensors] = React.useState>({
+ temperature: true,
+ humidity: true,
+ light: true,
+ });
+
+ const toggleSensor = (sensor: SensorType) => {
+ setVisibleSensors(prev => ({ ...prev, [sensor]: !prev[sensor] }));
+ };
+
+ const formattedData = React.useMemo(() => {
+ return data.map(reading => ({
+ date: reading.createdAt,
+ temperature: reading.sensorsData.temperature,
+ humidity: reading.sensorsData.humidity,
+ light: reading.sensorsData.light,
+ }));
+ }, [data]);
+
+ const filteredData = React.useMemo(() => {
+ const rangeMs = getTimeRangeInMs(timeRange);
+ let result = formattedData;
+
+ if (rangeMs !== null) {
+ const now = Date.now();
+ const cutoffTime = now - rangeMs;
+
+ result = formattedData.filter(item => {
+ const itemTime = new Date(item.date).getTime();
+ return itemTime >= cutoffTime;
+ });
+ }
+
+ // Downsample if too many points
+ const MAX_POINTS = 1000;
+ if (result.length > MAX_POINTS) {
+ const step = Math.ceil(result.length / MAX_POINTS);
+ result = result.filter((_, index) => index % step === 0);
+ }
+
+ return [...result].reverse();
+ }, [formattedData, timeRange]);
+
+ return (
+
+
+
+
Sensor Data
+
+
+ {(['temperature', 'humidity', 'light'] as SensorType[]).map((sensor) => (
+
toggleSensor(sensor)}
+ className='flex items-center gap-1.5 text-xs transition-opacity hover:opacity-80'
+ style={{ opacity: visibleSensors[sensor] ? 1 : 0.4 }}
+ >
+
+ {sensor}
+
+ ))}
+
+
+
+
+ {(['1h', '24h', '7d', '30d', 'all'] as TimeRange[]).map((range) => (
+ setTimeRange(range)}
+ >
+
+ {timeRangeLabels[range]}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ const date = new Date(value);
+ return date.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: 'numeric',
+ });
+ }}
+ />
+ {
+ return new Date(value).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: 'numeric',
+ });
+ }}
+ indicator='dot'
+ valueFormatter={(value, item) => {
+ if (item.dataKey === 'temperature') return `${value}°C`;
+ if (item.dataKey === 'humidity') return `${value}%`;
+ if (item.dataKey === 'light') return `${value}`;
+ return value;
+ }}
+ />
+ }
+ />
+ {visibleSensors.temperature && (
+
+ )}
+ {visibleSensors.humidity && (
+
+ )}
+ {visibleSensors.light && (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/data-table.tsx b/src/components/data-table.tsx
new file mode 100644
index 0000000..1b5fb9a
--- /dev/null
+++ b/src/components/data-table.tsx
@@ -0,0 +1,287 @@
+import * as React from 'react';
+import {
+ type ColumnDef,
+ type ColumnFiltersState,
+ type SortingState,
+ type VisibilityState,
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ useReactTable,
+} from '@tanstack/react-table';
+import {
+ IconChevronDown,
+ IconDotsVertical,
+} from '@tabler/icons-react';
+
+import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { Input } from '@/components/ui/input';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import type { Device } from '@/types/devices-types';
+import { Badge } from '@/components/ui/badge';
+
+export const columns: ColumnDef[] = [
+ {
+ id: 'select',
+ header: ({ table }) => (
+ table.toggleAllPageRowsSelected(!!value)}
+ aria-label='Select all'
+ />
+ ),
+ cell: ({ row }) => (
+ row.toggleSelected(!!value)}
+ aria-label='Select row'
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ accessorKey: 'deviceName',
+ header: 'Device Name',
+ cell: ({ row }) => (
+ {row.getValue('deviceName')}
+ ),
+ },
+ {
+ accessorKey: 'type',
+ header: 'Type',
+ cell: ({ row }) => {row.getValue('type')}
,
+ },
+ {
+ accessorKey: 'state',
+ header: 'State',
+ cell: ({ row }) => (
+
+ {row.getValue('state') === 'on' ? (
+
+ ) : (
+
+ )}
+ {row.getValue('state')}
+
+ ),
+ },
+ {
+ accessorKey: 'sensors',
+ header: 'Sensors',
+ cell: ({ row }) => {
+ const sensors = row.original.sensors;
+ return (
+
+ {sensors.map((sensor) => (
+
+ {sensor}
+
+ ))}
+
+ );
+ },
+ },
+ {
+ id: 'actions',
+ enableHiding: false,
+ cell: ({ row }) => {
+ const device = row.original;
+
+ return (
+
+
+
+ Open menu
+
+
+
+
+ Actions
+ navigator.clipboard.writeText(device._id)}
+ >
+ Copy device ID
+
+
+ View details
+ View sensor data
+
+
+ );
+ },
+ },
+];
+
+interface DataTableProps {
+ data: Device[];
+ onRowClick?: (device: Device) => void;
+}
+
+export function DataTable({ data, onRowClick }: DataTableProps) {
+ const [sorting, setSorting] = React.useState([]);
+ const [columnFilters, setColumnFilters] = React.useState(
+ [],
+ );
+ const [columnVisibility, setColumnVisibility] =
+ React.useState({});
+ const [rowSelection, setRowSelection] = React.useState({});
+
+ const table = useReactTable({
+ data,
+ columns,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ onColumnVisibilityChange: setColumnVisibility,
+ onRowSelectionChange: setRowSelection,
+ state: {
+ sorting,
+ columnFilters,
+ columnVisibility,
+ rowSelection,
+ },
+ });
+
+ return (
+
+
+
+ table.getColumn('deviceName')?.setFilterValue(event.target.value)
+ }
+ className='max-w-sm'
+ />
+
+
+
+ Columns
+
+
+
+ {table
+ .getAllColumns()
+ .filter((column) => column.getCanHide())
+ .map((column) => {
+ return (
+
+ column.toggleVisibility(!!value)
+ }
+ >
+ {column.id}
+
+ );
+ })}
+
+
+
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => {
+ return (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext(),
+ )}
+
+ );
+ })}
+
+ ))}
+
+
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+ onRowClick?.(row.original)}
+ className='cursor-pointer hover:bg-muted/50'
+ >
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext(),
+ )}
+
+ ))}
+
+ ))
+ ) : (
+
+
+ No results.
+
+
+ )}
+
+
+
+
+
+ {table.getFilteredSelectedRowModel().rows.length} of{' '}
+ {table.getFilteredRowModel().rows.length} row(s) selected.
+
+
+ table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ Previous
+
+ table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ Next
+
+
+
+
+ );
+}
diff --git a/src/components/forms/login-form.tsx b/src/components/forms/login-form.tsx
index 7ac33bc..ccd17dc 100644
--- a/src/components/forms/login-form.tsx
+++ b/src/components/forms/login-form.tsx
@@ -28,9 +28,6 @@ export function LoginForm({
const email = formData.get('email') as string;
const password = formData.get('password') as string;
- //TODO remove console.log on production
- console.log('Login attempt:', { email, password: '***' });
-
try {
const credentials: LoginCredentials = {
email: email,
@@ -67,13 +64,20 @@ export function LoginForm({
type='email'
placeholder='email@example.com'
required
+ autoComplete='Email'
/>
Password
-
+
Login
diff --git a/src/components/forms/register-form.tsx b/src/components/forms/register-form.tsx
index b6484ab..7a4dbe0 100644
--- a/src/components/forms/register-form.tsx
+++ b/src/components/forms/register-form.tsx
@@ -42,6 +42,7 @@ const formSchema = z.object({
type FormValues = z.infer;
export function RegisterForm() {
+ const navigate = useNavigate();
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
@@ -53,8 +54,6 @@ export function RegisterForm() {
async function onSubmit(values: FormValues) {
console.log(values);
- const navigate = useNavigate();
-
const user: CreateUserData = {
userName: values.fullName,
email: values.email,
@@ -62,6 +61,7 @@ export function RegisterForm() {
};
try {
const response = await userService.create(user);
+ console.log(response);
if (response.success === true) {
alert(response.message);
navigate('/login');
diff --git a/src/components/light-level-chart.tsx b/src/components/light-level-chart.tsx
new file mode 100644
index 0000000..797811e
--- /dev/null
+++ b/src/components/light-level-chart.tsx
@@ -0,0 +1,170 @@
+'use client';
+
+import * as React from 'react';
+import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
+
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import {
+ type ChartConfig,
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+} from '@/components/ui/chart';
+import type { DeviceReading } from '@/types/sensorsDataTypes';
+
+const chartConfig = {
+ light: {
+ label: 'Light Level',
+ color: '#fbbf24', // Amber-400 (sun-like color)
+ },
+} satisfies ChartConfig;
+
+interface LightLevelChartProps {
+ data: DeviceReading[];
+}
+
+type TimeRange = '1h' | '24h' | '7d' | '30d' | 'all';
+
+const timeRangeLabels: Record = {
+ '1h': '1 Hour',
+ '24h': '24 Hours',
+ '7d': '7 Days',
+ '30d': '30 Days',
+ 'all': 'All Time',
+};
+
+const getTimeRangeInMs = (range: TimeRange): number | null => {
+ switch (range) {
+ case '1h': return 60 * 60 * 1000;
+ case '24h': return 24 * 60 * 60 * 1000;
+ case '7d': return 7 * 24 * 60 * 60 * 1000;
+ case '30d': return 30 * 24 * 60 * 60 * 1000;
+ case 'all': return null;
+ }
+};
+
+export function LightLevelChart({ data }: LightLevelChartProps) {
+ const [timeRange, setTimeRange] = React.useState('24h');
+
+ const formattedData = React.useMemo(() => {
+ return data.map(reading => ({
+ date: reading.createdAt,
+ light: reading.sensorsData.light,
+ }));
+ }, [data]);
+
+ const filteredData = React.useMemo(() => {
+ const rangeMs = getTimeRangeInMs(timeRange);
+ if (rangeMs === null) return formattedData;
+
+ const now = Date.now();
+ const cutoffTime = now - rangeMs;
+
+ return formattedData.filter(item => {
+ const itemTime = new Date(item.date).getTime();
+ return itemTime >= cutoffTime;
+ });
+ }, [formattedData, timeRange]);
+
+ return (
+
+
+
+ Light Level
+
+ Day/Night Pattern
+
+
+
+ {(['1h', '24h', '7d', '30d', 'all'] as TimeRange[]).map((range) => (
+ setTimeRange(range)}
+ >
+
+ {timeRangeLabels[range]}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ const date = new Date(value);
+ return date.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: 'numeric',
+ });
+ }}
+ />
+
+ {
+ return new Date(value).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: 'numeric',
+ });
+ }}
+ indicator='dot'
+ valueFormatter={(value) => `${value}`}
+ />
+ }
+ />
+
+
+
+
+
+ );
+}
diff --git a/src/components/section-cards.tsx b/src/components/section-cards.tsx
new file mode 100644
index 0000000..9dbf360
--- /dev/null
+++ b/src/components/section-cards.tsx
@@ -0,0 +1,61 @@
+import { IconHome, IconDevices, IconDoor } from '@tabler/icons-react';
+
+import {
+ Card,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+
+interface SectionCardsProps {
+ totalHomes: number;
+ totalRooms: number;
+ totalDevices: number;
+}
+
+export function SectionCards({ totalHomes, totalRooms, totalDevices }: SectionCardsProps) {
+ return (
+
+
+
+ Total Homes
+
+ {totalHomes}
+
+
+
+
+ Active Homes
+
+
+
+
+
+ Total Rooms
+
+ {totalRooms}
+
+
+
+
+ Monitored Rooms
+
+
+
+
+
+ Total Devices
+
+ {totalDevices}
+
+
+
+
+ Connected Devices
+
+
+
+
+ );
+}
diff --git a/src/components/sensor-stats-cards.tsx b/src/components/sensor-stats-cards.tsx
new file mode 100644
index 0000000..585e1f8
--- /dev/null
+++ b/src/components/sensor-stats-cards.tsx
@@ -0,0 +1,76 @@
+import { IconTemperature, IconDroplet, IconSun } from '@tabler/icons-react';
+
+import {
+ Card,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import type { SensorData } from '@/types/sensorsDataTypes';
+
+interface SensorStatsCardsProps {
+ temperature: SensorData;
+ humidity: SensorData;
+ light: SensorData;
+ switch1: SensorData;
+}
+
+export function SensorStatsCards({ temperature, humidity, light, switch1 }: SensorStatsCardsProps) {
+ return (
+
+
+
+ Temperature
+
+ {temperature.temperature}°C
+
+
+
+
+ Current
+
+
+
+
+
+ Humidity
+
+ {humidity.humidity}%
+
+
+
+
+ Current
+
+
+
+
+
+ Light Level
+
+ {light.light}
+
+
+
+
+ Current
+
+
+
+
+
+ Switch 1
+
+ {switch1.switch1}
+
+
+
+
+ Current
+
+
+
+
+ );
+}
diff --git a/src/components/ui/Progress.tsx b/src/components/ui/Progress.tsx
index eaccb23..baeb4e0 100644
--- a/src/components/ui/Progress.tsx
+++ b/src/components/ui/Progress.tsx
@@ -19,7 +19,7 @@ function Progress({
>
diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx
new file mode 100644
index 0000000..52e6be0
--- /dev/null
+++ b/src/components/ui/avatar.tsx
@@ -0,0 +1,53 @@
+'use client';
+
+import * as React from 'react';
+import * as AvatarPrimitive from '@radix-ui/react-avatar';
+
+import { cn } from '@/lib/utils';
+
+function Avatar({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AvatarImage({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { Avatar, AvatarImage, AvatarFallback };
diff --git a/src/components/ui/breadcrumb.tsx b/src/components/ui/breadcrumb.tsx
new file mode 100644
index 0000000..18fa15f
--- /dev/null
+++ b/src/components/ui/breadcrumb.tsx
@@ -0,0 +1,109 @@
+import * as React from 'react';
+import { Slot } from '@radix-ui/react-slot';
+import { ChevronRight, MoreHorizontal } from 'lucide-react';
+
+import { cn } from '@/lib/utils';
+
+function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
+ return ;
+}
+
+function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
+ return (
+
+ );
+}
+
+function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
+ return (
+
+ );
+}
+
+function BreadcrumbLink({
+ asChild,
+ className,
+ ...props
+}: React.ComponentProps<'a'> & {
+ asChild?: boolean
+}) {
+ const Comp = asChild ? Slot : 'a';
+
+ return (
+
+ );
+}
+
+function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
+ return (
+
+ );
+}
+
+function BreadcrumbSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<'li'>) {
+ return (
+ svg]:size-3.5', className)}
+ {...props}
+ >
+ {children ?? }
+
+ );
+}
+
+function BreadcrumbEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<'span'>) {
+ return (
+
+
+ More
+
+ );
+}
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+};
diff --git a/src/components/ui/chart.tsx b/src/components/ui/chart.tsx
new file mode 100644
index 0000000..d467d6d
--- /dev/null
+++ b/src/components/ui/chart.tsx
@@ -0,0 +1,359 @@
+import * as React from 'react';
+import * as RechartsPrimitive from 'recharts';
+
+import { cn } from '@/lib/utils';
+
+// Format: { THEME_NAME: CSS_SELECTOR }
+const THEMES = { light: '', dark: '.dark' } as const;
+
+export type ChartConfig = {
+ [k in string]: {
+ label?: React.ReactNode
+ icon?: React.ComponentType
+ } & (
+ | { color?: string; theme?: never }
+ | { color?: never; theme: Record }
+ )
+};
+
+type ChartContextProps = {
+ config: ChartConfig
+};
+
+const ChartContext = React.createContext(null);
+
+function useChart() {
+ const context = React.useContext(ChartContext);
+
+ if (!context) {
+ throw new Error('useChart must be used within a ');
+ }
+
+ return context;
+}
+
+function ChartContainer({
+ id,
+ className,
+ children,
+ config,
+ ...props
+}: React.ComponentProps<'div'> & {
+ config: ChartConfig
+ children: React.ComponentProps<
+ typeof RechartsPrimitive.ResponsiveContainer
+ >['children']
+}) {
+ const uniqueId = React.useId();
+ const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
+
+ return (
+
+
+
+
+ {children}
+
+
+
+ );
+}
+
+const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
+ const colorConfig = Object.entries(config).filter(
+ ([, config]) => config.theme || config.color,
+ );
+
+ if (!colorConfig.length) {
+ return null;
+ }
+
+ return (
+