ui-v2: Integrate bossui + chart component (#2622)

This commit is contained in:
Zane
2025-05-21 20:24:34 -07:00
committed by GitHub
parent f71bebe1d7
commit b3dc1289f4
22 changed files with 1787 additions and 383 deletions

21
ui-v2/components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "http://localhost:3000/r/registry.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/styles/main.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

6
ui-v2/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

936
ui-v2/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -34,9 +34,12 @@
"dependencies": { "dependencies": {
"@tanstack/react-router": "^1.120.5", "@tanstack/react-router": "^1.120.5",
"@tanstack/router": "^0.0.1-beta.53", "@tanstack/router": "^0.0.1-beta.53",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.511.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0" "react-dom": "^19.1.0",
"recharts": "^2.15.3"
}, },
"devDependencies": { "devDependencies": {
"@electron-forge/cli": "^7.8.1", "@electron-forge/cli": "^7.8.1",
@@ -48,6 +51,7 @@
"@electron-forge/shared-types": "^7.8.1", "@electron-forge/shared-types": "^7.8.1",
"@playwright/test": "^1.52.0", "@playwright/test": "^1.52.0",
"@tailwindcss/postcss": "^4.1.7", "@tailwindcss/postcss": "^4.1.7",
"@tailwindcss/typography": "^0.5.16",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
@@ -75,11 +79,11 @@
"prettier": "^3.5.3", "prettier": "^3.5.3",
"stylelint": "^16.19.1", "stylelint": "^16.19.1",
"stylelint-config-standard": "^38.0.0", "stylelint-config-standard": "^38.0.0",
"@tailwindcss/typography": "^0.5.16",
"tailwind-merge": "^3.3.0", "tailwind-merge": "^3.3.0",
"tailwindcss": "^4.1.7", "tailwindcss": "^4.1.7",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tw-animate-css": "^1.3.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.3.5", "vite": "^6.3.5",
"vitest": "^3.1.3" "vitest": "^3.1.3"

View File

@@ -8,7 +8,7 @@ const App: React.FC = (): React.ReactElement => {
return ( return (
<div className=""> <div className="">
<div className="titlebar-drag-region" /> <div className="titlebar-drag-region" />
<div className="h-10 border-b-1 w-full" /> <div className="h-10 w-full" />
<div className=""> <div className="">
<div className=""> <div className="">
<Suspense fallback={<SuspenseLoader />}> <Suspense fallback={<SuspenseLoader />}>

View File

@@ -1,152 +0,0 @@
import React from 'react';
import { useTimelineStyles } from '../hooks/useTimelineStyles';
interface ChartTileProps {
title: string;
value: string;
trend?: string;
data: number[];
icon: React.ReactNode;
variant?: 'line' | 'bar';
date?: Date;
}
export default function ChartTile({
title,
value,
trend,
data,
icon,
variant = 'line',
date
}: ChartTileProps) {
const { contentCardStyle } = useTimelineStyles(date);
// Convert data points to SVG coordinates
const createSmoothPath = () => {
const points = data.map((value, index) => {
const x = (index / (data.length - 1)) * 100;
const y = 100 - ((value - Math.min(...data)) / (Math.max(...data) - Math.min(...data))) * 100;
return [x, y];
});
let path = `M ${points[0][0]},${points[0][1]}`;
for (let i = 0; i < points.length - 1; i++) {
const current = points[i];
const next = points[i + 1];
const controlPoint1X = current[0] + (next[0] - current[0]) / 3;
const controlPoint2X = current[0] + 2 * (next[0] - current[0]) / 3;
path += ` C ${controlPoint1X},${current[1]} ${controlPoint2X},${next[1]} ${next[0]},${next[1]}`;
}
return path;
};
// Create bar chart elements
const createBars = () => {
const maxValue = Math.max(...data.map(Math.abs));
const barWidth = 8;
const spacing = (100 - (data.length * barWidth)) / (data.length - 1);
return data.map((value, index) => {
const x = index * (barWidth + spacing);
const height = Math.abs(value) / maxValue * 50;
const y = value > 0 ? 50 - height : 50;
return {
x,
y,
height,
isPositive: value > 0
};
});
};
return (
<div
className={`
flex flex-col justify-between
w-[320px] h-[380px]
${contentCardStyle}
rounded-[18px]
relative
overflow-hidden
transition-all duration-200
hover:scale-[1.02]
`}
>
{/* Header section with icon */}
<div className="p-4 space-y-4">
<div className="w-6 h-6">
{icon}
</div>
<div>
<div className="text-gray-600 dark:text-white/40 text-sm mb-1">{title}</div>
<div className="text-gray-900 dark:text-white text-2xl font-semibold">
{value}
{trend && <span className="ml-1 text-sm">{trend}</span>}
</div>
</div>
</div>
{/* Chart Container */}
<div className="w-full h-[160px]">
<svg
width="100%"
height="100%"
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="relative z-10"
>
{variant === 'line' ? (
<>
<defs>
<linearGradient id="chart-gradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#00CAF7" />
<stop offset="100%" stopColor="#0B54DE" />
</linearGradient>
</defs>
<path
d={createSmoothPath()}
style={{ stroke: 'url(#chart-gradient)' }}
className="stroke-[1.5] fill-none"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Data points */}
{data.map((value, index) => {
const x = (index / (data.length - 1)) * 100;
const y = 100 - ((value - Math.min(...data)) / (Math.max(...data) - Math.min(...data))) * 100;
return (
<circle
key={index}
cx={x}
cy={y}
r="2"
className="fill-blue-500"
/>
);
})}
</>
) : (
<>
{createBars().map((bar, index) => (
<rect
key={index}
x={bar.x}
y={bar.y}
width="8"
height={bar.height}
className={`${bar.isPositive ? 'fill-green-500' : 'fill-red-500'} opacity-80`}
rx="4"
/>
))}
</>
)}
</svg>
</div>
</div>
);
}

View File

@@ -1,123 +0,0 @@
import React from 'react';
import { useTimelineStyles } from '../hooks/useTimelineStyles';
interface PieChartSegment {
value: number;
color: string;
label: string;
}
interface PieChartTileProps {
title: string;
icon: React.ReactNode;
segments: PieChartSegment[];
date?: Date;
}
export default function PieChartTile({
title,
icon,
segments,
date
}: PieChartTileProps) {
const { contentCardStyle } = useTimelineStyles(date);
const total = segments.reduce((sum, segment) => sum + segment.value, 0);
let currentAngle = 0;
const createPieSegments = () => {
return segments.map((segment, index) => {
const startAngle = currentAngle;
const percentage = segment.value / total;
const angle = percentage * 360;
currentAngle += angle;
const startX = Math.cos((startAngle - 90) * Math.PI / 180) * 32 + 50;
const startY = Math.sin((startAngle - 90) * Math.PI / 180) * 32 + 50;
const endX = Math.cos((currentAngle - 90) * Math.PI / 180) * 32 + 50;
const endY = Math.sin((currentAngle - 90) * Math.PI / 180) * 32 + 50;
const largeArcFlag = angle > 180 ? 1 : 0;
const d = [
`M 50 50`,
`L ${startX} ${startY}`,
`A 32 32 0 ${largeArcFlag} 1 ${endX} ${endY}`,
'Z'
].join(' ');
return {
path: d,
color: segment.color,
label: segment.label,
percentage: (percentage * 100).toFixed(1)
};
});
};
const pieSegments = createPieSegments();
return (
<div
className={`
flex flex-col
w-[320px] h-[380px]
${contentCardStyle}
rounded-[18px]
relative
overflow-hidden
transition-all duration-200
hover:scale-[1.02]
`}
>
{/* Header */}
<div className="p-4">
<div className="w-6 h-6 mb-4">
{icon}
</div>
<div className="text-gray-600 dark:text-white/40 text-sm">
{title}
</div>
</div>
{/* Pie Chart */}
<div className="flex-1 flex flex-col items-center">
<div className="relative w-[150px] h-[150px]">
<svg
viewBox="0 0 100 100"
className="w-full h-full transform -rotate-90"
>
{pieSegments.map((segment, index) => (
<path
key={index}
d={segment.path}
fill={segment.color}
className="transition-all duration-200 hover:opacity-90"
/>
))}
</svg>
</div>
{/* Legend */}
<div className="mt-4 px-4 w-full space-y-2">
{pieSegments.map((segment, index) => (
<div key={index} className="flex items-center justify-between">
<div className="flex items-center">
<div
className="w-3 h-3 rounded-full mr-2"
style={{ backgroundColor: segment.color }}
/>
<span className="text-sm text-gray-600 dark:text-white/60">
{segment.label}
</span>
</div>
<span className="text-sm font-medium text-gray-900 dark:text-white">
{segment.percentage}%
</span>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -1,13 +1,21 @@
import React, { useRef, useMemo, useEffect } from 'react'; import React, { useRef, useMemo, useEffect } from 'react';
import ChartTile from './ChartTile'; import ChartTile from './tiles/ChartTile.tsx';
import HighlightTile from './HighlightTile'; import HighlightTile from './tiles/HighlightTile.tsx';
import PieChartTile from './PieChartTile'; import PieChartTile from './tiles/PieChartTile.tsx';
import ListTile from './ListTile'; import ListTile from './tiles/ListTile.tsx';
import ClockTile from './ClockTile'; import ClockTile from './tiles/ClockTile.tsx';
import TimelineDots from './TimelineDots'; import TimelineDots from './TimelineDots';
import { ChartLineIcon, ChartBarIcon, PieChartIcon, ListIcon, StarIcon, TrendingUpIcon } from './icons'; import {
ChartLineIcon,
ChartBarIcon,
PieChartIcon,
ListIcon,
StarIcon,
TrendingUpIcon,
} from './icons';
const generateRandomData = (length: number) => Array.from({ length }, () => Math.floor(Math.random() * 100)); const generateRandomData = (length: number) =>
Array.from({ length }, () => Math.floor(Math.random() * 100));
const generateTileData = (date: Date) => { const generateTileData = (date: Date) => {
const isToday = new Date().toDateString() === date.toDateString(); const isToday = new Date().toDateString() === date.toDateString();
@@ -24,8 +32,8 @@ const generateTileData = (date: Date) => {
data: generateRandomData(7), data: generateRandomData(7),
icon: <ChartLineIcon />, icon: <ChartLineIcon />,
variant: 'line' as const, variant: 'line' as const,
date date,
} },
}, },
{ {
type: 'highlight' as const, type: 'highlight' as const,
@@ -35,8 +43,8 @@ const generateTileData = (date: Date) => {
icon: <StarIcon />, icon: <StarIcon />,
subtitle: isToday ? 'Personal best today' : 'Keep it up', subtitle: isToday ? 'Personal best today' : 'Keep it up',
date, date,
accentColor: '#FFB800' accentColor: '#FFB800',
} },
}, },
{ {
type: 'pie' as const, type: 'pie' as const,
@@ -46,10 +54,10 @@ const generateTileData = (date: Date) => {
segments: [ segments: [
{ value: 45, color: '#00CAF7', label: 'Completed' }, { value: 45, color: '#00CAF7', label: 'Completed' },
{ value: 35, color: '#FFB800', label: 'In Progress' }, { value: 35, color: '#FFB800', label: 'In Progress' },
{ value: 20, color: '#FF4444', label: 'Pending' } { value: 20, color: '#FF4444', label: 'Pending' },
], ],
date date,
} },
}, },
// Additional metrics // Additional metrics
{ {
@@ -61,8 +69,8 @@ const generateTileData = (date: Date) => {
data: generateRandomData(7), data: generateRandomData(7),
icon: <ChartBarIcon />, icon: <ChartBarIcon />,
variant: 'bar' as const, variant: 'bar' as const,
date date,
} },
}, },
{ {
type: 'highlight' as const, type: 'highlight' as const,
@@ -72,8 +80,8 @@ const generateTileData = (date: Date) => {
icon: <StarIcon />, icon: <StarIcon />,
subtitle: 'Based on feedback', subtitle: 'Based on feedback',
date, date,
accentColor: '#4CAF50' accentColor: '#4CAF50',
} },
}, },
{ {
type: 'list' as const, type: 'list' as const,
@@ -84,10 +92,10 @@ const generateTileData = (date: Date) => {
{ text: 'Project Alpha', value: '87%', color: '#00CAF7' }, { text: 'Project Alpha', value: '87%', color: '#00CAF7' },
{ text: 'Team Meeting', value: '2:30 PM' }, { text: 'Team Meeting', value: '2:30 PM' },
{ text: 'Review Code', value: '13', color: '#FFB800' }, { text: 'Review Code', value: '13', color: '#FFB800' },
{ text: 'Deploy Update', value: 'Done', color: '#4CAF50' } { text: 'Deploy Update', value: 'Done', color: '#4CAF50' },
], ],
date date,
} },
}, },
// System metrics // System metrics
{ {
@@ -99,8 +107,8 @@ const generateTileData = (date: Date) => {
data: generateRandomData(7), data: generateRandomData(7),
icon: <ChartLineIcon />, icon: <ChartLineIcon />,
variant: 'line' as const, variant: 'line' as const,
date date,
} },
}, },
{ {
type: 'pie' as const, type: 'pie' as const,
@@ -110,11 +118,11 @@ const generateTileData = (date: Date) => {
segments: [ segments: [
{ value: 60, color: '#4CAF50', label: 'Free' }, { value: 60, color: '#4CAF50', label: 'Free' },
{ value: 25, color: '#FFB800', label: 'Used' }, { value: 25, color: '#FFB800', label: 'Used' },
{ value: 15, color: '#FF4444', label: 'System' } { value: 15, color: '#FF4444', label: 'System' },
], ],
date date,
} },
} },
], ],
right: [ right: [
// Performance metrics // Performance metrics
@@ -127,16 +135,16 @@ const generateTileData = (date: Date) => {
data: generateRandomData(7), data: generateRandomData(7),
icon: <ChartBarIcon />, icon: <ChartBarIcon />,
variant: 'bar' as const, variant: 'bar' as const,
date date,
} },
}, },
// Clock tile // Clock tile
{ {
type: 'clock' as const, type: 'clock' as const,
props: { props: {
title: 'Current Time', title: 'Current Time',
date date,
} },
}, },
{ {
type: 'highlight' as const, type: 'highlight' as const,
@@ -146,8 +154,8 @@ const generateTileData = (date: Date) => {
icon: <TrendingUpIcon />, icon: <TrendingUpIcon />,
subtitle: 'Above target', subtitle: 'Above target',
date, date,
accentColor: '#4CAF50' accentColor: '#4CAF50',
} },
}, },
{ {
type: 'pie' as const, type: 'pie' as const,
@@ -157,10 +165,10 @@ const generateTileData = (date: Date) => {
segments: [ segments: [
{ value: 55, color: '#4CAF50', label: 'Available' }, { value: 55, color: '#4CAF50', label: 'Available' },
{ value: 30, color: '#FFB800', label: 'In Use' }, { value: 30, color: '#FFB800', label: 'In Use' },
{ value: 15, color: '#FF4444', label: 'Reserved' } { value: 15, color: '#FF4444', label: 'Reserved' },
], ],
date date,
} },
}, },
// Updates and notifications // Updates and notifications
{ {
@@ -172,10 +180,10 @@ const generateTileData = (date: Date) => {
{ text: 'System Update', value: 'Complete', color: '#4CAF50' }, { text: 'System Update', value: 'Complete', color: '#4CAF50' },
{ text: 'New Features', value: '3', color: '#00CAF7' }, { text: 'New Features', value: '3', color: '#00CAF7' },
{ text: 'Bug Fixes', value: '7', color: '#FFB800' }, { text: 'Bug Fixes', value: '7', color: '#FFB800' },
{ text: 'Performance', value: '+15%', color: '#4CAF50' } { text: 'Performance', value: '+15%', color: '#4CAF50' },
], ],
date date,
} },
}, },
// Additional metrics // Additional metrics
{ {
@@ -187,8 +195,8 @@ const generateTileData = (date: Date) => {
data: generateRandomData(7), data: generateRandomData(7),
icon: <ChartLineIcon />, icon: <ChartLineIcon />,
variant: 'line' as const, variant: 'line' as const,
date date,
} },
}, },
{ {
type: 'highlight' as const, type: 'highlight' as const,
@@ -198,8 +206,8 @@ const generateTileData = (date: Date) => {
icon: <TrendingUpIcon />, icon: <TrendingUpIcon />,
subtitle: 'Last 24 hours', subtitle: 'Last 24 hours',
date, date,
accentColor: '#00CAF7' accentColor: '#00CAF7',
} },
}, },
// System health // System health
{ {
@@ -210,10 +218,10 @@ const generateTileData = (date: Date) => {
segments: [ segments: [
{ value: 75, color: '#4CAF50', label: 'Healthy' }, { value: 75, color: '#4CAF50', label: 'Healthy' },
{ value: 20, color: '#FFB800', label: 'Warning' }, { value: 20, color: '#FFB800', label: 'Warning' },
{ value: 5, color: '#FF4444', label: 'Critical' } { value: 5, color: '#FF4444', label: 'Critical' },
], ],
date date,
} },
}, },
{ {
type: 'list' as const, type: 'list' as const,
@@ -224,12 +232,12 @@ const generateTileData = (date: Date) => {
{ text: 'Main API', value: 'Online', color: '#4CAF50' }, { text: 'Main API', value: 'Online', color: '#4CAF50' },
{ text: 'Database', value: '98%', color: '#00CAF7' }, { text: 'Database', value: '98%', color: '#00CAF7' },
{ text: 'Cache', value: 'Synced', color: '#4CAF50' }, { text: 'Cache', value: 'Synced', color: '#4CAF50' },
{ text: 'CDN', value: 'Active', color: '#4CAF50' } { text: 'CDN', value: 'Active', color: '#4CAF50' },
], ],
date date,
} },
} },
] ],
}; };
}; };
@@ -251,7 +259,7 @@ export default function Timeline() {
date, date,
isToday: i === 0, isToday: i === 0,
leftTiles: tileData.left, leftTiles: tileData.left,
rightTiles: tileData.right rightTiles: tileData.right,
}); });
} }
@@ -269,7 +277,7 @@ export default function Timeline() {
sectionElement.scrollTo({ sectionElement.scrollTo({
left: scrollToX, left: scrollToX,
behavior: 'smooth' behavior: 'smooth',
}); });
}); });
}; };
@@ -287,14 +295,14 @@ export default function Timeline() {
}, },
{ {
threshold: 0.5, threshold: 0.5,
rootMargin: '0px' rootMargin: '0px',
} }
); );
// Add resize handler // Add resize handler
const handleResize = () => { const handleResize = () => {
// Find the currently visible section // Find the currently visible section
const visibleSection = sectionRefs.current.find(section => { const visibleSection = sectionRefs.current.find((section) => {
if (!section) return false; if (!section) return false;
const rect = section.getBoundingClientRect(); const rect = section.getBoundingClientRect();
const viewportHeight = window.innerHeight; const viewportHeight = window.innerHeight;
@@ -354,16 +362,18 @@ export default function Timeline() {
{sections.map((section, index) => ( {sections.map((section, index) => (
<div <div
key={index} key={index}
ref={el => sectionRefs.current[index] = el} ref={(el) => (sectionRefs.current[index] = el)}
className="h-screen relative snap-center snap-always overflow-x-scroll snap-x snap-mandatory scrollbar-hide" className="h-screen relative snap-center snap-always overflow-y-hidden overflow-x-scroll snap-x snap-mandatory scrollbar-hide"
> >
<div className="relative min-w-[calc(200vw+100px)] h-full flex items-center"> <div className="relative min-w-[calc(200vw+100px)] h-full flex items-center">
{/* Main flex container */} {/* Main flex container */}
<div className="w-full h-full flex"> <div className="w-full h-full flex">
{/* Left Grid */} {/* Left Grid */}
<div className="w-screen p-4 mt-6 overflow-hidden"> <div className="w-screen p-4 mt-6 overflow-hidden">
<div className="ml-auto mr-0 flex flex-wrap gap-4 content-start justify-end" style={{ width: 'min(720px, 90%)' }}> <div
className="ml-auto mr-0 flex flex-wrap gap-4 content-start justify-end"
style={{ width: 'min(720px, 90%)' }}
>
{section.leftTiles.map((tile, i) => ( {section.leftTiles.map((tile, i) => (
<div key={i} className="w-[calc(50%-8px)]"> <div key={i} className="w-[calc(50%-8px)]">
{renderTile(tile, i)} {renderTile(tile, i)}
@@ -375,28 +385,45 @@ export default function Timeline() {
{/* Center Timeline */} {/* Center Timeline */}
<div className="w-100px relative flex flex-col items-center h-screen"> <div className="w-100px relative flex flex-col items-center h-screen">
{/* Upper Timeline Dots */} {/* Upper Timeline Dots */}
<TimelineDots height="calc(50vh - 96px)" isUpper={true} isCurrentDay={section.isToday} /> <TimelineDots
height="calc(50vh - 96px)"
isUpper={true}
isCurrentDay={section.isToday}
/>
{/* Date Display */} {/* Date Display */}
<div className="bg-white p-4 rounded z-[3] flex flex-col items-center transition-opacity"> <div className="bg-white p-4 rounded z-[3] flex flex-col items-center transition-opacity">
<div className={`font-['Cash_Sans'] text-3xl font-light ${section.isToday ? 'opacity-100' : 'opacity-20'}`}> <div
className={`font-['Cash_Sans'] text-3xl font-light ${section.isToday ? 'opacity-100' : 'opacity-20'}`}
>
{section.date.toLocaleString('default', { month: 'short' })} {section.date.toLocaleString('default', { month: 'short' })}
</div> </div>
<div className={`font-['Cash_Sans'] text-[64px] font-light leading-none ${section.isToday ? 'opacity-100' : 'opacity-20'}`}> <div
className={`font-['Cash_Sans'] text-[64px] font-light leading-none ${section.isToday ? 'opacity-100' : 'opacity-20'}`}
>
{section.date.getDate()} {section.date.getDate()}
</div> </div>
<div className={`font-['Cash_Sans'] text-sm font-light mt-1 ${section.isToday ? 'opacity-100' : 'opacity-20'}`}> <div
className={`font-['Cash_Sans'] text-sm font-light mt-1 ${section.isToday ? 'opacity-100' : 'opacity-20'}`}
>
{section.date.toLocaleString('default', { weekday: 'long' })} {section.date.toLocaleString('default', { weekday: 'long' })}
</div> </div>
</div> </div>
{/* Lower Timeline Dots */} {/* Lower Timeline Dots */}
<TimelineDots height="calc(50vh - 96px)" isUpper={false} isCurrentDay={section.isToday} /> <TimelineDots
height="calc(50vh - 96px)"
isUpper={false}
isCurrentDay={section.isToday}
/>
</div> </div>
{/* Right Grid */} {/* Right Grid */}
<div className="w-screen p-4 mt-6 overflow-hidden"> <div className="w-screen p-4 mt-6 overflow-hidden">
<div className="flex flex-wrap gap-4 content-start" style={{ width: 'min(720px, 90%)' }}> <div
className="flex flex-wrap gap-4 content-start"
style={{ width: 'min(720px, 90%)' }}
>
{section.rightTiles.map((tile, i) => ( {section.rightTiles.map((tile, i) => (
<div key={i} className="w-[calc(50%-8px)]"> <div key={i} className="w-[calc(50%-8px)]">
{renderTile(tile, i)} {renderTile(tile, i)}

View File

@@ -0,0 +1,100 @@
import React from 'react';
import { useTimelineStyles } from '../../hooks/useTimelineStyles.ts';
import { ChartConfig, ChartContainer } from "@/components/ui/chart.tsx";
import { BarChart, Bar, LineChart, Line, ResponsiveContainer, Tooltip } from 'recharts';
interface ChartTileProps {
title: string;
value: string;
trend?: string;
data: number[];
icon: React.ReactNode;
variant?: 'line' | 'bar';
date?: Date;
}
export default function ChartTile({
title,
value,
trend,
data,
icon,
variant = 'line',
date
}: ChartTileProps) {
const { contentCardStyle } = useTimelineStyles(date);
// Convert the data array to the format expected by recharts
const chartData = data.map((value, index) => ({
value,
index: `Point ${index + 1}`
}));
// Chart configuration
const chartConfig = {
value: {
label: title,
theme: {
light: variant === 'line' ? '#0B54DE' : '#4CAF50',
dark: variant === 'line' ? '#00CAF7' : '#4CAF50'
}
}
} satisfies ChartConfig;
return (
<div
className={`
flex flex-col justify-between
w-[320px] h-[380px]
${contentCardStyle}
rounded-[18px]
relative
overflow-hidden
transition-all duration-200
hover:scale-[1.02]
`}
>
{/* Header section with icon */}
<div className="p-4 space-y-4">
<div className="w-6 h-6">
{icon}
</div>
<div>
<div className="text-gray-600 dark:text-white/40 text-sm mb-1">{title}</div>
<div className="text-gray-900 dark:text-white text-2xl font-semibold">
{value}
{trend && <span className="ml-1 text-sm">{trend}</span>}
</div>
</div>
</div>
{/* Chart Container */}
<div className="w-full h-[160px] px-4">
<ChartContainer config={chartConfig}>
{variant === 'line' ? (
<LineChart data={chartData}>
<Line
type="monotone"
dataKey="value"
stroke="var(--color-value)"
strokeWidth={2}
dot={{ fill: 'var(--color-value)', r: 4 }}
/>
<Tooltip />
</LineChart>
) : (
<BarChart data={chartData}>
<Bar
dataKey="value"
fill="var(--color-value)"
radius={[4, 4, 0, 0]}
/>
<Tooltip />
</BarChart>
)}
</ChartContainer>
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useTimelineStyles } from '../hooks/useTimelineStyles'; import { useTimelineStyles } from '../../hooks/useTimelineStyles.ts';
import waveBg from '../assets/backgrounds/wave-bg.png'; import waveBg from '../../assets/backgrounds/wave-bg.png';
interface ClockCardProps { interface ClockCardProps {
date?: Date; date?: Date;

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { useTimelineStyles } from '../hooks/useTimelineStyles'; import { useTimelineStyles } from '../../hooks/useTimelineStyles.ts';
interface HighlightTileProps { interface HighlightTileProps {
title: string; title: string;

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { useTimelineStyles } from '../hooks/useTimelineStyles'; import { useTimelineStyles } from '../../hooks/useTimelineStyles.ts';
interface ListItem { interface ListItem {
text: string; text: string;

View File

@@ -0,0 +1,125 @@
import React from 'react';
import { useTimelineStyles } from '../../hooks/useTimelineStyles';
import { ChartConfig, ChartContainer } from "@/components/ui/chart";
import { PieChart, Pie, Cell, Tooltip, Legend } from 'recharts';
interface PieChartSegment {
value: number;
color: string;
label: string;
}
interface PieChartTileProps {
title: string;
icon: React.ReactNode;
segments: PieChartSegment[];
date?: Date;
}
export default function PieChartTile({
title,
icon,
segments,
date
}: PieChartTileProps) {
const { contentCardStyle } = useTimelineStyles(date);
// Convert segments to the format expected by recharts
const chartData = segments.map(segment => ({
name: segment.label,
value: segment.value
}));
// Create chart configuration with theme colors
const chartConfig = segments.reduce((config, segment) => {
config[segment.label] = {
label: segment.label,
color: segment.color
};
return config;
}, {} as ChartConfig);
// Custom tooltip formatter
const tooltipFormatter = (value: number, name: string) => {
const total = segments.reduce((sum, segment) => sum + segment.value, 0);
const percentage = ((value / total) * 100).toFixed(1);
return [`${percentage}%`, name];
};
return (
<div
className={`
flex flex-col
w-[320px] h-[380px]
${contentCardStyle}
rounded-[18px]
relative
overflow-hidden
transition-all duration-200
hover:scale-[1.02]
`}
>
{/* Header */}
<div className="p-4">
<div className="w-6 h-6 mb-4">
{icon}
</div>
<div className="text-gray-600 dark:text-white/40 text-sm">
{title}
</div>
</div>
{/* Pie Chart */}
<div className="flex-1 flex flex-col items-center">
<div className="w-full h-[200px]">
<ChartContainer config={chartConfig}>
<PieChart>
<Pie
data={chartData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={0}
outerRadius={70}
paddingAngle={2}
>
{segments.map((segment, index) => (
<Cell
key={`cell-${index}`}
fill={segment.color}
className="transition-all duration-200 hover:opacity-90"
/>
))}
</Pie>
<Tooltip formatter={tooltipFormatter} />
</PieChart>
</ChartContainer>
</div>
{/* Legend */}
<div className="mt-2 px-4 w-full space-y-2">
{segments.map((segment, index) => {
const percentage = ((segment.value / segments.reduce((sum, s) => sum + s.value, 0)) * 100).toFixed(1);
return (
<div key={index} className="flex items-center justify-between">
<div className="flex items-center">
<div
className="w-3 h-3 rounded-full mr-2"
style={{ backgroundColor: segment.color }}
/>
<span className="text-sm text-gray-600 dark:text-white/60">
{segment.label}
</span>
</div>
<span className="text-sm font-medium text-gray-900 dark:text-white">
{percentage}%
</span>
</div>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,353 @@
"use client";
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<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
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 (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
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 (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"border-border/50 bg-background-default grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-text-muted flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-text-muted">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-text-default font-mono tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-text-muted flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

View File

@@ -0,0 +1,4 @@
Files in this directory are automatically generated/pulled in from the shadcn registry.
Add new components like this:
```npx shadcn@canary add http://localhost:3000/r/chart.json```

20
ui-v2/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,20 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const copyToClipboard = (text: string) => {
if (window === undefined) return;
window.navigator.clipboard.writeText(text);
};
export function getComponentName(name: string) {
// convert kebab-case to title case
return name.replace(/-/g, " ");
}
export function getRandomIndex(array: any[]) {
return Math.floor(Math.random() * array.length);
}

View File

@@ -5,7 +5,7 @@ import ReactDOM from 'react-dom/client';
import { router } from './routeTree'; import { router } from './routeTree';
import './index.css'; import './styles/main.css';
// Initialize the router // Initialize the router
await router.load(); await router.load();

View File

@@ -1,8 +1,7 @@
@import url('tailwindcss'); @import "tailwindcss";
@import "tw-animate-css";
@tailwind base; @custom-variant dark (&:is(.dark *));
@tailwind components;
@tailwind utilities;
@layer base { @layer base {
:root { :root {
@@ -133,9 +132,6 @@
} }
} }
@font-face { @font-face {
font-family: 'Cash Sans'; font-family: 'Cash Sans';
src: src:
@@ -177,3 +173,119 @@
left: 0; left: 0;
z-index: 50; z-index: 50;
} }
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}

View File

@@ -1,5 +1,9 @@
{ {
"compilerOptions": { "compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"target": "ES2020", "target": "ES2020",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"], "lib": ["ES2020", "DOM", "DOM.Iterable"],
@@ -20,7 +24,6 @@
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true, "exactOptionalPropertyTypes": true,
"noImplicitReturns": true, "noImplicitReturns": true,
"baseUrl": ".",
"types": ["vitest/globals", "@testing-library/jest-dom"] "types": ["vitest/globals", "@testing-library/jest-dom"]
}, },
"include": ["src", "src/test/types.d.ts"], "include": ["src", "src/test/types.d.ts"],

View File

@@ -10,6 +10,7 @@ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src'),
'@shared': path.resolve(__dirname, './shared'), '@shared': path.resolve(__dirname, './shared'),
}, },
}, },

View File

@@ -11,6 +11,7 @@ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src'),
'@shared': path.resolve(__dirname, './shared'), '@shared': path.resolve(__dirname, './shared'),
'@platform': path.resolve(__dirname, './src/services/platform/electron'), '@platform': path.resolve(__dirname, './src/services/platform/electron'),
}, },