feat: refactored docs structure

This commit is contained in:
Prad Nukala committed 2026-10-05 14:22:28 -04:00
1 parent 31a1fcfdbd
commit 6247915ae5
222 files changed
+6452 -729

No files matched your search

+95
View File
@@ -0,0 +1,95 @@
---
/**
* The `PageFooter` slot: an Obsidian-style backlinks list under a topic landing
* page — every problem page that points at it, as `<num> <name>` rows in
* LeetCode-number order. Pages nothing points at (problem pages, the
* overview) render nothing; a tag no topic teaches lists its problems on its
* Appendix card instead (`pages/appendix.astro`), from the same answer.
*
* A problem points at a page when either
* - one of its topic badges links there — the same `tagTarget()` answer
* `ProblemBadges.astro` renders, so this list and the badges cannot drift
* (a DFS problem is a backlink of the one DFS landing its badge picks); or
* - it lives under the page's route (`/hybrid-structures/lru-cache` under
* `/hybrid-structures`), so a topic no LeetCode tag names still lists its
* own problems.
*
* Problem pages are recognised by their `<num>. <name>` title, the shape
* `apps/cli/sync.ts` writes.
*
* The list is a native `<details>`, open on load: the chevron at the top
* right of the header row collapses it like an accordion, with no JS.
*/
import { getEntry } from "astro:content";
import data from "blume:data";
import { withBase } from "blume/components/islands/base-path.ts";
import Icon from "blume/components/Icon.astro";
import { tagTarget, type TopicTag } from "../topic-landings.ts";
interface Props {
route: string;
}
interface Route {
path: string;
title: string;
collection: string;
entryId: string;
}
const { route } = Astro.props;
const routes: Route[] = data.routes;
const served = new Set(routes.map((r) => r.path));
const problems = routes.flatMap((r) => {
const m = r.title.match(/^(\d+)\.\s+(.+)$/);
return m ? [{ route: r, num: Number(m[1]), name: m[2]! }] : [];
});
// A problem page is never a backlink target: skip the per-problem lookups there.
const backlinks = problems.some((p) => p.route.path === route)
? []
: (
await Promise.all(
problems.map(async (p) => {
if (p.route.path.startsWith(`${route}/`)) return p;
const entry = await getEntry(p.route.collection, p.route.entryId);
const tags: TopicTag[] = entry?.data.topics ?? [];
const page = { route: p.route.path, tags };
return tags.some((tag) => tagTarget(tag, page, served).href === route) ? p : undefined;
}),
)
)
.filter((p) => p !== undefined)
.sort((a, b) => a.num - b.num);
---
{
backlinks.length > 0 && (
<details
class="group not-prose mx-auto mt-12 max-w-content border-border border-t pt-6"
open
>
<summary class="mb-2 flex cursor-pointer list-none items-center justify-between gap-2 text-muted-foreground transition-colors hover:text-foreground [&::-webkit-details-marker]:hidden">
<h2 class="flex items-baseline gap-2 font-medium text-xs uppercase tracking-wide" id="backlinks">
Backlinks
<span class="tabular-nums">{backlinks.length}</span>
</h2>
<Icon class="shrink-0 transition-transform group-open:rotate-180" name="chevron-down" size={16} />
</summary>
<ul class="text-sm">
{backlinks.map((p) => (
<li>
<a
class="-mx-2 flex items-baseline gap-3 rounded-md px-2 py-1 text-foreground no-underline transition-colors hover:bg-muted"
href={withBase(p.route.path)}
>
<span class="w-10 shrink-0 text-right font-mono text-muted-foreground text-xs tabular-nums">{p.num}</span>
<span class="truncate">{p.name}</span>
</a>
</li>
))}
</ul>
</details>
)
}
+170
View File
@@ -0,0 +1,170 @@
---
/**
* A horizontal carousel of <CarouselImage> slides, built the way Blume's own
* content components are: static markup, a custom element for the behaviour,
* no framework. The track is a CSS scroll-snap strip (so it also swipes on
* touch and the browser does the animation); prev/next sit outside it and
* the play toggle sits below with a "n / N" counter.
*
* `time` is seconds per slide while playing (default 1.25); `autoplay` starts
* playing on load. Play wraps around, pauses whenever the tab is hidden, and
* never starts on its own for a reader who prefers reduced motion — the
* button still works for them. Manual navigation (buttons, arrow keys, swipe)
* does not stop playback; it only restarts the interval so a step is never
* doubled.
*/
import Icon from "blume/components/Icon.astro";
interface Props {
/** Seconds each slide stays while playing. */
time?: number;
autoplay?: boolean;
}
const { time = 1.25, autoplay = false } = Astro.props;
const buttonClass =
"inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border bg-background text-muted-foreground transition-colors hover:border-foreground hover:text-foreground disabled:cursor-default disabled:opacity-40";
---
<blume-carousel
class="not-prose my-6 block"
data-time={String(time)}
data-autoplay={autoplay ? "" : undefined}
role="region"
aria-roledescription="carousel"
>
<div class="flex items-center gap-3">
<button class={buttonClass} data-prev type="button" aria-label="Previous slide">
<Icon class="rtl:-scale-x-100" name="chevron-left" size={18} />
</button>
<div
class="flex min-w-0 flex-1 snap-x snap-mandatory overflow-x-auto scroll-smooth rounded-blume [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
data-track
tabindex="0"
aria-live="polite"
>
<slot />
</div>
<button class={buttonClass} data-next type="button" aria-label="Next slide">
<Icon class="rtl:-scale-x-100" name="chevron-right" size={18} />
</button>
</div>
<div class="mt-3 flex items-center justify-center gap-3">
<button class={buttonClass} data-play type="button" aria-label="Play" aria-pressed="false">
<Icon class="[[aria-pressed=true]_&]:hidden" name="play" size={16} />
<Icon class="[[aria-pressed=false]_&]:hidden" name="pause" size={16} />
</button>
<span class="text-muted-foreground text-xs tabular-nums leading-5" data-count></span>
</div>
</blume-carousel>
<script>
class BlumeCarousel extends HTMLElement {
#track!: HTMLElement;
#play!: HTMLButtonElement;
#count!: HTMLElement;
#timer: number | null = null;
#index = 0;
connectedCallback() {
this.#track = this.querySelector("[data-track]")!;
this.#play = this.querySelector("[data-play]")!;
this.#count = this.querySelector("[data-count]")!;
const prev = this.querySelector<HTMLButtonElement>("[data-prev]")!;
const next = this.querySelector<HTMLButtonElement>("[data-next]")!;
const slides = this.#slides().length;
if (slides < 2) {
for (const b of [prev, next, this.#play]) b.disabled = true;
}
this.#render();
prev.addEventListener("click", () => this.#step(-1));
next.addEventListener("click", () => this.#step(1));
this.#play.addEventListener("click", () => (this.#timer === null ? this.#start() : this.#stop()));
this.addEventListener("keydown", (event) => {
if (event.key === "ArrowLeft") this.#step(-1);
else if (event.key === "ArrowRight") this.#step(1);
else return;
event.preventDefault();
});
// Swipes and trackpad scrolls move the track without #step; follow them
// once the scroll settles. Settle-only, not per event: a long smooth
// scroll from #step passes over intermediate slides and must not be
// read back as the reader's position mid-flight.
let settle: number | undefined;
this.#track.addEventListener(
"scroll",
() => {
window.clearTimeout(settle);
settle = window.setTimeout(() => this.#syncFromScroll(), 150);
},
{ passive: true }
);
document.addEventListener("visibilitychange", this.#onVisibility);
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
if (this.hasAttribute("data-autoplay") && slides > 1 && !reduced) this.#start();
}
disconnectedCallback() {
this.#stop();
document.removeEventListener("visibilitychange", this.#onVisibility);
}
#slides(): HTMLElement[] {
return [...this.#track.querySelectorAll<HTMLElement>(":scope > blume-carousel-image")];
}
#step(delta: number) {
const slides = this.#slides();
this.#index = (this.#index + delta + slides.length) % slides.length;
slides[this.#index]!.scrollIntoView({ block: "nearest", inline: "start", behavior: "smooth" });
this.#render();
if (this.#timer !== null) this.#start(); // restart the interval from this slide
}
#syncFromScroll() {
const at = Math.round(this.#track.scrollLeft / this.#track.clientWidth);
if (at !== this.#index && Number.isFinite(at)) {
this.#index = Math.min(Math.max(at, 0), this.#slides().length - 1);
this.#render();
}
}
#start() {
this.#stop();
const ms = Number(this.dataset.time) * 1000;
this.#timer = window.setInterval(() => this.#step(1), ms > 0 ? ms : 1250);
this.#play.setAttribute("aria-pressed", "true");
this.#play.setAttribute("aria-label", "Pause");
}
#stop() {
if (this.#timer !== null) window.clearInterval(this.#timer);
this.#timer = null;
this.#play.setAttribute("aria-pressed", "false");
this.#play.setAttribute("aria-label", "Play");
}
// A background tab keeps its timers but not its layout; resume on return.
#wasPlaying = false;
#onVisibility = () => {
if (document.hidden) {
this.#wasPlaying = this.#timer !== null;
this.#stop();
} else if (this.#wasPlaying) this.#start();
};
#render() {
const slides = this.#slides();
this.#count.textContent = `${this.#index + 1} / ${slides.length}`;
slides.forEach((s, i) => s.setAttribute("aria-hidden", i === this.#index ? "false" : "true"));
}
}
if (!customElements.get("blume-carousel")) {
customElements.define("blume-carousel", BlumeCarousel);
}
</script>
+45
View File
@@ -0,0 +1,45 @@
---
/**
* One slide of a <Carousel>: Blume's own <Frame> around an <img>, so a slide
* is pixel-identical to a framed image elsewhere on the site — `alt` doubles
* as the frame's hint line and `caption` is Markdown, exactly as in Frame.
*
* `src` accepts what MDX authors already write for images: a URL, a
* root-relative `/file.png` (served from public/, prefixed with the deploy
* base), or the page-relative `../../public/file.png` form. A relative path
* that does not pass through public/ has nothing to serve it, so it is a
* build error rather than a silent 404.
*/
import Frame from "blume/components/content/Frame.astro";
import { withBase } from "blume/components/islands/base-path.ts";
interface Props {
src: string;
alt?: string;
caption?: string;
}
const { src, alt, caption } = Astro.props;
const resolveSrc = (value: string): string => {
if (URL.canParse(value)) return value;
if (value.startsWith("/")) return withBase(value);
const at = value.indexOf("public/");
if (at === -1) {
throw new Error(
`<CarouselImage src="${value}">: a relative src must point into public/ (or use /file.png)`
);
}
return withBase(`/${value.slice(at + "public/".length)}`);
};
---
<blume-carousel-image
class="block w-full shrink-0 snap-center [&_blume-frame]:my-0"
role="group"
aria-roledescription="slide"
>
<Frame caption={caption} hint={alt}>
<img alt={alt ?? ""} src={resolveSrc(src)} loading="lazy" draggable="false" />
</Frame>
</blume-carousel-image>
+134
View File
@@ -0,0 +1,134 @@
---
/**
* The MDX `wrapper`: the badge row between a page's description and its body.
* Blume's page template renders the title and description itself and then
* the MDX content, with no slot in between, so wrapping the content is the
* only way to land right after the description. MDX hands a wrapper no
* route, so it is read off the URL, and the page's frontmatter is then
* looked up through the route manifest — the same `getEntry` call Blume's own
* page template makes. Pages with none of the keys below (landing pages, the
* changelog) render only their content.
*
* - `topics:` (problem pages, written by `apps/cli/sync.ts`) is LeetCode's own
* topic tags. A tag the campaign teaches (`topic-landings.ts`) links to that
* topic's landing page and is filled; any other tag links to its card on the
* Appendix glossary, `/appendix#<slug>` (`appendix.ts`), and is outlined.
* `tagTarget()` picks the route (and between two landings, e.g. DFS); the
* `PageFooter` backlinks list and the Appendix page read the same answer.
* Each tag badge is a pill carrying the icon its target shows (a topic's
* `meta.ts` icon in the sidebar, a term's `icon` on its card) and the tag's
* own colour (`lib/tag-colors.ts`), assigned over every tag the site has a
* page or card for — no two tags share one, and tags that share problem
* pages are spread furthest apart. A tag with neither (the LeetCode-list
* fallback) stays gray and iconless.
* - `video:` (problem pages, written by `apps/cli/videos.ts`) is the NeetCode
* walkthrough's YouTube id, the red "Explanation" badge.
* - `issue:` (problem pages, written by sync) is the problem's GitHub issue
* URL, a neutral "Issue #N" badge with the GitHub mark, right after
* "Explanation" at the end of the row.
*/
import { getEntry } from "astro:content";
import data from "blume:data";
import Badge from "blume/components/content/Badge.astro";
import { stripBase, withBase } from "blume/components/islands/base-path.ts";
import { APPENDIX } from "../appendix.ts";
import { tagColors } from "../lib/tag-colors.ts";
import { TOPIC_LANDINGS, tagTarget, type TopicTag } from "../topic-landings.ts";
// `/sub/tree-dfs/invert-binary-tree/` (or `.../index.html` in a static build)
// -> `/tree-dfs/invert-binary-tree`, the base-less shape of `data.routes`.
const route =
stripBase(import.meta.env.BASE_URL ?? "/", Astro.url.pathname)
.replace(/(\/index)?\.html$/u, "")
.replace(/(.)\/+$/u, "$1");
const at = data.routes.find((r: { path: string }) => r.path === route);
const entry = at ? await getEntry(at.collection, at.entryId) : undefined;
const tags: TopicTag[] = entry?.data.topics ?? [];
const video: string | undefined = entry?.data.video;
const issue: string | undefined = entry?.data.issue;
const served = new Set<string>(data.routes.map((r: { path: string }) => r.path));
// route → the icon its sidebar row shows: a page's own, or a topic group's
// (from its `meta.ts`) for the landing page the group row links to.
interface NavNode {
route?: string;
icon?: string;
children?: NavNode[];
}
const icons = new Map<string, string>();
const walk = (nodes: NavNode[]) => {
for (const node of nodes) {
if (node.route && node.icon && !icons.has(node.route)) icons.set(node.route, node.icon);
if (node.children) walk(node.children);
}
};
walk(data.navigation.sidebar);
// Every tag with a landing or an Appendix card, coloured against how problem
// pages (`<num>. <name>` titles, the shape sync writes) combine them. Only a
// page with tags needs the answer.
const colors = tags.length
? tagColors(
[...Object.keys(TOPIC_LANDINGS), ...Object.keys(APPENDIX)],
await Promise.all(
data.routes
.filter((r: { title: string }) => /^\d+\.\s/u.test(r.title))
.map(async (r: { collection: string; entryId: string }) => {
const page = await getEntry(r.collection, r.entryId);
return (page?.data.topics ?? []).map((t: TopicTag) => t.slug);
}),
),
)
: new Map<string, string>();
const badges = tags.map((tag) => {
const target = tagTarget(tag, { route, tags }, served);
const external = target.kind === "external";
return {
name: tag.name,
href: external ? target.href : withBase(target.href),
external,
icon: target.kind === "appendix" ? APPENDIX[tag.slug]?.icon : external ? undefined : icons.get(target.href),
color: external ? undefined : colors.get(tag.slug),
stroke: target.kind !== "landing",
};
});
const linkClass = "no-underline transition-opacity hover:opacity-80";
---
{
(badges.length > 0 || video || issue) && (
<div class="not-prose mb-6 flex flex-wrap gap-1.5">
{badges.map((b) => (
<a
href={b.href}
target={b.external ? "_blank" : undefined}
rel={b.external ? "noopener" : undefined}
class={linkClass}
>
<Badge color={b.color} icon={b.icon} shape="pill" stroke={b.stroke}>
{b.name}
</Badge>
</a>
))}
{video && (
<a href={`https://www.youtube.com/watch?v=${video}`} target="_blank" rel="noopener" class={linkClass}>
<Badge variant="danger" icon="youtube" shape="pill">
Explanation
</Badge>
</a>
)}
{issue && (
<a href={issue} target="_blank" rel="noopener" class={linkClass}>
<Badge icon="github" shape="pill">
Issue #{issue.slice(issue.lastIndexOf("/") + 1)}
</Badge>
</a>
)}
</div>
)
}
<slot />
+67
View File
@@ -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<Load, { phase: "ready" }>; className: string }) {
if (load.phase === "loading") return <Skeleton className={className} />;
return (
<p className="my-0! text-muted-foreground text-sm">
Stats unavailable — they are served live from the SRS Worker, and it did not respond.
</p>
);
}
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<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [tip, setTip] = useState<Tip | null>(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 && (
<div
className={cn(
"pointer-events-none absolute z-10 grid min-w-[8rem] -translate-x-1/2 -translate-y-[calc(100%+0.75rem)] items-start gap-1.5",
"rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
)}
style={{ left: tip.x, top: tip.y }}
>
{tip.content}
</div>
);
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 (
<div className="flex w-full items-center gap-2">
<div className="size-2.5 shrink-0 rounded-[2px]" style={{ background: color }} />
<div className="flex flex-1 items-center justify-between gap-4 leading-none">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium font-mono text-foreground tabular-nums">{value}</span>
</div>
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
import * as React from "react"
import { cn } from "../../lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+372
View File
@@ -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 <html>, 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<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,
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 (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.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-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.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 [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer
initialDimension={initialDimension}
>
{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
} & Omit<
RechartsPrimitive.DefaultTooltipContentProps<
TooltipValueType,
TooltipNameType
>,
"accessibilityLayer"
>) {
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]?.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(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.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={index}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
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-muted-foreground">
{itemConfig?.label ?? item.name}
</span>
</div>
{item.value != null && (
<span className="font-mono font-medium text-foreground tabular-nums">
{typeof item.value === "number"
? item.value.toLocaleString()
: String(item.value)}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> & {
hideIcon?: boolean
nameKey?: string
} & RechartsPrimitive.DefaultLegendContentProps) {
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
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={index}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{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]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "../../lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
+83
View File
@@ -0,0 +1,83 @@
"use client"
import * as React from "react"
import { type VariantProps } from "class-variance-authority"
import { cn } from "../../lib/utils"
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"
import { toggleVariants } from "./toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
}
>({
size: "default",
variant: "default",
spacing: 0,
})
function ToggleGroup({
className,
variant,
size,
spacing = 0,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }
+47
View File
@@ -0,0 +1,47 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../../lib/utils"
import { Toggle as TogglePrimitive } from "radix-ui"
// Blume's `accent` is its brand colour, not shadcn's subtle surface: the on
// and hover states use background/muted instead (see theme.css).
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-sm dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-muted hover:text-foreground",
},
size: {
default: "h-9 min-w-9 px-2",
sm: "h-8 min-w-8 px-1.5",
lg: "h-10 min-w-10 px-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }