Skip to content

Commit 1132e91

Browse files
committed
add charts page
1 parent 1836ff2 commit 1132e91

23 files changed

+1080
-65
lines changed

package-lock.json

Lines changed: 443 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"dependencies": {
1414
"@radix-ui/react-accordion": "^1.2.2",
1515
"@radix-ui/react-dialog": "^1.1.4",
16+
"@radix-ui/react-label": "^2.1.1",
1617
"@radix-ui/react-select": "^2.1.2",
1718
"@radix-ui/react-toggle": "^1.1.1",
1819
"class-variance-authority": "^0.7.1",
@@ -24,6 +25,7 @@
2425
"react": "^18.3.1",
2526
"react-dom": "^18.3.1",
2627
"react-router": "^7.0.2",
28+
"recharts": "^2.15.0",
2729
"tailwind-merge": "^2.5.5"
2830
},
2931
"devDependencies": {

src/components/shadcn/Card.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import * as React from "react";
2+
3+
import { cn } from "./lib/utils";
4+
5+
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
6+
<div
7+
ref={ref}
8+
className={cn("rounded-xl border border-primary-dark bg-black text-primary-main shadow", className)}
9+
{...props}
10+
/>
11+
));
12+
Card.displayName = "Card";
13+
14+
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
15+
({ className, ...props }, ref) => (
16+
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6 border-primary-dark", className)} {...props} />
17+
)
18+
);
19+
CardHeader.displayName = "CardHeader";
20+
21+
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
22+
({ className, ...props }, ref) => (
23+
<div ref={ref} className={cn("font-semibold leading-none tracking-tight", className)} {...props} />
24+
)
25+
);
26+
CardTitle.displayName = "CardTitle";
27+
28+
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
29+
({ className, ...props }, ref) => <div ref={ref} className={cn("text-sm text-primary-dark", className)} {...props} />
30+
);
31+
CardDescription.displayName = "CardDescription";
32+
33+
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
34+
({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
35+
);
36+
CardContent.displayName = "CardContent";
37+
38+
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
39+
({ className, ...props }, ref) => <div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
40+
);
41+
CardFooter.displayName = "CardFooter";
42+
43+
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

