diff --git a/apps/docs/components.json b/apps/docs/components.json new file mode 100644 index 0000000..a96b68e --- /dev/null +++ b/apps/docs/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "theme.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/apps/docs/components/progress/shared.tsx b/apps/docs/components/progress/shared.tsx new file mode 100644 index 0000000..b412d2b --- /dev/null +++ b/apps/docs/components/progress/shared.tsx @@ -0,0 +1,67 @@ +/** + * What the four progress islands share besides the data: the not-ready body + * (a skeleton while /api/stats loads, one muted line if it fails) and the + * hover tooltip for the two charts drawn as plain SVG (the heatmap calendar + * and the topic bubbles — Recharts has neither layout). The tooltip wears the + * exact classes of shadcn's ChartTooltipContent, so all four cards' tooltips + * look like one component. + */ +import { type PointerEvent, type ReactNode, useRef, useState } from "react"; +import { cn } from "../../lib/utils"; +import type { Load } from "../../lib/stats"; +import { Skeleton } from "../ui/skeleton"; + +export function StatsPending({ load, className }: { load: Exclude; className: string }) { + if (load.phase === "loading") return ; + return ( +

+ Stats unavailable — they are served live from the SRS Worker, and it did not respond. +

+ ); +} + +interface Tip { + x: number; + y: number; + content: ReactNode; +} + +/** + * A pointer-following tooltip for SVG marks. Put `ref` on a `relative` + * wrapper, call `show` from a mark's pointer events, render `tip` inside the + * wrapper. Positions are relative to the wrapper, so page scroll never skews it. + */ +export function useHoverTip() { + const ref = useRef(null); + const [tip, setTip] = useState(null); + const show = (event: PointerEvent, content: ReactNode) => { + const box = ref.current?.getBoundingClientRect(); + if (box) setTip({ x: event.clientX - box.left, y: event.clientY - box.top, content }); + }; + const hide = () => setTip(null); + const node = tip && ( +
+ {tip.content} +
+ ); + return { ref, show, hide, node }; +} + +/** One tooltip row, the shape ChartTooltipContent draws: colour dot, label, mono value. */ +export function TipRow({ color, label, value }: { color: string; label: ReactNode; value: ReactNode }) { + return ( +
+
+
+ {label} + {value} +
+
+ ); +} diff --git a/apps/docs/components/ui/card.tsx b/apps/docs/components/ui/card.tsx new file mode 100644 index 0000000..c2909e0 --- /dev/null +++ b/apps/docs/components/ui/card.tsx @@ -0,0 +1,91 @@ +import * as React from "react" +import { cn } from "../../lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/apps/docs/components/ui/chart.tsx b/apps/docs/components/ui/chart.tsx new file mode 100644 index 0000000..e834ffb --- /dev/null +++ b/apps/docs/components/ui/chart.tsx @@ -0,0 +1,372 @@ +import * as React from "react" +import { cn } from "../../lib/utils" +import * as RechartsPrimitive from "recharts" +import type { TooltipValueType } from "recharts" + +// Format: { THEME_NAME: CSS_SELECTOR }. Blume switches dark mode with +// [data-theme="dark"] on , not shadcn's .dark class. +const THEMES = { light: "", dark: ':root[data-theme="dark"]' } as const + +const INITIAL_DIMENSION = { width: 320, height: 200 } as const +type TooltipNameType = number | string + +export type ChartConfig = Record< + 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, + initialDimension = INITIAL_DIMENSION, + ...props +}: React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] + initialDimension?: { + width: number + height: number + } +}) { + 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 ( +