src/components/shadcn/Chart.tsx

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
"use client";
2+
3+
import * as React from "react";
4+
import * as RechartsPrimitive from "recharts";
5+
6+
import { cn } from "./lib/utils";
7+
8+
// Format: { THEME_NAME: CSS_SELECTOR }
9+
const THEMES = { light: "", dark: ".dark" } as const;
10+
11+
export type ChartConfig = {
12+
[k in string]: {
13+
label?: React.ReactNode;
14+
icon?: React.ComponentType;
15+
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> });
16+
};
17+
18+
type ChartContextProps = {
19+
config: ChartConfig;
20+
};
21+
22+
const ChartContext = React.createContext<ChartContextProps | null>(null);
23+
24+
function useChart() {
25+
const context = React.useContext(ChartContext);
26+
27+
if (!context) {
28+
throw new Error("useChart must be used within a <ChartContainer />");
29+
}
30+
31+
return context;
32+
}
33+
34+
const ChartContainer = React.forwardRef<
35+
HTMLDivElement,
36+
React.ComponentProps<"div"> & {
37+
config: ChartConfig;
38+
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
39+
}
40+
>(({ id, className, children, config, ...props }, ref) => {
41+
const uniqueId = React.useId();
42+
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
43+
44+
return (
45+
<ChartContext.Provider value={{ config }}>
46+
<div
47+
data-chart={chartId}
48+
ref={ref}
49+
className={cn(
50+
"flex aspect-video justify-center text-xs select-none [&_.recharts-cartesian-axis-tick_text]:fill-primary-dark [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-primary-dark/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-primary-dark [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-primary-dark [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-primary-dark [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
51+
className
52+
)}
53+
{...props}>
54+
<ChartStyle id={chartId} config={config} />
55+
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
56+
</div>
57+
</ChartContext.Provider>
58+
);
59+
});
60+
ChartContainer.displayName = "Chart";
61+
62+
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
63+
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
64+
65+
if (!colorConfig.length) {
66+
return null;
67+
}
68+
69+
return (
70+
<style
71+
dangerouslySetInnerHTML={{
72+
__html: Object.entries(THEMES)
73+
.map(
74+
([theme, prefix]) => `
75+
${prefix} [data-chart=${id}] {
76+
${colorConfig
77+
.map(([key, itemConfig]) => {
78+
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
79+
return color ? ` --color-${key}: ${color};` : null;
80+
})
81+
.join("\n")}
82+
}
83+
`
84+
)
85+
.join("\n")
86+
}}
87+
/>
88+
);
89+
};
90+
91+
const ChartTooltip = RechartsPrimitive.Tooltip;
92+
93+
const ChartTooltipContent = React.forwardRef<
94+
HTMLDivElement,
95+
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
96+
React.ComponentProps<"div"> & {
97+
hideLabel?: boolean;
98+
hideIndicator?: boolean;
99+
indicator?: "line" | "dot" | "dashed";
100+
nameKey?: string;
101+
labelKey?: string;
102+
}
103+
>(
104+
(
105+
{
106+
active,
107+
payload,
108+
className,
109+
indicator = "dot",
110+
hideLabel = false,
111+
hideIndicator = false,
112+
label,
113+
labelFormatter,
114+
labelClassName,
115+
formatter,
116+
color,
117+
nameKey,
118+
labelKey
119+
},
120+
ref
121+
) => {
122+
const { config } = useChart();
123+
124+
const tooltipLabel = React.useMemo(() => {
125+
if (hideLabel || !payload?.length) {
126+
return null;
127+
}
128+
129+
const [item] = payload;
130+
const key = `${labelKey || item.dataKey || item.name || "value"}`;
131+
const itemConfig = getPayloadConfigFromPayload(config, item, key);
132+
const value =
133+
!labelKey && typeof label === "string"
134+
? config[label as keyof typeof config]?.label || label
135+
: itemConfig?.label;
136+
137+
if (labelFormatter) {
138+
return <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>;
139+
}
140+
141+
if (!value) {
142+
return null;
143+
}
144+
145+
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
146+
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
147+
148+
if (!active || !payload?.length) {
149+
return null;
150+
}
151+
152+
const nestLabel = payload.length === 1 && indicator !== "dot";
153+
154+
return (
155+
<div
156+
ref={ref}
157+
className={cn(
158+
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-primary-dark bg-black px-2.5 py-1.5 text-xs shadow-xl",
159+
className
160+
)}>
161+
{!nestLabel ? tooltipLabel : null}
162+
<div className='grid gap-1.5'>
163+
{payload.map((item, index) => {
164+
const key = `${nameKey || item.name || item.dataKey || "value"}`;
165+
const itemConfig = getPayloadConfigFromPayload(config, item, key);
166+
const indicatorColor = color || item.payload.fill || item.color;
167+
168+
return (
169+
<div
170+
key={item.dataKey}
171+
className={cn(
172+
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-primary-dark",
173+
indicator === "dot" && "items-center"
174+
)}>
175+
{formatter && item?.value !== undefined && item.name ? (
176+
formatter(item.value, item.name, item, index, item.payload)
177+
) : (
178+
<>
179+
{itemConfig?.icon ? (
180+
<itemConfig.icon />
181+
) : (
182+
!hideIndicator && (
183+
<div
184+
className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
185+
"h-2.5 w-2.5": indicator === "dot",
186+
"w-1": indicator === "line",
187+
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
188+
"my-0.5": nestLabel && indicator === "dashed"
189+
})}
190+
style={
191+
{
192+
"--color-bg": indicatorColor,
193+
"--color-border": indicatorColor
194+
} as React.CSSProperties
195+
}
196+
/>
197+
)
198+
)}
199+
<div
200+
className={cn(
201+
"flex flex-1 justify-between leading-none",
202+
nestLabel ? "items-end" : "items-center"
203+
)}>
204+
<div className='grid gap-1.5'>
205+
{nestLabel ? tooltipLabel : null}
206+
<span className='text-primary-dark'>{itemConfig?.label || item.name}</span>
207+
</div>
208+
{item.value && (
209+
<span className='font-mono font-medium tabular-nums text-foreground'>
210+
{item.value.toLocaleString()}
211+
</span>
212+
)}
213+
</div>
214+
</>
215+
)}
216+
</div>
217+
);
218+
})}
219+
</div>
220+
</div>
221+
);
222+
}
223+
);
224+
ChartTooltipContent.displayName = "ChartTooltip";
225+
226+
const ChartLegend = RechartsPrimitive.Legend;
227+
228+
const ChartLegendContent = React.forwardRef<
229+
HTMLDivElement,
230+
React.ComponentProps<"div"> &
231+
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
232+
hideIcon?: boolean;
233+
nameKey?: string;
234+
}
235+
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
236+
const { config } = useChart();
237+
238+
if (!payload?.length) {
239+
return null;
240+
}
241+
242+
return (
243+
<div
244+
ref={ref}
245+
className={cn("flex items-center justify-center gap-4", verticalAlign === "top" ? "pb-3" : "pt-3", className)}>
246+
{payload.map((item) => {
247+
const key = `${nameKey || item.dataKey || "value"}`;
248+
const itemConfig = getPayloadConfigFromPayload(config, item, key);
249+
250+
return (
251+
<div
252+
key={item.value}
253+
className={cn("flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-primary-dark")}>
254+
{itemConfig?.icon && !hideIcon ? (
255+
<itemConfig.icon />
256+
) : (
257+
<div
258+
className='h-2 w-2 shrink-0 rounded-[2px]'
259+
style={{
260+
backgroundColor: item.color
261+
}}
262+
/>
263+
)}
264+
{itemConfig?.label}
265+
</div>
266+
);
267+
})}
268+
</div>
269+
);
270+
});
271+
ChartLegendContent.displayName = "ChartLegend";
272+
273+
// Helper to extract item config from a payload.
274+
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
275+
if (typeof payload !== "object" || payload === null) {
276+
return undefined;
277+
}
278+
279+
const payloadPayload =
280+
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
281+
? payload.payload
282+
: undefined;
283+
284+
let configLabelKey: string = key;
285+
286+
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
287+
configLabelKey = payload[key as keyof typeof payload] as string;
288+
} else if (
289+
payloadPayload &&
290+
key in payloadPayload &&
291+
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
292+
) {
293+
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
294+
}
295+
296+
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
297+
}
298+
299+
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };

src/components/shadcn/Label.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"use client";
2+
3+
import * as React from "react";
4+
import * as LabelPrimitive from "@radix-ui/react-label";
5+
import { cva, type VariantProps } from "class-variance-authority";
6+
7+
import { cn } from "./lib/utils";
8+
9+
const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70");
10+
11+
const Label = React.forwardRef<
12+
React.ElementRef<typeof LabelPrimitive.Root>,
13+
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
14+
>(({ className, ...props }, ref) => (
15+
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
16+
));
17+
Label.displayName = LabelPrimitive.Root.displayName;
18+
19+
export { Label };

0 commit comments

Comments
 (0)