mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 21:22:43 +00:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66affbe128 | ||
|
|
9de25fecff | ||
|
|
d388b3000e | ||
|
|
f14dcf2723 | ||
|
|
d171fe1565 | ||
|
|
e9d40020f4 | ||
|
|
0b004fcba2 | ||
|
|
33022c806d | ||
|
|
d59b9dccd9 | ||
|
|
17c691c481 | ||
|
|
ea62cda955 | ||
|
|
74939c31d7 | ||
|
|
0d5c40ac4a | ||
|
|
4a1838ab28 | ||
|
|
c2a375fffc | ||
|
|
0e9e06eb8d | ||
|
|
937266b629 | ||
|
|
88c0c9b898 | ||
|
|
099123539e | ||
|
|
8c39b3ac23 | ||
|
|
7ead021ee2 | ||
|
|
0d066b8104 | ||
|
|
9af5ab1fbb | ||
|
|
f770408ea2 | ||
|
|
cf6f09825a | ||
|
|
ab09e40a2e | ||
|
|
91dad73ebf | ||
|
|
400bd5abf5 | ||
|
|
a258b46cf0 | ||
|
|
4875fd5139 | ||
|
|
7b14673e95 | ||
|
|
1c45e03ddd | ||
|
|
cbc858b667 | ||
|
|
c890fd35a7 | ||
|
|
3e62d6fa6f | ||
|
|
017f111594 |
No files matched your search
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
name: problem-approach
|
name: problem-approach
|
||||||
description: Write the `## Approach` section — a Mermaid flowchart that traces the submitted solution — for every docs solution page under apps/docs/content/ that lacks one, using the repo's exact placement and node conventions. Use when asked to add/implement/fill in missing approaches, draw the approach diagram for a problem page, or when `/problem-approach` is run.
|
description: Write the `## Approach` section — a Mermaid flowchart that traces the submitted solution — for every docs solution page under docs/ that lacks one, using the repo's exact placement and node conventions. Use when asked to add/implement/fill in missing approaches, draw the approach diagram for a problem page, or when `/problem-approach` is run.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Problem approach diagrams
|
# Problem approach diagrams
|
||||||
|
|
||||||
Every solution page (`apps/docs/content/(<phase>)/<topic>/<num>-<slug>.mdx`) has a
|
Every solution page (`docs/(<phase>)/<topic>/(problems)/<num>_<slug>.mdx`) has a
|
||||||
hand-owned section before the script-owned one:
|
hand-owned section before the script-owned one:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -23,7 +23,7 @@ next `work/` push.
|
|||||||
1. **Find the pages** (numbered pages with no `## Approach` heading):
|
1. **Find the pages** (numbered pages with no `## Approach` heading):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
grep -L '^## Approach' apps/docs/content/*/*.mdx
|
grep -rL '^## Approach' docs --include='[0-9]*_*.mdx'
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **For each page**, read `## Solution` and write the diagram from *that code* — the
|
2. **For each page**, read `## Solution` and write the diagram from *that code* — the
|
||||||
@@ -47,11 +47,13 @@ next `work/` push.
|
|||||||
````
|
````
|
||||||
|
|
||||||
4. **Check** each diagram by hand against the syntax rules below (the docs build does
|
4. **Check** each diagram by hand against the syntax rules below (the docs build does
|
||||||
not parse Mermaid — a broken diagram fails only in the browser), then run the
|
not parse Mermaid — a broken diagram fails only in the browser). The site is built
|
||||||
docs build once at the end and review the pages in `bun run dev`:
|
from prdlk/cs, which reads `docs/` over HTTP, so preview unpushed pages there
|
||||||
|
against this checkout (its `README` names the local-content switch), then build
|
||||||
|
once at the end:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd apps/docs && bunx blume build --isolated && rm -rf .blume-verify
|
cd ../cs && bunx blume build --isolated && rm -rf .blume-verify
|
||||||
```
|
```
|
||||||
|
|
||||||
Pages with two languages (`<CodeGroup>`) get **one** diagram, traced from the
|
Pages with two languages (`<CodeGroup>`) get **one** diagram, traced from the
|
||||||
@@ -59,7 +61,7 @@ Python version. Pages with only JavaScript trace the JavaScript.
|
|||||||
|
|
||||||
## Diagram conventions
|
## Diagram conventions
|
||||||
|
|
||||||
Reference page: `apps/docs/content/(array)/15-3sum.mdx`. Every existing diagram
|
Reference page: `docs/(01-linear-structures)/two-pointers/(problems)/15_3sum.mdx`. Every existing diagram
|
||||||
follows this shape:
|
follows this shape:
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -100,7 +102,7 @@ flowchart TD
|
|||||||
The base case is the first decision after entry.
|
The base case is the first decision after entry.
|
||||||
- **Classes** (design problems) use one `subgraph name["method(args)"]` per public
|
- **Classes** (design problems) use one `subgraph name["method(args)"]` per public
|
||||||
method, plus a `state["…"]` subgraph describing the fields, with `state --> method`
|
method, plus a `state["…"]` subgraph describing the fields, with `state --> method`
|
||||||
edges — see `(hash-table)/146-lru-cache.mdx`. Private helpers are named inside the
|
edges — see `hybrid-structures/(problems)/146_lru-cache.mdx`. Private helpers are named inside the
|
||||||
method nodes (`"_remove_node then _add_node — move it beside the tail"`), not drawn.
|
method nodes (`"_remove_node then _add_node — move it beside the tail"`), not drawn.
|
||||||
- **Annotate the why with an em dash** inside the node, sparingly (one clause, a few
|
- **Annotate the why with an em dash** inside the node, sparingly (one clause, a few
|
||||||
per diagram): `"nums.sort() — duplicates become adjacent"`, `"map = {0: 1} — the
|
per diagram): `"nums.sort() — duplicates become adjacent"`, `"map = {0: 1} — the
|
||||||
@@ -122,7 +124,7 @@ flowchart TD
|
|||||||
### Code that does not run
|
### Code that does not run
|
||||||
|
|
||||||
Trace it anyway — the diagram documents what was submitted — and put a `::::warning`
|
Trace it anyway — the diagram documents what was submitted — and put a `::::warning`
|
||||||
callout **before** the fence naming exactly why it fails (`(array)/121-best-time-to-buy-and-sell-stock.mdx`):
|
callout **before** the fence naming exactly why it fails (`sliding-window/(problems)/121_best-time-to-buy-and-sell-stock.mdx`):
|
||||||
|
|
||||||
````md
|
````md
|
||||||
## Approach
|
## Approach
|
||||||
|
|||||||
@@ -5,53 +5,19 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# The Worker only. The docs site is built and published from prdlk/cs, which
|
||||||
|
# reads docs/ from this repo over HTTP (Blume's mdxRemote()), so this repo has
|
||||||
|
# no Pages build.
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
pages: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
# The Worker deploy is idempotent (wrangler uploads the whole bundle), so on a
|
# The Worker deploy is idempotent (wrangler uploads the whole bundle), so on a
|
||||||
# burst of pushes only the last one matters: cancel-in-progress is correct for
|
# burst of pushes only the last one matters: cancel-in-progress is correct.
|
||||||
# both the Pages build and the Worker job.
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: deploy
|
group: deploy
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22 # Blume requires 22.12+
|
|
||||||
|
|
||||||
- uses: oven-sh/setup-bun@v2 # skip if you use npm/pnpm
|
|
||||||
|
|
||||||
- run: bun install --frozen-lockfile
|
|
||||||
# pages/changelog.astro reads this repo's GitHub releases at build time
|
|
||||||
# (apps/docs/lib/releases.ts). The token authenticates that fetch —
|
|
||||||
# required if the repo is private, and it lifts the shared runner's
|
|
||||||
# unauthenticated API rate limit. `contents: read` covers it.
|
|
||||||
- run: bun run build
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- uses: actions/upload-pages-artifact@v3
|
|
||||||
with:
|
|
||||||
path: apps/docs/dist
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
environment:
|
|
||||||
name: github-pages
|
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
|
||||||
steps:
|
|
||||||
- id: deployment
|
|
||||||
uses: actions/deploy-pages@v4
|
|
||||||
|
|
||||||
# Deploys on every main push (not path-filtered): apps/api/data/schedule.json
|
# Deploys on every main push (not path-filtered): apps/api/data/schedule.json
|
||||||
# is bundled at deploy time, and re-deploying an unchanged Worker is a no-op.
|
# is bundled at deploy time, and re-deploying an unchanged Worker is a no-op.
|
||||||
# Needs repo secrets CLOUDFLARE_API_TOKEN (Workers Scripts: Edit),
|
# Needs repo secrets CLOUDFLARE_API_TOKEN (Workers Scripts: Edit),
|
||||||
|
|||||||
@@ -37,9 +37,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||||||
# Finder (MacOS) folder config
|
# Finder (MacOS) folder config
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/CLAUDE.md
|
/CLAUDE.md
|
||||||
.blume/
|
|
||||||
/apps/docs/docs/.wrangler/state/v3
|
|
||||||
.blume-verify/
|
|
||||||
|
|
||||||
# api worker
|
# api worker
|
||||||
apps/api/.dev.vars
|
apps/api/.dev.vars
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
LeetCode interview-prep campaign (2026-08-17 → Nov 3: eight weeks of topics, then the Review-issue phase; the end is the last date in `apps/api/data/schedule.json` — `CAMPAIGN_END`/`CAMPAIGN_DAYS` in `apps/api/src/srs.ts` are derived from it, never declared) run as a repo: solution files under `work/`, a Blume docs site under `apps/docs/`, GitHub Actions that reconcile issues and publish docs, and a Cloudflare Worker (`apps/api/`) that runs the spaced-repetition system (SRS) — daily digest email, one-tap logging, weekly review issues, live charts. Issues model the curriculum: ~24 `topic` issues (`Topic NN — Name`) own ~161 `problem` sub-issues titled `LC <num> · <Name>`, with difficulty and set carried **only** by labels (`set:core|optional|deferred`, `diff:*`) — never by the title; milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state.
|
LeetCode interview-prep campaign (2026-08-17 → Nov 3: eight weeks of topics, then the Review-issue phase; the end is the last date in `apps/api/data/schedule.json` — `CAMPAIGN_END`/`CAMPAIGN_DAYS` in `apps/api/src/srs.ts` are derived from it, never declared) run as a repo: solution files under `work/`, the docs content under `docs/` (MDX only — the Blume site that publishes it lives in [prdlk/cs](https://github.com/prdlk/cs) and reads `docs/` over HTTP), GitHub Actions that reconcile issues, and a Cloudflare Worker (`apps/api/`) that runs the spaced-repetition system (SRS) — daily digest email, one-tap logging, weekly review issues, live charts. Issues model the curriculum: ~24 `topic` issues (`Topic NN — Name`) own ~161 `problem` sub-issues titled `LC <num> · <Name>`, with difficulty and set carried **only** by labels (`set:core|optional|deferred`, `diff:*`) — never by the title; milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state.
|
||||||
|
|
||||||
## Architecture & Data Flow
|
## Architecture & Data Flow
|
||||||
|
|
||||||
@@ -10,7 +10,8 @@ Three owners, one direction of truth:
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
work[work/ solutions] -->|bun run sync| docs[apps/docs/content/*.mdx]
|
work[work/ solutions] -->|bun run sync| docs[docs/*.mdx + docs/site.json]
|
||||||
|
docs -->|mdxRemote + site.json fetch| Site[prdlk/cs docs site]
|
||||||
work -->|close-solved.yml| PI[problem issues]
|
work -->|close-solved.yml| PI[problem issues]
|
||||||
work -->|close-solved.yml POST /admin/solved| D1[(D1 = SRS state)]
|
work -->|close-solved.yml POST /admin/solved| D1[(D1 = SRS state)]
|
||||||
PI -->|close-topics.yml| TI[topic + Review N issues]
|
PI -->|close-topics.yml| TI[topic + Review N issues]
|
||||||
@@ -38,7 +39,7 @@ flowchart LR
|
|||||||
- **Determinism = idempotency.** Drill/gate sampling uses a seeded PRNG (`rng()` in `apps/api/src/srs.ts`, FNV-1a → mulberry32, seed = date / ISO week); the digest is keyed by ET date in `email_log`; one-tap links are unique on (problem, date, kind).
|
- **Determinism = idempotency.** Drill/gate sampling uses a seeded PRNG (`rng()` in `apps/api/src/srs.ts`, FNV-1a → mulberry32, seed = date / ISO week); the digest is keyed by ET date in `email_log`; one-tap links are unique on (problem, date, kind).
|
||||||
- **DST-proof crons:** each event has two UTC crons; code fires only on the computed ET hour (`etHour`), unit-checked in `apps/api/src/srs.test.ts`.
|
- **DST-proof crons:** each event has two UTC crons; code fires only on the computed ET hour (`etHour`), unit-checked in `apps/api/src/srs.test.ts`.
|
||||||
- **Chained workflows:** `GITHUB_TOKEN`-driven issue closes fire no `issues` events, so `close-topics.yml` chains off `workflow_run` of Close Solved instead. The Worker's webhook uses its own `GH_PAT`, so its events flow normally.
|
- **Chained workflows:** `GITHUB_TOKEN`-driven issue closes fire no `issues` events, so `close-topics.yml` chains off `workflow_run` of Close Solved instead. The Worker's webhook uses its own `GH_PAT`, so its events flow normally.
|
||||||
- **One "finished topic" rule, two consumers.** `apps/cli/topics.ts` (`coreSet()`: every `set:core` sub-issue closed, zero core = never) is read by `close-topics.ts` (closes the topic issue) and `release-topics.ts` (the `release` job of `close-solved.yml`). The release reads the sub-issues, not the topic issue, so the push that finishes a topic releases it in the same run instead of waiting for Close Topics to chain after it. Version = [timever](https://gist.github.com/twolfson/de1b004dd22536b8e668) (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC) of the instant the last core problem closed; the tag is annotated and backdated to that instant, on the last first-parent main commit before it; the body is a changelog snapshot at that instant (a one-sentence summary first, then the problems closed on the finish day / earlier, with solution links at the tag — problems still open at the finish are left out). A release is found by its `<!-- release-topics: #N -->` body marker, never by tag name, so re-closing a core problem never mints a second one; an existing release keeps its tag and has its name (the topic title) and body rewritten whenever the render changes. The docs `/changelog` is ONE custom page, `apps/docs/pages/changelog.astro` (a NeonUI-style timeline: date left, a card per release right, no per-release subpages, so nothing of it enters the sidebar or prev/next), linked from the header by `navigation.actions` (not a tab). `apps/docs/lib/releases.ts` fetches the releases at build (`GITHUB_TOKEN` in `deploy.yml`; a failed fetch fails the build), dates each by its timever tag, not `published_at` (which the API cannot backdate), tags it `Topic` or `Review` from the release name, and lists the rows of its `### Closed …` tables — so the tag format and that table shape are the page's contract with `render()`. There is no changelog RSS feed.
|
- **One "finished topic" rule, two consumers.** `apps/cli/topics.ts` (`coreSet()`: every `set:core` sub-issue closed, zero core = never) is read by `close-topics.ts` (closes the topic issue) and `release-topics.ts` (the `release` job of `close-solved.yml`). The release reads the sub-issues, not the topic issue, so the push that finishes a topic releases it in the same run instead of waiting for Close Topics to chain after it. Version = [timever](https://gist.github.com/twolfson/de1b004dd22536b8e668) (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC) of the instant the last core problem closed; the tag is annotated and backdated to that instant, on the last first-parent main commit before it; the body is a changelog snapshot at that instant (a one-sentence summary first, then the problems closed on the finish day / earlier, with solution links at the tag — problems still open at the finish are left out). A release is found by its `<!-- release-topics: #N -->` body marker, never by tag name, so re-closing a core problem never mints a second one; an existing release keeps its tag and has its name (the topic title) and body rewritten whenever the render changes. The docs site's `/changelog` (prdlk/cs `pages/changelog.astro` + `lib/releases.ts`) fetches this repo's releases at its build, dates each by its timever tag, not `published_at` (which the API cannot backdate), tags it `Topic` or `Review` from the release name, and lists the rows of its `### Closed …` tables — so the tag format and that table shape are its cross-repo contract with `render()`.
|
||||||
- **Review issues re-parent problems; topics still own them.** The transfer phase's 20 `Review <N> — <Phase>` issues (label `review`, milestone `Review — Transfer & Mocks`; not the Worker's `Review — Week N` gate issues) hold open optional/deferred problems as sub-issues in solve order, moved there with `replace_parent`. `apps/api/src/reviews.ts` is the ONE recovery of ownership — `readReviews()` (one GraphQL request per 50 `review` issues, cheap enough for the Worker's cron subrequest budget) reads each moved problem's topic off its last `parent_issue_removed` event naming a topic, `movedFromTopics()` groups them — shared by the Worker's catalog reconcile (a moved problem's D1 row keeps syncing its labels and `topic_issue`) and `apps/cli/topics.ts` (`readProblems()` returns a topic's sub-issues plus its moved problems, so the finished rule and existing release bodies do not change when a problem moves). A Review issue finishes by `completion()` in `topics.ts` — every sub-issue closed, any set — which `close-topics.ts` closes it on and `release-topics.ts` releases it on, exactly like a topic: timever of the last close, same `<!-- release-topics: #N -->` marker (issue numbers never collide), body fields from the review's `**Scheduled:**`/`**Phase:**`/`**Load:**` lines.
|
- **Review issues re-parent problems; topics still own them.** The transfer phase's 20 `Review <N> — <Phase>` issues (label `review`, milestone `Review — Transfer & Mocks`; not the Worker's `Review — Week N` gate issues) hold open optional/deferred problems as sub-issues in solve order, moved there with `replace_parent`. `apps/api/src/reviews.ts` is the ONE recovery of ownership — `readReviews()` (one GraphQL request per 50 `review` issues, cheap enough for the Worker's cron subrequest budget) reads each moved problem's topic off its last `parent_issue_removed` event naming a topic, `movedFromTopics()` groups them — shared by the Worker's catalog reconcile (a moved problem's D1 row keeps syncing its labels and `topic_issue`) and `apps/cli/topics.ts` (`readProblems()` returns a topic's sub-issues plus its moved problems, so the finished rule and existing release bodies do not change when a problem moves). A Review issue finishes by `completion()` in `topics.ts` — every sub-issue closed, any set — which `close-topics.ts` closes it on and `release-topics.ts` releases it on, exactly like a topic: timever of the last close, same `<!-- release-topics: #N -->` marker (issue numbers never collide), body fields from the review's `**Scheduled:**`/`**Phase:**`/`**Load:**` lines.
|
||||||
- **Review days are plan days.** `schedule.json` books each Review issue on its own date — Mon/Tue/Thu/Fri only, Oct 1 → Nov 3 — as `review: { issue, title, problems }` (solve order). The digest renders it as a linked "Today's review" section with the issue's day rules (`REVIEW_RULES` in `email.tsx`, one sentence for HTML and text), its problems join `RESERVED` so drills and sampled gates never draw them early, and `gate: []` skips a Saturday's checkpoint (Oct 3). The issue's sub-issues stay the truth: the catalog reconcile compares every booked review's title and problem list with GitHub and returns any mismatch as `reviewDrift` (also logged), so a re-arranged Review issue shows up the next sync instead of silently disagreeing with the email. Move a review by editing its `schedule.json` date AND the issue's `**Scheduled:**` line (the release body reads it).
|
- **Review days are plan days.** `schedule.json` books each Review issue on its own date — Mon/Tue/Thu/Fri only, Oct 1 → Nov 3 — as `review: { issue, title, problems }` (solve order). The digest renders it as a linked "Today's review" section with the issue's day rules (`REVIEW_RULES` in `email.tsx`, one sentence for HTML and text), its problems join `RESERVED` so drills and sampled gates never draw them early, and `gate: []` skips a Saturday's checkpoint (Oct 3). The issue's sub-issues stay the truth: the catalog reconcile compares every booked review's title and problem list with GitHub and returns any mismatch as `reviewDrift` (also logged), so a re-arranged Review issue shows up the next sync instead of silently disagreeing with the email. Move a review by editing its `schedule.json` date AND the issue's `**Scheduled:**` line (the release body reads it).
|
||||||
|
|
||||||
@@ -48,10 +49,10 @@ flowchart LR
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `work/<1\|3\|7>/<Difficulty>/<Category>/<num>.<slug>.{js,py}` | Solutions, e.g. `work/1/Easy/Array/1.two-sum.py`. The top directory is the spaced-repetition bucket: `1` = first solve, `3`/`7` = the blind re-solves of that problem 3 and 7 days later. Machine-parsed header comment (title / `Difficulty:` / URL / `─` rule / statement) — preserve its exact shape |
|
| `work/<1\|3\|7>/<Difficulty>/<Category>/<num>.<slug>.{js,py}` | Solutions, e.g. `work/1/Easy/Array/1.two-sum.py`. The top directory is the spaced-repetition bucket: `1` = first solve, `3`/`7` = the blind re-solves of that problem 3 and 7 days later. Machine-parsed header comment (title / `Difficulty:` / URL / `─` rule / statement) — preserve its exact shape |
|
||||||
| `apps/cli/` | Flat Bun TS workspace: automation entries (shebang + top-level await) and libraries (no shebang, side-effect-free on import). Root `bun run` scripts delegate here |
|
| `apps/cli/` | Flat Bun TS workspace: automation entries (shebang + top-level await) and libraries (no shebang, side-effect-free on import). Root `bun run` scripts delegate here |
|
||||||
| `apps/docs/` | Blume site (`blume.config.ts`, `content/`, `components/` + `components.ts`, `islands/`, `public/`). `content/(0N-<phase>)/<topic>/<num>-<slug>.mdx` plus one `meta.ts` per phase and per topic folder, all generated by sync (a phase's once, then hand-owned): the phase folder is a `(group)` (sidebar section, no route segment), the topic folder is the README campaign topic slug (`tree-dfs/`, `heap-priority-queue/`) and a real route segment (`/tree-dfs/invert-binary-tree`), the `meta.ts` files carry the exact labels, phase/topic numbers and icons so the sidebar reads in curriculum order with each topic a collapsible group. A problem page's badge row (between the description and the body) is rendered, not written: the MDX `wrapper` (`components/ProblemBadges.astro`, registered as `mdx.wrapper` in `components.ts` — Blume's page template renders the title and description itself with no slot before the body, so the wrapper, which MDX hands no route, reads it off `Astro.url`) reads the page's `topics:` (LeetCode's topic tags, from sync) and `video:` (NeetCode YouTube id, from `bun run videos`) frontmatter — both declared via `frontmatter.extend` in `blume.config.ts`, hand-rolled Standard Schemas, no zod — and links a tag the campaign teaches to its topic landing page (filled), any other tag to its Appendix guide page `/appendix/<slug>` (outlined; LeetCode's problem list if the guide is gone, gray and iconless), and the video as a red `Explanation` badge. Every badge in the row is a pill. A tag badge carries the icon its target shows in the sidebar (read off `data.navigation`: the topic `meta.ts` icon, the guide's `icon:`) and the tag's own colour from `lib/tag-colors.ts` — unique per tag site-wide, assigned greedily over every tag with a page so tags that share problem pages sit furthest apart in OKLab, in a lightness band that keeps ≥ 3:1 contrast in both themes (Blume's Badge takes one hex for both). The reverse direction is the `PageFooter` slot (`components/BacklinksList.astro`): an Obsidian-style `<num> <name>` list under every topic landing and guide page of the problems whose badges link there, plus a topic's own folder members (so a topic no tag names, `hybrid-structures`, still lists its problems), in a native `<details>` open on load whose top-right chevron collapses it (no JS); problem pages are recognised by their `<num>. <name>` title and render no list. Which tags the campaign teaches is ONE map, `apps/docs/topic-landings.ts` (tag slug → landing routes), and where a badge points is ONE function beside it, `tagTarget()` — read by both components (so badges and backlinks cannot disagree) and the map by sync. `content/appendix/` (the sidebar's `Appendix` row, right under Progress: `order: 0` ahead of the phase groups, `display: "page"` so it drills into its own panel — no header tab) is one page per tag outside that map, filed by hand into `(group)` categories — `(1-data-structures)/`, `(2-algorithms)/`, `(3-techniques)/` (hand-written `meta.ts` each; no route segment, so every guide stays `/appendix/<slug>`). Sync finds a guide by slug anywhere under `appendix/` and creates a placeholder only when none exists or it is empty — at the `appendix/` root, for you to file into a category and give a Lucide `icon:` — the one every badge linking to it shows — that no topic and no other guide uses (sync warns while it is missing, a `TOPIC_ICONS` value, or another guide's) — and after that owns exactly one key of it: `sidebar.badge`, the sidebar count, rewritten every run to the guide's backlink count (problem pages carrying the tag — equal to the `BacklinksList` rows, since a guide's tag has no landing; dropped at zero). Everything else is hand-owned (a guide whose tag no page carries any more is left alone, badge dropped), with frontmatter `list:` — the tag's LeetCode problem list, rendered by the same wrapper as an orange `LeetCode list` badge; sync also writes `appendix/meta.ts`. A hand-written `(0N-<phase>)/<topic>/index.mdx` (`hidden: true`) is that topic's landing page: Blume ≥ 1.7.3 links the sidebar group row to it (`sliding-window/index.mdx` → `/sliding-window`) and the hidden flag drops the duplicate row. Two kinds of custom MDX component, both import-free on every page: `islands/<Name>.tsx` is hydrated React (auto-discovered; the five `/progress` cards `SolvedRing`/`SubmissionHeatmap`/`SubmissionChart`/`TopicBubbles`/`RecentSolves`, and `DependencySpine` — the README topic map as data, zoom/pan, nodes link to whatever route Blume's `/api/docs/navigation.json` reports for the topic folder, inert until a topic has pages), `components/<Name>.astro` registered in `components.ts` is static markup plus a custom element for behaviour, the way Blume's own `Frame` is built (`Carousel`/`CarouselImage`: `src` takes a URL, `/file.png`, or the page-relative `../../public/file.png`) |
|
| `docs/` | The docs content, and nothing of the site: no `blume.config.ts`, no `meta.ts`, no components. prdlk/cs publishes it with Blume's `mdxRemote()` source, which fetches `.md`/`.mdx` files only. `docs/(0N-<phase>)/<topic>/(problems)/<num>_<slug>.mdx` is generated by sync; `(0N-<phase>)/<topic>/index.mdx` (`hidden: true`) is a topic's hand-written landing page, and `index.mdx`/`keywords.mdx`/`progress.mdx` sit at the root. The phase folder is a `(group)` (sidebar section, no route segment), the topic folder is the README campaign topic slug (`tree-dfs/`, `heap-priority-queue/`) and a real route segment, and `(problems)` is a second `(group)` inside it, so `tree-dfs/(problems)/226_invert-binary-tree.mdx` routes to `/tree-dfs/invert-binary-tree` (Blume sorts by the `<num>_` prefix and strips it from the route). `docs/site.json` is sync's generated manifest and the site's ONE other input: `folders` (path under `docs/` → `{ title, order, icon?, display? }`, exactly what a `meta.ts` in that folder would say — every topic `display: "page"`, so it is one sidebar row whose panel holds its landing page and a "Problems" section) and `redirects` (the `/<num>` short links). Images are root-absolute (``, `<CarouselImage src='/binary_search.001.png' />`) and served from the site's `public/` — a page-relative path cannot resolve once the MDX is fetched over HTTP. Frontmatter keys beyond Blume's (`topics:`, `video:`, `issue:`) and the MDX components pages use (`<CodeGroup>`, `<Carousel>`/`<CarouselImage>`, the islands) are the site's to declare — add a new one there before a page uses it |
|
||||||
| `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (the plan: `date → { topics | problems | review | gate | sweep }`, human-edited, bundled at deploy), `migrations/` (append-only; `0002` is the +3/+7 ladder rebuild) |
|
| `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (the plan: `date → { topics | problems | review | gate | sweep }`, human-edited, bundled at deploy), `migrations/` (append-only; `0002` is the +3/+7 ladder rebuild) |
|
||||||
| `.github/workflows/` | deploy (docs → Pages with `GITHUB_TOKEN` for the releases-backed changelog, Worker → Cloudflare with the `OPENROUTER_API_KEY` Actions secret put into it first, on every main push), close-solved (issues + `/admin/solved` + Project `Set`/`Difficulty`, needs `SRS_ADMIN_KEY` and `PROJECT_PAT`; then the `release` job: timever tag + release per finished topic, `contents: write`), close-topics, sync-d1 (issue edits → `/admin/reconcile`), spaced-repetition (daily 06:00 ET cron → today's review issue; `GITHUB_TOKEN` only, no secrets) |
|
| `.github/workflows/` | deploy (Worker → Cloudflare with the `OPENROUTER_API_KEY` Actions secret put into it first, on every main push; the docs site deploys from prdlk/cs), close-solved (issues + `/admin/solved` + Project `Set`/`Difficulty`, needs `SRS_ADMIN_KEY` and `PROJECT_PAT`; then the `release` job: timever tag + release per finished topic, `contents: write`), close-topics, sync-d1 (issue edits → `/admin/reconcile`), spaced-repetition (daily 06:00 ET cron → today's review issue; `GITHUB_TOKEN` only, no secrets) |
|
||||||
| `.githooks/` | Tracked git hooks; `bun install` points `core.hooksPath` here (root `prepare` script, no husky). `pre-commit` runs `bun run sync` and stages only what sync changed (pages, `apps/docs/redirects.ts`), so a solution, its page and its `/<num>` short link land in one commit; it refuses to run while `work/1` has anything unstaged (sync reads the tree, so the page would not match the commit), and leaves pre-existing dirt under `apps/docs/content` (in-progress `## Approach` prose) unstaged. `git commit --no-verify` for a deliberate partial commit |
|
| `.githooks/` | `bun install` points `core.hooksPath` here (root `prepare` script, no husky). Its `pre-commit` hook (run `bun run sync`, stage only what sync changed — pages and `docs/site.json`) was removed in `e63d517`, so nothing runs on commit today: run `bun run sync` yourself before committing a solution, or its page and short link ship a commit later |
|
||||||
|
|
||||||
## Development Commands
|
## Development Commands
|
||||||
|
|
||||||
@@ -71,12 +72,11 @@ bun run test # run ONE solution against LeetCode's judge (not a test
|
|||||||
# most recently tested first, stamped in the db on exit 0)
|
# most recently tested first, stamped in the db on exit 0)
|
||||||
bun run submit # submit ONE solution to LeetCode (newest scaffold first, nothing
|
bun run submit # submit ONE solution to LeetCode (newest scaffold first, nothing
|
||||||
# hidden; last_submitted stamped in the db on exit 0)
|
# hidden; last_submitted stamped in the db on exit 0)
|
||||||
bun run sync # work/1 → apps/docs/content/ pages + apps/docs/redirects.ts (/<num> short
|
bun run sync # work/1 → docs/ pages + docs/site.json (folder meta + /<num> short
|
||||||
# links; re-solves are never published)
|
# links; re-solves are never published)
|
||||||
bun run videos # docs pages with no `video:` frontmatter → NeetCode video per page
|
bun run videos # docs pages with no `video:` frontmatter → NeetCode video per page
|
||||||
# (channel search scraped, no API key; --write adds the
|
# (channel search scraped, no API key; --write adds the
|
||||||
# `video:` key; "no NeetCode match" pages stay untouched)
|
# `video:` key; "no NeetCode match" pages stay untouched)
|
||||||
bun run dev|build # Blume docs site (runs in apps/docs/)
|
|
||||||
bun run close-solved -- --dry-run # issue reconcilers (also DRY_RUN=1)
|
bun run close-solved -- --dry-run # issue reconcilers (also DRY_RUN=1)
|
||||||
bun run close-topics -- --dry-run
|
bun run close-topics -- --dry-run
|
||||||
bun run release-topics -- --dry-run # prints every would-be topic release body
|
bun run release-topics -- --dry-run # prints every would-be topic release body
|
||||||
@@ -113,21 +113,20 @@ curl -X POST -H "Authorization: Bearer $LINK_KEY" \
|
|||||||
- `apps/api/src/ask.ts` — `POST /ask`, the docs assistant's backend. Blume's own assistant route is server-rendered and cannot ship in the static Pages build (`BLUME_SERVER_FEATURE_REQUIRED`), so `blume.config.ts` sets `ai.assistant.endpoint` to this route instead of a `provider`. It speaks the Blume island's wire contract (`{ messages, page: { path } }` in, a plain-text stream out, a 503 "The assistant is not configured…" when the key is missing), validates like Blume's generated route (1–40 user/assistant messages, 24 000 chars, 64 KB — no caller system prompt), grounds on the published `llms-full.txt` (current page first, then keyword-ranked excerpts in Blume's 6 × 2 000 / 10 000-char budget, cached 5 min), and streams OpenRouter's chat completions over plain `fetch` — no AI SDK — for the `ASK_MODEL` var. `:free` models share an upstream pool and often answer 429, which the island shows as its rate-limit notice.
|
- `apps/api/src/ask.ts` — `POST /ask`, the docs assistant's backend. Blume's own assistant route is server-rendered and cannot ship in the static Pages build (`BLUME_SERVER_FEATURE_REQUIRED`), so `blume.config.ts` sets `ai.assistant.endpoint` to this route instead of a `provider`. It speaks the Blume island's wire contract (`{ messages, page: { path } }` in, a plain-text stream out, a 503 "The assistant is not configured…" when the key is missing), validates like Blume's generated route (1–40 user/assistant messages, 24 000 chars, 64 KB — no caller system prompt), grounds on the published `llms-full.txt` (current page first, then keyword-ranked excerpts in Blume's 6 × 2 000 / 10 000-char budget, cached 5 min), and streams OpenRouter's chat completions over plain `fetch` — no AI SDK — for the `ASK_MODEL` var. `:free` models share an upstream pool and often answer 429, which the island shows as its rate-limit notice.
|
||||||
- `apps/api/src/digest.ts` / `apps/api/src/email.tsx` — the daily digest, split data/presentation. `digest.ts` reads D1 into a `DigestData`; `email.tsx` owns every colour and every sentence, and renders both the HTML and (via its own `plainDigest`, not React Email's `plainText` mode, which flattens the tables) the text alternative. The retrieval rules hold by construction: `DigestRow` has no title and no issue field, so a review or drill line *cannot* leak the topic or a solution link. Subject is `(Day N/<CAMPAIGN_DAYS>) LeetCode Daily Digest` — 79 with the plan ending Nov 3; booking a later day in `schedule.json` lengthens it, and the heatmaps and `/api/stats` `heat` with it.
|
- `apps/api/src/digest.ts` / `apps/api/src/email.tsx` — the daily digest, split data/presentation. `digest.ts` reads D1 into a `DigestData`; `email.tsx` owns every colour and every sentence, and renders both the HTML and (via its own `plainDigest`, not React Email's `plainText` mode, which flattens the tables) the text alternative. The retrieval rules hold by construction: `DigestRow` has no title and no issue field, so a review or drill line *cannot* leak the topic or a solution link. Subject is `(Day N/<CAMPAIGN_DAYS>) LeetCode Daily Digest` — 79 with the plan ending Nov 3; booking a later day in `schedule.json` lengthens it, and the heatmaps and `/api/stats` `heat` with it.
|
||||||
- `apps/api/src/png.ts` — hand-rolled PNG encoder (RGB8, one IDAT, zlib via `CompressionStream("deflate")`, CRC32, 5×7 bitmap font). It exists because every major email client refuses remote SVG, so `/chart/heatmap.png` rasters the heatmap for the digest while `/chart/heatmap.svg` keeps serving the README byte-for-byte. Both come from one `heatmapCells()` so the two pictures cannot drift.
|
- `apps/api/src/png.ts` — hand-rolled PNG encoder (RGB8, one IDAT, zlib via `CompressionStream("deflate")`, CRC32, 5×7 bitmap font). It exists because every major email client refuses remote SVG, so `/chart/heatmap.png` rasters the heatmap for the digest while `/chart/heatmap.svg` keeps serving the README byte-for-byte. Both come from one `heatmapCells()` so the two pictures cannot drift.
|
||||||
- `apps/cli/sync.ts` — work→docs contract: on existing pages only the frontmatter `icon:` and the `## Solution` section are regenerated, and `topics:` (LeetCode's `topicTags` as `{ name, slug }`, fetched by GraphQL) and `issue:` (the URL of the problem's `problem`-labelled GitHub issue, `LC <num> · …`, listed via `github()` — rendered as the neutral `Issue #N` badge after `Explanation`) are filled once — on create, or on any run that finds them missing — never refreshed (delete a key to refetch; offline or without `gh` auth, the page is written without it and the next sync retries; a sync with nothing missing makes no request). Only a problem the README plan lists gets `issue:` — an unplanned warm-up has no catalog issue, so it is never looked up. `## Solution` is the latter bounded by `solutionEnd()` at the next unfenced top-level heading; human prose before it and any sections after it are never touched. Never hand-write solution pages or edit inside `## Solution`. Two classifications, two homes: the sidebar group is the README topic (`readmeTopics()`, first listing wins for a problem under two topics; a warm-up outside the plan groups under its category at `order: 0`, ahead of topic 1) and the `work/1/<Difficulty>/<Category>/` category, which only places an unplanned problem — the page's topic badges are LeetCode's tags, not either classification. Topics nest under their README phase (`## Phase N — Name` headings, shortened via `PHASE_ALIASES`): `content/(0N-<phase>)/<topic>/`. The topic `meta.ts` is script-owned and rewritten every run (label, curriculum order, `TOPIC_ICONS` icon, `display: "group"`); the phase `meta.ts` is written once when missing (`Phase N: Name`, `order: N`) and hand-owned after — rename its title freely. Never rewriting it loses nothing: its order and name are also the folder name, so a README change to either lands in a new folder with a fresh meta. The phase folder is a Blume `(group)` — no route segment, so `/tree-dfs` is unchanged and a landing page there is one level deep, never at `/` — and the topic folder is the real segment; never put a topic `index.mdx` directly inside a `(group)`. A page is `rename`d when its topic or phase changes; a folder every page has left is deleted (climbing into an emptied phase folder) unless it still holds a hand-written `index.mdx` (sync never writes, moves, or deletes one — so page-relative asset paths in a moved landing page are yours to fix). Slugs come from `slugify()`, which glues a leading number to the next token (`1d-dynamic-programming`) because Blume strips `<digits>-` prefixes from routes. The hand-owned parts have their own owners: `## Approach` (a Mermaid flowchart tracing the page's code, placed *before* `## Solution` so a re-sync keeps it) is written per the `problem-approach` skill (`/problem-approach` fills every page missing one); the frontmatter `video:` (the NeetCode walkthrough, rendered as the red `Explanation` badge) is added by `bun run videos --write` (`apps/cli/videos.ts`).
|
- `apps/cli/sync.ts` — work→docs contract: on existing pages only the frontmatter `icon:` and the `## Solution` section are regenerated, and `topics:` (LeetCode's `topicTags` as `{ name, slug }`, fetched by GraphQL) and `issue:` (the URL of the problem's `problem`-labelled GitHub issue, `LC <num> · …`, listed via `github()` — rendered as the neutral `Issue #N` badge after `Explanation`) are filled once — on create, or on any run that finds them missing — never refreshed (delete a key to refetch; offline or without `gh` auth, the page is written without it and the next sync retries; a sync with nothing missing makes no request). Only a problem the README plan lists gets `issue:` — an unplanned warm-up has no catalog issue, so it is never looked up. `## Solution` is the latter bounded by `solutionEnd()` at the next unfenced top-level heading; human prose before it and any sections after it are never touched. Never hand-write solution pages or edit inside `## Solution`. Two classifications, two homes: the sidebar group is the README topic (`readmeTopics()`, first listing wins for a problem under two topics; a warm-up outside the plan groups under its category at `order: 0`, ahead of topic 1) and the `work/1/<Difficulty>/<Category>/` category, which only places an unplanned problem — the page's topic badges are LeetCode's tags, not either classification. Topics nest under their README phase (`## Phase N — Name` headings, shortened via `PHASE_ALIASES`): `docs/(0N-<phase>)/<topic>/`, with every problem page in the topic's `(problems)/` group as `<num>_<slug>.mdx` (a page is found by its frontmatter title, wherever it sits, so a layout change is one sync of `rename`s). `docs/site.json` is wholly script-owned and rewritten whenever it differs, because `mdxRemote()` reads no `meta.ts`: its `folders` are what a `meta.ts` per phase and topic folder would carry — a phase `N. Name` at `order: N`, a topic its exact label, curriculum order, `TOPIC_ICONS` icon and `display: "page"` — all derived from the README, so a rename there is a rename on the site; its `redirects` are the `/<num>` → `/<topic>/<slug>` short links. Which tags the site teaches as topics (`topic-landings.ts`) and its Appendix glossary (`appendix.ts`) are the site's, in prdlk/cs: sync no longer checks them. The phase folder is a Blume `(group)` — no route segment, so `/tree-dfs` is unchanged and a landing page there is one level deep, never at `/` — and the topic folder is the real segment; never put a topic `index.mdx` directly inside a `(group)`. A page is `rename`d when its topic or phase changes; a folder every page has left is deleted (climbing through an emptied `(problems)` and topic into an emptied phase folder) unless it still holds a hand-written `index.mdx` (sync never writes, moves, or deletes one). Slugs come from `slugify()`, which glues a leading number to the next token (`1d-dynamic-programming`) because Blume strips `<digits>-` prefixes from routes. The hand-owned parts have their own owners: `## Approach` (a Mermaid flowchart tracing the page's code, placed *before* `## Solution` so a re-sync keeps it) is written per the `problem-approach` skill (`/problem-approach` fills every page missing one); the frontmatter `video:` (the NeetCode walkthrough, rendered as the red `Explanation` badge) is added by `bun run videos --write` (`apps/cli/videos.ts`).
|
||||||
- `apps/cli/work.ts` — the `work/` layout: bucket paths, `isSolution()`/`solvedInBucket()` (the stub test, shared with `close-solved.ts`), `firstSolved()` (one `git log`, ET dates, keyed by LC number so past layout moves don't matter), and `reviewQueues()` (what each window still owes). Everything that needs to know where a solution file lives, or when it was solved, goes through here.
|
- `apps/cli/work.ts` — the `work/` layout: bucket paths, `isSolution()`/`solvedInBucket()` (the stub test, shared with `close-solved.ts`), `firstSolved()` (one `git log`, ET dates, keyed by LC number so past layout moves don't matter), and `reviewQueues()` (what each window still owes). Everything that needs to know where a solution file lives, or when it was solved, goes through here.
|
||||||
- `apps/api/src/stats.ts` / `apps/cli/stats.ts` / `apps/cli/tui.ts` / `apps/cli/api.ts` / `apps/docs/lib/stats.ts` — the read side. `buildStats()` is the ONE aggregation: phases, ladder, difficulty, `attempting` (open misses, via `MISS_WHERE` in `srs.ts` — the sweep day's definition), per-topic temperature, gates, the 14-day due queue, `heat` (every campaign day, zeros and future days included — the TUI windows its tail rather than asking a second question), `recent` (the ten problems most recently solved, dated by each one's first pass) and `year` (the 365 days ending today — always the calendar year to date — each split by difficulty into attempts and first solves; the docs cards window and bucket it themselves). Two readers consume it, the docs `/progress` islands and `bun run stats`, so its shape is their contract: change the Worker and both readers together (additive fields a reader ignores are safe). `tui.ts` is hand-rolled ANSI (bars, sparklines, heat cells, alternate screen) on charts.ts's palette, and every chart differs by GLYPH as well as by colour so a piped or `NO_COLOR` dashboard still reads. The TUI recomputes nothing: the only local fact it shows is the `work/` solution count, which is inventory, not schedule.
|
- `apps/api/src/stats.ts` / `apps/cli/stats.ts` / `apps/cli/tui.ts` / `apps/cli/api.ts` — the read side. `buildStats()` is the ONE aggregation: phases, ladder, difficulty, `attempting` (open misses, via `MISS_WHERE` in `srs.ts` — the sweep day's definition), per-topic temperature, gates, the 14-day due queue, `heat` (every campaign day, zeros and future days included — the TUI windows its tail rather than asking a second question), `recent` (the ten problems most recently solved, dated by each one's first pass) and `year` (the 365 days ending today — always the calendar year to date — each split by difficulty into attempts and first solves; the docs cards window and bucket it themselves). Two readers consume it, the docs site's `/progress` islands (prdlk/cs `lib/stats.ts`) and `bun run stats`, so its shape is a cross-repo contract: change the Worker and both readers together (additive fields a reader ignores are safe). `tui.ts` is hand-rolled ANSI (bars, sparklines, heat cells, alternate screen) on charts.ts's palette, and every chart differs by GLYPH as well as by colour so a piped or `NO_COLOR` dashboard still reads. The TUI recomputes nothing: the only local fact it shows is the `work/` solution count, which is inventory, not schedule.
|
||||||
- `README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache). The docs `/progress` page is five LeetCode-style islands — `SolvedRing` (difficulty gauge + "Attempting"), `SubmissionHeatmap` and `SubmissionChart` sharing one row (the heatmap: the last six months in month blocks, the README's colour ramp as theme.css `--heat-0..4`, its attempts / active days / max streak counted from the cells it draws; the chart: this calendar year as stacked Easy/Med./Hard bars per month or week, first solves or every attempt, via two shadcn `ToggleGroup`s), `TopicBubbles` (the campaign topics circle-packed by `d3-hierarchy`, filled by solved share), `RecentSolves` (the ten most recent, linked through `redirects.ts`) — composed from shadcn/ui (`components/ui/{card,chart,skeleton,toggle,toggle-group}.tsx`, Recharts under `chart`, Radix under the toggles) on Blume's tokens: `apps/docs/theme.css` maps shadcn's names onto `--blume-*` (never `accent`, which is Blume's brand colour — vendored components that use it, like `toggle.tsx`, are retokened to `background`/`muted`) and gives shadcn's `data-slot` parts their border colour. They share `lib/stats.ts` (`useStats()`), so the page makes ONE `/api/stats` request however many islands hydrate. shadcn sources live outside `islands/` (every `.tsx` there becomes an island) and import `lib/utils` relatively — Blume's Vite has no `@/` alias; `components.json` + `tsconfig.json` exist for the shadcn CLI and `tsc`, so after `shadcn add` rewrite `@/…` imports to relative ones — and check `package.json`: the CLI has twice installed an unrelated `cn` package for `lib/utils` and skipped real deps (`class-variance-authority`). Blume's `.prose :where(a, p, ol)` rules ignore `not-prose`, so island markup overrides them with `!` utilities, as Blume's own components do.
|
- `README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache); the campaign table doubles as topic-map input to `sync.ts` (`readmeTopics()`, `TOPIC_ALIASES`, `PHASE_ALIASES`). `/api/stats` and `/ask` reflect a requesting origin only when it is in the `DOCS_ORIGIN` var (`apps/api/wrangler.jsonc`, comma-separated: `https://lc.prd.lk,https://prdlk.github.io`) — a new host for the docs site must be added there.
|
||||||
- `apps/docs/blume.config.ts` — site base `/leetcode`; imports `apps/docs/redirects.ts`, sync-generated (`/<num>` → `/<topic>/<slug>`, one row per page, exact root-relative paths — Blume adds the base and, for the static Pages build, emits a meta-refresh page per rule plus `blume-redirects.json`); `README.md` campaign table doubles as topic-map input to `sync.ts` (`readmeTopics()`, `TOPIC_ALIASES`, `PHASE_ALIASES`).
|
|
||||||
- Old `.github/srs/` JSON state, its `srs-*` Actions, and the one-shot `import-srs.ts` importer are RETIRED and deleted — do not resurrect; git history is the record.
|
- Old `.github/srs/` JSON state, its `srs-*` Actions, and the one-shot `import-srs.ts` importer are RETIRED and deleted — do not resurrect; git history is the record.
|
||||||
|
|
||||||
## Runtime/Tooling Preferences
|
## Runtime/Tooling Preferences
|
||||||
|
|
||||||
- **Bun only**: run scripts with `bun apps/cli/<name>.ts` (or the root `bun run` aliases), install with `bun install --frozen-lockfile`. One workspace (`apps/*`), one root `bun.lock`. Node 22 appears only in `deploy.yml` because Blume requires it.
|
- **Bun only**: run scripts with `bun apps/cli/<name>.ts` (or the root `bun run` aliases), install with `bun install --frozen-lockfile`. One workspace (`apps/*`), one root `bun.lock`.
|
||||||
- **Near-zero runtime npm dependencies** — SVG, PNG, terminal ANSI, HMAC (WebCrypto), GraphQL are hand-rolled; Actions scripts use Bun builtins + `fetch` only. Two exceptions, each confined to one surface. The digest email: `apps/api/src/email.tsx` renders React Email (`react`, `react-dom`, `@react-email/components`, `@react-email/render`) inside the Worker, because hand-rolling table-based email HTML that survives Gmail *and* Outlook is not worth owning. It costs ~235 KB gzipped of a 3 MB budget, needs no `nodejs_compat` (it resolves to `renderToReadableStream`), and requires `"jsx": "react-jsx"` in `apps/api/tsconfig.json`. And the docs `/progress` islands: shadcn/ui (`recharts`, `radix-ui`, `class-variance-authority`, `clsx`, `tailwind-merge`) plus `d3-hierarchy` for the bubble packing, client-side in `apps/docs` only. Do not extend either exception to any other module.
|
- **Near-zero runtime npm dependencies** — SVG, PNG, terminal ANSI, HMAC (WebCrypto), GraphQL are hand-rolled; Actions scripts use Bun builtins + `fetch` only. One exception, confined to one surface: the digest email, `apps/api/src/email.tsx`, renders React Email (`react`, `react-dom`, `@react-email/components`, `@react-email/render`) inside the Worker, because hand-rolling table-based email HTML that survives Gmail *and* Outlook is not worth owning. It costs ~235 KB gzipped of a 3 MB budget, needs no `nodejs_compat` (it resolves to `renderToReadableStream`), and requires `"jsx": "react-jsx"` in `apps/api/tsconfig.json`. Do not extend it to any other module. (The docs site's own dependencies are prdlk/cs's.)
|
||||||
- Local GitHub auth falls back to `gh auth token`; scripts run fine outside Actions.
|
- Local GitHub auth falls back to `gh auth token`; scripts run fine outside Actions.
|
||||||
|
|
||||||
## Testing & QA
|
## Testing & QA
|
||||||
|
|
||||||
- Repo-wide: no test framework — `bun run test` submits one solution to LeetCode's judge. Exception: `apps/api/src/srs.test.ts` (`bun:test`) proves the DST cron guards with fixed dates; run via `bun run api:test`.
|
- Repo-wide: no test framework — `bun run test` submits one solution to LeetCode's judge. Exception: `apps/api/src/srs.test.ts` (`bun:test`) proves the DST cron guards with fixed dates; run via `bun run api:test`.
|
||||||
- QA is dry-runs + local state: Worker logic against `wrangler dev` + local D1 with `?dry=1&date=`; reconcilers with `--dry-run`; idempotency check = run twice, `cmp` output.
|
- QA is dry-runs + local state: Worker logic against `wrangler dev` + local D1 with `?dry=1&date=`; reconcilers with `--dry-run`; idempotency check = run twice, `cmp` output.
|
||||||
- Docs changes: `bunx blume build --isolated` (from `apps/docs/`) must pass with no new warnings (then `rm -rf apps/docs/.blume-verify`); after sync, review only *created* pages (prose transforms are heuristic).
|
- Docs changes: the site builds in prdlk/cs, which must pass `bunx blume build --isolated` with no new warnings against this repo's `docs/`; after sync, review only *created* pages (prose transforms are heuristic) and `docs/site.json`'s diff.
|
||||||
@@ -21,14 +21,14 @@
|
|||||||
* tagged commit — the last main commit before the finish. Problems still open
|
* tagged commit — the last main commit before the finish. Problems still open
|
||||||
* at the finish are left out: the release records what finished, and an open
|
* at the finish are left out: the release records what finished, and an open
|
||||||
* optional problem is tracked by its issue (or its Review day), not here.
|
* optional problem is tracked by its issue (or its Review day), not here.
|
||||||
* apps/docs/lib/releases.ts reads these `### Closed …` tables back for the
|
* The docs site (prdlk/cs `lib/releases.ts`) reads these `### Closed …`
|
||||||
* docs `/changelog` timeline, so their row shape is its contract.
|
* tables back for its `/changelog` timeline, so their row shape is its contract.
|
||||||
*
|
*
|
||||||
* The tag is annotated and backdated (tagger date = finish), so
|
* The tag is annotated and backdated (tagger date = finish), so
|
||||||
* `git tag --sort=taggerdate` reads as the campaign's history even for tags
|
* `git tag --sort=taggerdate` reads as the campaign's history even for tags
|
||||||
* minted by a backfill. The release's own published date cannot be backdated
|
* minted by a backfill. The release's own published date cannot be backdated
|
||||||
* (the API has no field for it), so the docs changelog dates each entry from
|
* (the API has no field for it), so the docs changelog dates each entry from
|
||||||
* this timever tag instead (apps/docs/lib/releases.ts)
|
* this timever tag instead (prdlk/cs `lib/releases.ts`)
|
||||||
* — the tag format is that contract.
|
* — the tag format is that contract.
|
||||||
*
|
*
|
||||||
* Reconciles instead of reacting: every finished topic or review without a
|
* Reconciles instead of reacting: every finished topic or review without a
|
||||||
@@ -59,7 +59,7 @@ const DRY =
|
|||||||
process.env.DRY_RUN === "1" ||
|
process.env.DRY_RUN === "1" ||
|
||||||
process.env.DRY_RUN === "true";
|
process.env.DRY_RUN === "true";
|
||||||
|
|
||||||
/** The docs site (apps/docs/blume.config.ts `deployment.site`); sync publishes `/<num>` short links. */
|
/** The docs site (prdlk/cs `blume.config.ts` `deployment.site`); it publishes sync's `/<num>` short links. */
|
||||||
const DOCS_SITE = "https://lc.prd.lk";
|
const DOCS_SITE = "https://lc.prd.lk";
|
||||||
const TIMEVER = "https://gist.github.com/twolfson/de1b004dd22536b8e668";
|
const TIMEVER = "https://gist.github.com/twolfson/de1b004dd22536b8e668";
|
||||||
|
|
||||||
|
|||||||
+84
-218
@@ -1,6 +1,13 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
/**
|
/**
|
||||||
* Sync work/1 leetcode solutions into apps/docs/content/ pages.
|
* Sync work/1 leetcode solutions into docs/ pages, plus docs/site.json.
|
||||||
|
*
|
||||||
|
* docs/ is content only — MDX pages and one generated JSON manifest, no Blume
|
||||||
|
* config. The docs site itself (config, components, islands, the Appendix)
|
||||||
|
* lives in prdlk/cs and reads docs/ over HTTP with Blume's `mdxRemote()`
|
||||||
|
* source, which fetches `.md`/`.mdx` and nothing else: no `meta.ts`. So the
|
||||||
|
* sidebar meta a folder `meta.ts` used to carry, and the `/<num>` short links,
|
||||||
|
* travel in `docs/site.json` instead, which the site fetches at the same ref.
|
||||||
*
|
*
|
||||||
* - New problems get a full page (frontmatter, warning, examples,
|
* - New problems get a full page (frontmatter, warning, examples,
|
||||||
* constraints, solution) in the gold-standard format.
|
* constraints, solution) in the gold-standard format.
|
||||||
@@ -15,9 +22,9 @@
|
|||||||
* `topicTags` once — when the page is created, or on any later run that
|
* `topicTags` once — when the page is created, or on any later run that
|
||||||
* finds the key missing — and never refreshed after, so a sync with nothing
|
* finds the key missing — and never refreshed after, so a sync with nothing
|
||||||
* new makes no request. Delete the key to refetch it. The page itself
|
* new makes no request. Delete the key to refetch it. The page itself
|
||||||
* renders no badges: the docs MDX `wrapper`
|
* renders no badges: the site's MDX `wrapper` (prdlk/cs
|
||||||
* (`apps/docs/components/ProblemBadges.astro`) turns `topics:` and the
|
* `components/ProblemBadges.astro`) turns `topics:` and the `video:` key
|
||||||
* `video:` key `videos.ts` writes into the badge row under the description.
|
* `videos.ts` writes into the badge row under the description.
|
||||||
* - Every planned page (a problem the README plan lists) carries its GitHub
|
* - Every planned page (a problem the README plan lists) carries its GitHub
|
||||||
* problem issue as frontmatter `issue:` (the URL of the `problem`-labelled
|
* problem issue as frontmatter `issue:` (the URL of the `problem`-labelled
|
||||||
* issue titled `LC <num> · …`), rendered by the same wrapper as a GitHub
|
* issue titled `LC <num> · …`), rendered by the same wrapper as a GitHub
|
||||||
@@ -27,54 +34,48 @@
|
|||||||
* (never on a sync with nothing new); offline, or with no `gh` auth, the
|
* (never on a sync with nothing new); offline, or with no `gh` auth, the
|
||||||
* page is written without it and the next sync retries. Delete the key to
|
* page is written without it and the next sync retries. Delete the key to
|
||||||
* refetch it.
|
* refetch it.
|
||||||
* - Every tag a page carries that no campaign topic teaches (not a key of
|
|
||||||
* `apps/docs/topic-landings.ts`) gets a placeholder guide page in the
|
|
||||||
* Appendix, `appendix/<slug>.mdx`, with `list:` pointing at LeetCode's
|
|
||||||
* problem list for the tag. Created once (a missing or empty file), then
|
|
||||||
* hand-owned except for `sidebar.badge`, which sync keeps at the guide's
|
|
||||||
* backlink count (the problem pages carrying the tag; dropped at zero); the
|
|
||||||
* tag's badge on a problem page links to it. A guide may be filed into a
|
|
||||||
* category `(group)` folder (e.g. `appendix/(2-algorithms)/`) — no route
|
|
||||||
* segment, so `/appendix/<slug>` holds — and is found there by slug; a new
|
|
||||||
* placeholder lands at the `appendix/` root for a human to file (and give
|
|
||||||
* an `icon:`, which every badge linking to the guide shows; sync warns
|
|
||||||
* while one is missing or shared).
|
|
||||||
*
|
*
|
||||||
* The sidebar group is the campaign TOPIC from the README table (`tree-dfs/`,
|
* The sidebar group is the campaign TOPIC from the README table (`tree-dfs/`).
|
||||||
* ordered by topic number via a script-owned `meta.ts`). A problem outside the
|
* A problem outside the plan (warm-ups) has no topic, so it groups under the
|
||||||
* plan (warm-ups) has no topic, so it groups under the data-structure CATEGORY
|
* data-structure CATEGORY its file sits under in
|
||||||
* its file sits under in `work/1/<Difficulty>/<Category>/` instead.
|
* `work/1/<Difficulty>/<Category>/` instead.
|
||||||
*
|
*
|
||||||
* Topics nest under their README PHASE: `(01-linear-structures)/tree-dfs/`.
|
* Topics nest under their README PHASE: `(01-linear-structures)/tree-dfs/`.
|
||||||
* The phase folder is a Blume `(group)` — a sidebar section adding no route
|
* The phase folder is a Blume `(group)` — a sidebar section adding no route
|
||||||
* segment — while the topic folder is a real segment
|
* segment — while the topic folder is a real segment
|
||||||
* (`/tree-dfs/invert-binary-tree`), so a hand-written `index.mdx` in it is the
|
* (`/tree-dfs/invert-binary-tree`), so a hand-written `index.mdx` in it is the
|
||||||
* topic's landing page at `/tree-dfs` and Blume links the sidebar group row to
|
* topic's landing page at `/tree-dfs` and Blume links the sidebar group row to
|
||||||
* it. Sync never writes or moves an `index.mdx`, and never removes a folder
|
* it. The topic's problem pages sit one level down in a `(problems)` group,
|
||||||
* that still holds one. A phase folder's `meta.ts` is written once, when
|
* `tree-dfs/(problems)/226_invert-binary-tree.mdx`: no route segment either,
|
||||||
* missing, then hand-owned (its title is yours to rename); a topic's is
|
* and Blume strips the `<num>_` prefix from the route while sorting by it, so
|
||||||
* script-owned. An unplanned category group has no phase and sits at the
|
* the URL is still `/tree-dfs/invert-binary-tree`. Sync never writes or moves
|
||||||
* content root.
|
* an `index.mdx`, and never removes a folder that still holds one. An
|
||||||
|
* unplanned category group has no phase and sits at the docs/ root.
|
||||||
|
*
|
||||||
|
* `docs/site.json` is wholly script-owned and rewritten whenever it differs:
|
||||||
|
* - `folders`: Blume folder meta keyed by folder path under docs/ — exactly
|
||||||
|
* the key a `meta.ts` in that folder would have. A phase is `N. Name` in
|
||||||
|
* phase order; a topic is its exact name (the folder slug loses `/` and
|
||||||
|
* case), curriculum order, `TOPIC_ICONS` icon and `display: "page"`, so it
|
||||||
|
* is one sidebar row drilling into its own panel. `(problems)` has none:
|
||||||
|
* Blume's humanized "Problems" is the label.
|
||||||
|
* - `redirects`: one `{ from: "/<num>", to: "/<topic>/<slug>" }` per page, in
|
||||||
|
* number order — exact root-relative paths; Blume prepends the deployment
|
||||||
|
* base itself.
|
||||||
*
|
*
|
||||||
* Only the first-solve bucket is read: work/3 and work/7 hold blind re-solves
|
* Only the first-solve bucket is read: work/3 and work/7 hold blind re-solves
|
||||||
* of problems whose page already exists (see work.ts), and a fresh scaffold
|
* of problems whose page already exists (see work.ts), and a fresh scaffold
|
||||||
* there would otherwise wipe the page's `## Solution`.
|
* there would otherwise wipe the page's `## Solution`.
|
||||||
*
|
|
||||||
* Every page also gets a short link: `/<num>` → `/<topic>/<slug>` (so
|
|
||||||
* `/leetcode/217` opens Contains Duplicate). The rules live in the generated
|
|
||||||
* `apps/docs/redirects.ts`, which `blume.config.ts` imports — Blume takes exact
|
|
||||||
* root-relative paths and prepends the deployment base itself.
|
|
||||||
*/
|
*/
|
||||||
import { mkdir, readdir, rename, rmdir, unlink } from "node:fs/promises";
|
import { mkdir, readdir, rename, rmdir } from "node:fs/promises";
|
||||||
import { basename, dirname, join } from "node:path";
|
import { basename, dirname, join } from "node:path";
|
||||||
|
|
||||||
import { TOPIC_LANDINGS } from "../docs/topic-landings.ts";
|
|
||||||
import { github } from "./github.ts";
|
import { github } from "./github.ts";
|
||||||
|
|
||||||
const ROOT = join(import.meta.dir, "..", "..");
|
const ROOT = join(import.meta.dir, "..", "..");
|
||||||
const WORK = join(ROOT, "work", "1");
|
const WORK = join(ROOT, "work", "1");
|
||||||
const DOCS = join(ROOT, "apps", "docs", "content");
|
const DOCS = join(ROOT, "docs");
|
||||||
const REDIRECTS = join(ROOT, "apps", "docs", "redirects.ts");
|
const MANIFEST = join(DOCS, "site.json");
|
||||||
|
|
||||||
type Lang = "js" | "py";
|
type Lang = "js" | "py";
|
||||||
|
|
||||||
@@ -328,31 +329,34 @@ function phaseDir(phase: Phase): string {
|
|||||||
return `(${String(phase.order).padStart(2, "0")}-${slugify(phase.name)})`;
|
return `(${String(phase.order).padStart(2, "0")}-${slugify(phase.name)})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** A sidebar group's Blume folder meta, as `docs/site.json` carries it. */
|
||||||
* Blume `(group)` meta for a phase: the overview page's heading, in phase
|
interface FolderMeta {
|
||||||
* order. Written only when the folder has none — the title is hand-owned after
|
title: string;
|
||||||
* that. Nothing is lost by never rewriting it: the order and name it is built
|
order: number;
|
||||||
* from are also the folder name (`phaseDir`), so a README change to either
|
icon?: string;
|
||||||
* lands in a new folder that gets a fresh meta.
|
display?: "page";
|
||||||
*/
|
|
||||||
function phaseMeta(phase: Phase): string {
|
|
||||||
return `import { defineMeta } from "blume";\n\nexport default defineMeta({ title: ${JSON.stringify(`Phase ${phase.order}: ${phase.name}`)}, order: ${phase.order} });\n`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Blume folder meta for a topic: exact label (the folder slug loses `/` and
|
* Folder meta for a phase `(group)`: the overview page's heading, `N. Name`,
|
||||||
* case), curriculum order, the sidebar icon, and `display: "group"` so the
|
* in phase order. Derived like everything in the manifest, so a README rename
|
||||||
* topic collapses under its phase heading. An unplanned category group
|
* renames the heading — and lands in a new folder (`phaseDir`) anyway.
|
||||||
* (warm-ups) has no README number and sorts at 0, ahead of phase 1, so the
|
|
||||||
* sidebar opens on the basics.
|
|
||||||
*/
|
*/
|
||||||
function groupMeta(topic: Topic): string {
|
function phaseMeta(phase: Phase): FolderMeta {
|
||||||
|
return { title: `${phase.order}. ${phase.name}`, order: phase.order };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Folder meta for a topic: exact label, curriculum order, the sidebar icon,
|
||||||
|
* and `display: "page"` so the topic is one row under its phase heading that
|
||||||
|
* drills into its own panel (the landing page, then its `(problems)` list).
|
||||||
|
* An unplanned category group (warm-ups) has no README number and sorts at 0,
|
||||||
|
* ahead of phase 1, so the sidebar opens on the basics.
|
||||||
|
*/
|
||||||
|
function groupMeta(topic: Topic): FolderMeta {
|
||||||
const icon = TOPIC_ICONS[topic.name];
|
const icon = TOPIC_ICONS[topic.name];
|
||||||
if (!icon) console.warn(`no icon for group ${JSON.stringify(topic.name)}: add it to TOPIC_ICONS`);
|
if (!icon) console.warn(`no icon for group ${JSON.stringify(topic.name)}: add it to TOPIC_ICONS`);
|
||||||
const fields = [`title: ${JSON.stringify(topic.name)}`, `order: ${topic.order ?? 0}`];
|
return { title: topic.name, order: topic.order ?? 0, ...(icon ? { icon } : {}), display: "page" };
|
||||||
if (icon) fields.push(`icon: ${JSON.stringify(icon)}`);
|
|
||||||
fields.push(`display: "group"`);
|
|
||||||
return `import { defineMeta } from "blume";\n\nexport default defineMeta({ ${fields.join(", ")} });\n`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── LeetCode topic tags ──────────────────────────────────────────
|
// ── LeetCode topic tags ──────────────────────────────────────────
|
||||||
@@ -399,14 +403,6 @@ function topicsYaml(tags: TopicTag[]): string {
|
|||||||
return ["topics:", ...tags.map((t) => ` - { name: ${JSON.stringify(t.name)}, slug: ${JSON.stringify(t.slug)} }`)].join("\n");
|
return ["topics:", ...tags.map((t) => ` - { name: ${JSON.stringify(t.name)}, slug: ${JSON.stringify(t.slug)} }`)].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The frontmatter's `topics:` as sync wrote them (or a human edited them); [] when absent. */
|
|
||||||
function frontmatterTopics(page: string): TopicTag[] {
|
|
||||||
const fm = page.match(/^---\n([\s\S]*?)\n---/)?.[1];
|
|
||||||
const data: unknown = fm ? Bun.YAML.parse(fm) : undefined;
|
|
||||||
if (!data || typeof data !== "object" || !("topics" in data) || !Array.isArray(data.topics)) return [];
|
|
||||||
return data.topics.filter((t): t is TopicTag => typeof t?.name === "string" && typeof t?.slug === "string");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── GitHub problem issues ────────────────────────────────────────
|
// ── GitHub problem issues ────────────────────────────────────────
|
||||||
|
|
||||||
let issueUrls: Promise<Map<number, string> | undefined> | undefined;
|
let issueUrls: Promise<Map<number, string> | undefined> | undefined;
|
||||||
@@ -452,56 +448,6 @@ async function issueUrl(sol: Solution): Promise<string | undefined> {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── guides ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Placeholder for a tag no campaign topic teaches: the `/appendix/<slug>` page a
|
|
||||||
* problem's topic badge links to. `list:` is the tag's LeetCode problem list,
|
|
||||||
* rendered as a badge by the docs MDX `wrapper`. Written once — the page
|
|
||||||
* is hand-owned from then on.
|
|
||||||
*/
|
|
||||||
function guidePage(tag: TopicTag): string {
|
|
||||||
return [
|
|
||||||
"---",
|
|
||||||
`title: ${JSON.stringify(tag.name)}`,
|
|
||||||
`list: ${JSON.stringify(`https://leetcode.com/problem-list/${tag.slug}/`)}`,
|
|
||||||
"---",
|
|
||||||
"",
|
|
||||||
":::note",
|
|
||||||
`This guide has not been written yet. The LeetCode list above has every problem tagged ${tag.name}.`,
|
|
||||||
":::",
|
|
||||||
"",
|
|
||||||
].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Folder meta for `appendix/`: a drill-in (`display: "page"`) sidebar row
|
|
||||||
* sorted ahead of the phase groups, so it sits right under the root pages
|
|
||||||
* (Introduction, Keywords, Progress).
|
|
||||||
*/
|
|
||||||
const APPENDIX_META = `import { defineMeta } from "blume";\n\nexport default defineMeta({ title: "Appendix", icon: "library", order: 0, display: "page" });\n`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A guide's frontmatter with `sidebar.badge` set to its backlink count — the
|
|
||||||
* one key sync owns on a hand-owned guide — or dropped (with a `sidebar:` left
|
|
||||||
* empty) at zero, so the sidebar shows a count only where there is something
|
|
||||||
* to count.
|
|
||||||
*/
|
|
||||||
function withBacklinkBadge(page: string, count: number): string {
|
|
||||||
const fm = page.match(/^---\n[\s\S]*?\n---/);
|
|
||||||
if (!fm) return page;
|
|
||||||
let head = fm[0].replace(/^ {2}badge: .*\n/m, "");
|
|
||||||
if (count > 0) {
|
|
||||||
const line = ` badge: '${count}'`;
|
|
||||||
head = /^sidebar:$/m.test(head)
|
|
||||||
? head.replace(/^sidebar:$/m, `sidebar:\n${line}`)
|
|
||||||
: head.replace(/\n---$/, `\nsidebar:\n${line}\n---`);
|
|
||||||
} else {
|
|
||||||
head = head.replace(/^sidebar:\n(?! {2})/m, "");
|
|
||||||
}
|
|
||||||
return head + page.slice(fm[0].length);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── inline-code formatting heuristics ────────────────────────────
|
// ── inline-code formatting heuristics ────────────────────────────
|
||||||
|
|
||||||
/** Identifiers worth backticking in prose, harvested from the code. */
|
/** Identifiers worth backticking in prose, harvested from the code. */
|
||||||
@@ -751,28 +697,24 @@ for (const rel of (await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))).s
|
|||||||
|
|
||||||
const report: [string, string, string][] = [];
|
const report: [string, string, string][] = [];
|
||||||
const ordered = [...solutions.values()].sort((a, b) => a.num - b.num);
|
const ordered = [...solutions.values()].sort((a, b) => a.num - b.num);
|
||||||
const groups = new Map<string, Topic>(); // topic folder -> topic, for meta.ts
|
const folders = new Map<string, FolderMeta>(); // folder under docs/ -> its sidebar meta, for site.json
|
||||||
const phases = new Map<string, Phase>(); // phase folder -> phase, for meta.ts
|
|
||||||
const vacated = new Set<string>(); // folders a page moved out of; removed below if empty
|
const vacated = new Set<string>(); // folders a page moved out of; removed below if empty
|
||||||
const redirects: string[] = []; // `{ from: "/<num>", to: "/<topic>/<slug>" }` rows, numeric order
|
const redirects: { from: string; to: string }[] = []; // one per page, numeric order
|
||||||
const tags = new Map<string, TopicTag>(); // every tag slug any problem page carries -> tag
|
|
||||||
const tagged = new Map<string, number>(); // tag slug -> how many problem pages carry it
|
|
||||||
|
|
||||||
for (const sol of ordered) {
|
for (const sol of ordered) {
|
||||||
const langs = Object.keys(sol.code).sort().join("+");
|
const langs = Object.keys(sol.code).sort().join("+");
|
||||||
const workRef = `${sol.num}.${sol.slug} (${langs})`;
|
const workRef = `${sol.num}.${sol.slug} (${langs})`;
|
||||||
// (0N-<phase>)/<topic>/ folder (the topic is a real route segment, so its
|
// (0N-<phase>)/<topic>/(problems)/ folder (the topic is a real route segment,
|
||||||
// index.mdx is the landing page) + leetcode-number prefix: numeric sidebar
|
// so its index.mdx is the landing page; `(problems)` adds none) +
|
||||||
// order, prefix stripped from the URL.
|
// leetcode-number prefix: numeric sidebar order, prefix stripped from the URL.
|
||||||
const topic = topics.get(sol.num) ?? topicByName.get(sol.category) ?? { name: sol.category };
|
const topic = topics.get(sol.num) ?? topicByName.get(sol.category) ?? { name: sol.category };
|
||||||
const dir = topicDir(topic);
|
const dir = topicDir(topic);
|
||||||
groups.set(dir, topic);
|
if (!folders.has(dir)) folders.set(dir, groupMeta(topic));
|
||||||
if (topic.phase) phases.set(phaseDir(topic.phase), topic.phase);
|
if (topic.phase) folders.set(phaseDir(topic.phase), phaseMeta(topic.phase));
|
||||||
const name = `${dir}/${sol.num}-${sol.slug}.mdx`;
|
const name = `${dir}/(problems)/${sol.num}_${sol.slug}.mdx`;
|
||||||
const existing = pages.get(sol.num);
|
const existing = pages.get(sol.num);
|
||||||
await mkdir(join(DOCS, dir), { recursive: true });
|
await mkdir(join(DOCS, dirname(name)), { recursive: true });
|
||||||
|
|
||||||
let written: string; // the page as it stands after this run, for its topics
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const page = await Bun.file(join(DOCS, existing)).text();
|
const page = await Bun.file(join(DOCS, existing)).text();
|
||||||
const at = page.indexOf("## Solution");
|
const at = page.indexOf("## Solution");
|
||||||
@@ -794,117 +736,41 @@ for (const sol of ordered) {
|
|||||||
actions.push("updated");
|
actions.push("updated");
|
||||||
}
|
}
|
||||||
report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]);
|
report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]);
|
||||||
written = next;
|
|
||||||
} else {
|
} else {
|
||||||
const page = renderPage(sol, await fetchTopicTags(sol), await issueUrl(sol));
|
await Bun.write(join(DOCS, name), renderPage(sol, await fetchTopicTags(sol), await issueUrl(sol)));
|
||||||
await Bun.write(join(DOCS, name), page);
|
|
||||||
report.push([workRef, name, "created"]);
|
report.push([workRef, name, "created"]);
|
||||||
written = page;
|
|
||||||
}
|
}
|
||||||
for (const tag of frontmatterTopics(written)) {
|
// The route drops the phase `(group)`, `(problems)` and the number prefix.
|
||||||
tags.set(tag.slug, tag);
|
redirects.push({ from: `/${sol.num}`, to: `/${slugify(topic.name)}/${sol.slug}` });
|
||||||
tagged.set(tag.slug, (tagged.get(tag.slug) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
// The route drops the phase `(group)` and the page's number prefix.
|
|
||||||
redirects.push(` { from: "/${sol.num}", to: ${JSON.stringify(`/${slugify(topic.name)}/${sol.slug}`)} },`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Script-owned short links, one per page; imported by blume.config.ts.
|
// The site's one generated input besides the pages; script-owned, rewritten
|
||||||
|
// only when it differs. Folders sorted by path so a diff reads by location.
|
||||||
{
|
{
|
||||||
const module = [
|
const manifest = `${JSON.stringify(
|
||||||
"// Generated by `bun run sync` (apps/cli/sync.ts) — do not edit.",
|
{ folders: Object.fromEntries([...folders].sort(([a], [b]) => (a < b ? -1 : 1))), redirects },
|
||||||
"// `/leetcode/<num>` → that problem's page; exact paths, base added by Blume.",
|
null,
|
||||||
"export default [",
|
2,
|
||||||
...redirects,
|
)}\n`;
|
||||||
"];",
|
if (!(await Bun.file(MANIFEST).exists()) || (await Bun.file(MANIFEST).text()) !== manifest) {
|
||||||
"",
|
await Bun.write(MANIFEST, manifest);
|
||||||
].join("\n");
|
report.push([`${folders.size} folders, ${redirects.length} redirects`, "docs/site.json", "written"]);
|
||||||
if (!(await Bun.file(REDIRECTS).exists()) || (await Bun.file(REDIRECTS).text()) !== module) {
|
|
||||||
await Bun.write(REDIRECTS, module);
|
|
||||||
report.push([`${redirects.length} redirects`, "apps/docs/redirects.ts", "written"]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// One guide page per tag that no campaign topic teaches, created when missing
|
// A folder every page left behind is dead: drop it, then the folders above it
|
||||||
// (an empty file counts as missing) and never rewritten. A guide is found by
|
// that emptied too (`(problems)`, its topic, the phase). A folder that still
|
||||||
// slug anywhere under appendix/ — a human files it into a category `(group)`,
|
// holds anything (a hand-written index.mdx, another topic) stops the climb.
|
||||||
// which leaves its route alone — and a new one lands at the appendix/ root. A
|
|
||||||
// guide whose tag no page carries any more is left alone: it is hand-owned.
|
|
||||||
const guideTags = [...tags.values()]
|
|
||||||
.filter((tag) => !TOPIC_LANDINGS[tag.slug])
|
|
||||||
.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
||||||
const guideFiles = new Map<string, string>(); // slug -> path relative to DOCS
|
|
||||||
for await (const rel of new Bun.Glob("appendix/**/*.mdx").scan(DOCS)) guideFiles.set(basename(rel, ".mdx"), rel);
|
|
||||||
for (const tag of guideTags) {
|
|
||||||
const rel = guideFiles.get(tag.slug) ?? `appendix/${tag.slug}.mdx`;
|
|
||||||
const file = Bun.file(join(DOCS, rel));
|
|
||||||
if ((await file.exists()) && file.size > 0) continue;
|
|
||||||
await Bun.write(file, guidePage(tag));
|
|
||||||
report.push([`tag ${tag.slug}`, rel, "created"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every guide's sidebar badge is its backlink count: the problem pages whose
|
|
||||||
// tag badge links to it, which is every page carrying the tag, since a guide's
|
|
||||||
// tag has no landing (`tagTarget()`). A guide whose tag a landing has since
|
|
||||||
// claimed has no backlinks left. Counts move with every solved problem, so the
|
|
||||||
// badge is rewritten whenever it differs; the rest of the guide is untouched.
|
|
||||||
// The same pass checks each guide's hand-picked `icon:`, which is also the icon
|
|
||||||
// on every problem-page badge linking to the guide: it must exist, must not be
|
|
||||||
// a topic group's (`TOPIC_ICONS`), so a guide badge never reads as a topic's,
|
|
||||||
// and must not be another guide's.
|
|
||||||
const topicIcons = new Set(Object.values(TOPIC_ICONS));
|
|
||||||
const guideIcons = new Map<string, string>(); // icon -> the guide that took it first
|
|
||||||
for await (const rel of new Bun.Glob("appendix/**/*.mdx").scan(DOCS)) {
|
|
||||||
const slug = basename(rel, ".mdx");
|
|
||||||
const count = TOPIC_LANDINGS[slug] ? 0 : (tagged.get(slug) ?? 0);
|
|
||||||
const page = await Bun.file(join(DOCS, rel)).text();
|
|
||||||
const icon = page.match(/^---\n[\s\S]*?\n---/)?.[0].match(/^icon: (\S+)$/m)?.[1];
|
|
||||||
if (!icon) console.warn(`${rel}: no icon; its badges render without one — file it and add \`icon:\``);
|
|
||||||
else if (topicIcons.has(icon)) console.warn(`${rel}: icon ${JSON.stringify(icon)} is a topic's (TOPIC_ICONS); pick another`);
|
|
||||||
else if (guideIcons.has(icon)) console.warn(`${rel}: icon ${JSON.stringify(icon)} is ${guideIcons.get(icon)}'s too; pick another`);
|
|
||||||
else guideIcons.set(icon, rel);
|
|
||||||
const next = withBacklinkBadge(page, count);
|
|
||||||
if (next === page) continue;
|
|
||||||
await Bun.write(join(DOCS, rel), next);
|
|
||||||
report.push([`${count} backlinks`, rel, "badged"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase metas: created when missing (an empty file counts as missing), never
|
|
||||||
// rewritten, so a renamed phase title survives every sync.
|
|
||||||
for (const [dir, phase] of phases) {
|
|
||||||
const file = Bun.file(join(DOCS, dir, "meta.ts"));
|
|
||||||
if ((await file.exists()) && file.size > 0) continue;
|
|
||||||
await Bun.write(file, phaseMeta(phase));
|
|
||||||
report.push([`phase ${phase.order}`, `${dir}/meta.ts`, "created"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Script-owned folder metas — one per live topic folder, plus appendix/ —
|
|
||||||
// rewritten only when they differ.
|
|
||||||
const metas: [label: string, dir: string, meta: string][] = [
|
|
||||||
...[...groups].map(([dir, topic]): [string, string, string] => [`topic ${topic.order ?? "-"}`, dir, groupMeta(topic)]),
|
|
||||||
...(guideTags.length ? [["appendix", "appendix", APPENDIX_META] as [string, string, string]] : []),
|
|
||||||
];
|
|
||||||
for (const [label, dir, meta] of metas) {
|
|
||||||
const path = join(DOCS, dir, "meta.ts");
|
|
||||||
if ((await Bun.file(path).exists()) && (await Bun.file(path).text()) === meta) continue;
|
|
||||||
await Bun.write(path, meta);
|
|
||||||
report.push([label, `${dir}/meta.ts`, "written"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A folder every page left behind is dead: drop its meta and the folder itself,
|
|
||||||
// then the phase folder above it if that emptied too. A folder that still holds
|
|
||||||
// anything else (a hand-written index.mdx, another topic) stops the climb.
|
|
||||||
for (const start of vacated) {
|
for (const start of vacated) {
|
||||||
for (let dir = start; dir !== "."; dir = dirname(dir)) {
|
for (let dir = start; dir !== "."; dir = dirname(dir)) {
|
||||||
if (groups.has(dir) || phases.has(dir)) break;
|
if (folders.has(dir)) break;
|
||||||
let left: string[];
|
let left: string[];
|
||||||
try {
|
try {
|
||||||
left = await readdir(join(DOCS, dir));
|
left = await readdir(join(DOCS, dir));
|
||||||
} catch {
|
} catch {
|
||||||
break; // already removed via a sibling's climb
|
break; // already removed via a sibling's climb
|
||||||
}
|
}
|
||||||
if (left.some((f) => f !== "meta.ts")) break;
|
if (left.length) break;
|
||||||
if (left.length) await unlink(join(DOCS, dir, "meta.ts"));
|
|
||||||
await rmdir(join(DOCS, dir));
|
await rmdir(join(DOCS, dir));
|
||||||
report.push(["", dir, "removed (empty)"]);
|
report.push(["", dir, "removed (empty)"]);
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -3,8 +3,8 @@
|
|||||||
* Find the NeetCode explanation video for every docs page that has none.
|
* Find the NeetCode explanation video for every docs page that has none.
|
||||||
*
|
*
|
||||||
* A finished solution page carries frontmatter `video: <YouTube id>` —
|
* A finished solution page carries frontmatter `video: <YouTube id>` —
|
||||||
* NeetCode's walkthrough of the problem — which the docs MDX `wrapper`
|
* NeetCode's walkthrough of the problem — which the site's MDX `wrapper`
|
||||||
* (`apps/docs/components/ProblemBadges.astro`) renders as the red
|
* (prdlk/cs `components/ProblemBadges.astro`) renders as the red
|
||||||
* "Explanation" badge beside the topic badges. `sync.ts` never writes that key
|
* "Explanation" badge beside the topic badges. `sync.ts` never writes that key
|
||||||
* (it owns `icon:`, `topics:` and `## Solution`), so pages accumulate without
|
* (it owns `icon:`, `topics:` and `## Solution`), so pages accumulate without
|
||||||
* one. This script lists every numbered page lacking `video:` and looks each
|
* one. This script lists every numbered page lacking `video:` and looks each
|
||||||
@@ -25,7 +25,7 @@ import { join } from "node:path";
|
|||||||
import { printTable } from "./report.ts";
|
import { printTable } from "./report.ts";
|
||||||
import { ROOT } from "./work.ts";
|
import { ROOT } from "./work.ts";
|
||||||
|
|
||||||
const DOCS = join(ROOT, "apps", "docs", "content");
|
const DOCS = join(ROOT, "docs");
|
||||||
const WRITE = process.argv.includes("--write");
|
const WRITE = process.argv.includes("--write");
|
||||||
|
|
||||||
/** Channel handles searched in order; the first exact match wins. */
|
/** Channel handles searched in order; the first exact match wins. */
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
.blume-verify/
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
import { defineConfig } from "blume";
|
|
||||||
import type { StandardSchema } from "blume/core/standard-schema.ts";
|
|
||||||
import { filesystem } from "blume/sources";
|
|
||||||
import redirects from "./redirects";
|
|
||||||
|
|
||||||
// Blume's defaults (`inter`, `ibm-plex-mono`) are fetched from fonts.gstatic.com
|
|
||||||
// on every build, which has timed out on the Pages runner and, fully offline,
|
|
||||||
// silently ships no fonts at all. The same two families from Fontsource, as
|
|
||||||
// local files, keep the build network-free. Blume resolves `src` from this
|
|
||||||
// directory; the workspace links these packages under ./node_modules.
|
|
||||||
const inter = {
|
|
||||||
name: "Inter",
|
|
||||||
variants: [
|
|
||||||
{
|
|
||||||
src: "./node_modules/@fontsource-variable/inter/files/inter-latin-wght-normal.woff2",
|
|
||||||
weight: "100..900",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const plexMono = {
|
|
||||||
name: "IBM Plex Mono",
|
|
||||||
variants: [
|
|
||||||
{
|
|
||||||
src: "./node_modules/@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-400-normal.woff2",
|
|
||||||
weight: 400,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "./node_modules/@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-500-normal.woff2",
|
|
||||||
weight: 500,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom frontmatter, rendered as the badge row by
|
|
||||||
* components/ProblemBadges.astro. Problem pages: `topics:` is LeetCode's topic
|
|
||||||
* tags and `issue:` the problem's GitHub issue URL (both written by
|
|
||||||
* apps/cli/sync.ts), `video:` the NeetCode walkthrough's
|
|
||||||
* YouTube id (apps/cli/videos.ts). Guide pages (content/appendix/, created by
|
|
||||||
* sync): `list:` is the tag's LeetCode problem list. Blume rejects undeclared
|
|
||||||
* keys and takes any Standard Schema; these are hand-rolled so the docs
|
|
||||||
* workspace needs no zod. All optional — every other page carries none.
|
|
||||||
*/
|
|
||||||
const optional = <T>(
|
|
||||||
message: string,
|
|
||||||
valid: (value: unknown) => value is T,
|
|
||||||
): StandardSchema<unknown, T | undefined> => ({
|
|
||||||
"~standard": {
|
|
||||||
version: 1,
|
|
||||||
vendor: "prdlk",
|
|
||||||
validate: (value) =>
|
|
||||||
value === undefined || valid(value)
|
|
||||||
? { value }
|
|
||||||
: { issues: [{ message }] },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const topicsSchema = optional(
|
|
||||||
"expected a list of { name, slug } LeetCode topic tags",
|
|
||||||
(value): value is { name: string; slug: string }[] =>
|
|
||||||
Array.isArray(value) &&
|
|
||||||
value.every(
|
|
||||||
(t) => typeof t?.name === "string" && typeof t?.slug === "string",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const videoSchema = optional(
|
|
||||||
"expected an 11-character YouTube video id",
|
|
||||||
(value): value is string =>
|
|
||||||
typeof value === "string" && /^[\w-]{11}$/.test(value),
|
|
||||||
);
|
|
||||||
const listSchema = optional(
|
|
||||||
"expected a https://leetcode.com/problem-list/<slug>/ URL",
|
|
||||||
(value): value is string =>
|
|
||||||
typeof value === "string" &&
|
|
||||||
/^https:\/\/leetcode\.com\/problem-list\/[\w-]+\/$/.test(value),
|
|
||||||
);
|
|
||||||
const issueSchema = optional(
|
|
||||||
"expected a https://github.com/<owner>/<repo>/issues/<n> URL",
|
|
||||||
(value): value is string =>
|
|
||||||
typeof value === "string" &&
|
|
||||||
/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/issues\/\d+$/.test(value),
|
|
||||||
);
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
title: "prdlk",
|
|
||||||
description: "Documentation for my LeetCode solutions",
|
|
||||||
logo: "./public/logo.svg",
|
|
||||||
deployment: {
|
|
||||||
site: "https://lc.prd.lk",
|
|
||||||
},
|
|
||||||
github: {
|
|
||||||
owner: "prdlk",
|
|
||||||
repo: "leetcode",
|
|
||||||
},
|
|
||||||
lastModified: "git",
|
|
||||||
redirects,
|
|
||||||
frontmatter: {
|
|
||||||
extend: {
|
|
||||||
topics: topicsSchema,
|
|
||||||
video: videoSchema,
|
|
||||||
list: listSchema,
|
|
||||||
issue: issueSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
seo: {
|
|
||||||
og: { enabled: true },
|
|
||||||
// No `rss`: the changelog is one custom page (pages/changelog.astro), not
|
|
||||||
// `type: changelog` entries, so a changelog feed would have nothing in it.
|
|
||||||
sitemap: true,
|
|
||||||
robots: true,
|
|
||||||
structuredData: true,
|
|
||||||
x: { handle: "@prdlk" },
|
|
||||||
},
|
|
||||||
// Blume's own assistant route is server-rendered (it reads the provider key
|
|
||||||
// per request), which a static Pages build cannot ship. The SRS Worker
|
|
||||||
// answers instead (apps/api/src/ask.ts: OpenRouter, grounded on this site's
|
|
||||||
// llms-full.txt, which `agents.llmsTxt` publishes).
|
|
||||||
ai: {
|
|
||||||
assistant: {
|
|
||||||
enabled: true,
|
|
||||||
endpoint: "https://srs-api.prdlk.workers.dev/ask",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
agents: {
|
|
||||||
llmsTxt: true,
|
|
||||||
mcp: { enabled: false },
|
|
||||||
},
|
|
||||||
// The only content source. `/changelog` is a custom page (pages/
|
|
||||||
// changelog.astro) reading the GitHub releases itself — one timeline, no
|
|
||||||
// per-release pages, so nothing of it enters the sidebar or prev/next.
|
|
||||||
content: {
|
|
||||||
sources: [filesystem({ root: "content" })],
|
|
||||||
},
|
|
||||||
theme: {
|
|
||||||
fonts: { display: inter, body: inter, mono: plexMono },
|
|
||||||
},
|
|
||||||
navigation: {
|
|
||||||
repo: "https://github.com/prdlk/leetcode",
|
|
||||||
sidebar: {
|
|
||||||
display: "flat", // "flat" | "group" | "page"
|
|
||||||
},
|
|
||||||
// A plain header link on the right, not a tab.
|
|
||||||
actions: [{ href: "/changelog", label: "Changelog" }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"$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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
/**
|
|
||||||
* Project MDX components, available on every page with no import, and layout
|
|
||||||
* slot overrides. Static `.astro` files ship no framework JS (a custom element
|
|
||||||
* carries any behaviour); interactive React lives in `islands/` instead.
|
|
||||||
*/
|
|
||||||
import { defineComponents } from "blume";
|
|
||||||
|
|
||||||
export default defineComponents({
|
|
||||||
mdx: {
|
|
||||||
Carousel: "./components/Carousel.astro",
|
|
||||||
CarouselImage: "./components/CarouselImage.astro",
|
|
||||||
// MDX's own content wrapper: problem pages' topic + explanation badges, from
|
|
||||||
// `topics:` / `video:` frontmatter, between the description and the body.
|
|
||||||
wrapper: "./components/ProblemBadges.astro",
|
|
||||||
},
|
|
||||||
layout: {
|
|
||||||
// Topic landing + guide pages' "what links here": every problem pointing at the page.
|
|
||||||
PageFooter: "./components/BacklinksList.astro",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
---
|
|
||||||
/**
|
|
||||||
* The `PageFooter` slot: an Obsidian-style backlinks list under a topic landing
|
|
||||||
* or guide page — every problem page that points at it, as `<num> <name>`
|
|
||||||
* rows in LeetCode-number order. Pages nothing points at (problem pages, the
|
|
||||||
* overview, the changelog) render nothing.
|
|
||||||
*
|
|
||||||
* 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
---
|
|
||||||
/**
|
|
||||||
* 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>
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
---
|
|
||||||
/**
|
|
||||||
* 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>
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
---
|
|
||||||
/**
|
|
||||||
* 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 guide
|
|
||||||
* page in the Appendix, `/appendix/<slug>` (sync creates one per such tag), and is outlined.
|
|
||||||
* `tagTarget()` picks the route (and between two landings, e.g. DFS); the
|
|
||||||
* `PageFooter` backlinks list reads the same answer. Each tag badge is a
|
|
||||||
* pill carrying the icon its target shows in the sidebar (the topic's
|
|
||||||
* `meta.ts` icon, the guide's `icon:`) and the tag's own colour
|
|
||||||
* (`lib/tag-colors.ts`), assigned over every tag the site has a page for —
|
|
||||||
* no two tags share one, and tags that share problem pages are spread
|
|
||||||
* furthest apart. A tag with no page (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.
|
|
||||||
* - `list:` (guide pages, written by sync on create) is the tag's LeetCode
|
|
||||||
* problem list, an orange "LeetCode list" badge.
|
|
||||||
*/
|
|
||||||
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 { 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 list: string | undefined = entry?.data.list;
|
|
||||||
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 page — the ones a landing teaches, plus every served guide —
|
|
||||||
// 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),
|
|
||||||
...[...served].flatMap((path) => path.match(/^\/appendix\/([^/]+)$/u)?.slice(1) ?? []),
|
|
||||||
],
|
|
||||||
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: 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 || list || 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>
|
|
||||||
)}
|
|
||||||
{list && (
|
|
||||||
<a href={list} target="_blank" rel="noopener" class={linkClass}>
|
|
||||||
<Badge variant="warning" icon="list" shape="pill">
|
|
||||||
LeetCode list
|
|
||||||
</Badge>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
<slot />
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
@@ -1,372 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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 }
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
"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 }
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
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 }
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Binary Search", order: 5, icon: "search", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Hash Table", order: 1, icon: "hash", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "1. Linear Structures", order: 1 });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Monotonic Stack", order: 7, icon: "trending-up", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Prefix Sum", order: 2, icon: "sigma", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Sliding Window", order: 4, icon: "app-window", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Stack", order: 6, icon: "layers", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Two Pointers", order: 3, icon: "arrow-left-right", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Hybrid Structures", order: 9, icon: "combine", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Linked List", order: 8, icon: "link", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Matrix Index Math", order: 10, icon: "grid-3x3", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "2. Nodal & Grid", order: 2 });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Binary Search Tree", order: 13, icon: "git-fork", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Heap / Priority Queue", order: 14, icon: "triangle", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "3. Hierarchical", order: 3 });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Tree BFS", order: 12, icon: "network", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Tree DFS", order: 11, icon: "folder-tree", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Trie", order: 15, icon: "spell-check", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Graph BFS", order: 17, icon: "radar", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Graph DFS", order: 16, icon: "waypoints", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "4. Relational", order: 4 });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Topological Sort", order: 18, icon: "workflow", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Union-Find", order: 19, icon: "merge", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "1-D Dynamic Programming", order: 21, icon: "columns-3", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Backtracking", order: 20, icon: "undo-2", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Greedy", order: 23, icon: "coins", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Intervals", order: 24, icon: "calendar-range", display: "group" });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "5. Decision Space", order: 5 });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Multi-D / Grid DP", order: 22, icon: "table", display: "group" });
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Array"
|
|
||||||
list: "https://leetcode.com/problem-list/array/"
|
|
||||||
icon: list-ordered
|
|
||||||
sidebar:
|
|
||||||
badge: '45'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Array.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Binary Tree"
|
|
||||||
list: "https://leetcode.com/problem-list/binary-tree/"
|
|
||||||
icon: git-branch
|
|
||||||
sidebar:
|
|
||||||
badge: '9'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Binary Tree.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Directed Acyclic Graph"
|
|
||||||
list: "https://leetcode.com/problem-list/directed-acyclic-graph/"
|
|
||||||
icon: route
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Directed Acyclic Graph.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Doubly-Linked List"
|
|
||||||
list: "https://leetcode.com/problem-list/doubly-linked-list/"
|
|
||||||
icon: link-2
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Doubly-Linked List.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Graph Theory"
|
|
||||||
list: "https://leetcode.com/problem-list/graph/"
|
|
||||||
icon: share-2
|
|
||||||
sidebar:
|
|
||||||
badge: '5'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Graph Theory.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "K-D Tree"
|
|
||||||
list: "https://leetcode.com/problem-list/k-d-tree/"
|
|
||||||
icon: axis-3d
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged K-D Tree.
|
|
||||||
:::
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Data Structures", order: 1 });
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Queue"
|
|
||||||
list: "https://leetcode.com/problem-list/queue/"
|
|
||||||
icon: list-end
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Queue.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "String"
|
|
||||||
list: "https://leetcode.com/problem-list/string/"
|
|
||||||
icon: quote
|
|
||||||
sidebar:
|
|
||||||
badge: '17'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged String.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Tree"
|
|
||||||
list: "https://leetcode.com/problem-list/tree/"
|
|
||||||
icon: tree-deciduous
|
|
||||||
sidebar:
|
|
||||||
badge: '9'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Tree.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Bidirectional Search"
|
|
||||||
list: "https://leetcode.com/problem-list/bidirectional-search/"
|
|
||||||
icon: move-horizontal
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Bidirectional Search.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Binary Lifting"
|
|
||||||
list: "https://leetcode.com/problem-list/binary-lifting/"
|
|
||||||
icon: chevrons-up
|
|
||||||
sidebar:
|
|
||||||
badge: '2'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Binary Lifting.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Bit Manipulation"
|
|
||||||
list: "https://leetcode.com/problem-list/bit-manipulation/"
|
|
||||||
icon: binary
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Bit Manipulation.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Bracket Sequences"
|
|
||||||
list: "https://leetcode.com/problem-list/bracket-sequences/"
|
|
||||||
icon: parentheses
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Bracket Sequences.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Bucket Sort"
|
|
||||||
list: "https://leetcode.com/problem-list/bucket-sort/"
|
|
||||||
icon: container
|
|
||||||
sidebar:
|
|
||||||
badge: '2'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Bucket Sort.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Complete Knapsack"
|
|
||||||
list: "https://leetcode.com/problem-list/complete-knapsack/"
|
|
||||||
icon: package-plus
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Complete Knapsack.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Divide and Conquer"
|
|
||||||
list: "https://leetcode.com/problem-list/divide-and-conquer/"
|
|
||||||
icon: split
|
|
||||||
sidebar:
|
|
||||||
badge: '5'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Divide and Conquer.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Floyd's Cycle Finding Algorithm"
|
|
||||||
list: "https://leetcode.com/problem-list/floyds-cycle-finding-algorithm/"
|
|
||||||
icon: repeat
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Floyd's Cycle Finding Algorithm.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Knapsack Problem"
|
|
||||||
list: "https://leetcode.com/problem-list/knapsack-problem/"
|
|
||||||
icon: backpack
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Knapsack Problem.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Longest Common Subsequence"
|
|
||||||
list: "https://leetcode.com/problem-list/longest-common-subsequence/"
|
|
||||||
icon: diff
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Longest Common Subsequence.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Lowest Common Ancestor"
|
|
||||||
list: "https://leetcode.com/problem-list/lowest-common-ancestor/"
|
|
||||||
icon: git-merge
|
|
||||||
sidebar:
|
|
||||||
badge: '2'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Lowest Common Ancestor.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Memoization"
|
|
||||||
list: "https://leetcode.com/problem-list/memoization/"
|
|
||||||
icon: database
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Memoization.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Merge Sort"
|
|
||||||
list: "https://leetcode.com/problem-list/merge-sort/"
|
|
||||||
icon: blend
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Merge Sort.
|
|
||||||
:::
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Algorithms", order: 2 });
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Quickselect"
|
|
||||||
list: "https://leetcode.com/problem-list/quickselect/"
|
|
||||||
icon: crosshair
|
|
||||||
sidebar:
|
|
||||||
badge: '3'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Quickselect.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Quicksort"
|
|
||||||
icon: shuffle
|
|
||||||
list: "https://leetcode.com/problem-list/quicksort/"
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Quicksort.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Recursion"
|
|
||||||
list: "https://leetcode.com/problem-list/recursion/"
|
|
||||||
icon: iteration-ccw
|
|
||||||
sidebar:
|
|
||||||
badge: '3'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Recursion.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Simulation"
|
|
||||||
list: "https://leetcode.com/problem-list/simulation/"
|
|
||||||
icon: monitor-play
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Simulation.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Sorting"
|
|
||||||
list: "https://leetcode.com/problem-list/sorting/"
|
|
||||||
icon: arrow-down-wide-narrow
|
|
||||||
sidebar:
|
|
||||||
badge: '12'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Sorting.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Tournament Sort"
|
|
||||||
list: "https://leetcode.com/problem-list/tournament-sort/"
|
|
||||||
icon: trophy
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Tournament Sort.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Combinatorics"
|
|
||||||
list: "https://leetcode.com/problem-list/combinatorics/"
|
|
||||||
icon: dices
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Combinatorics.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Counting"
|
|
||||||
list: "https://leetcode.com/problem-list/counting/"
|
|
||||||
icon: tally-5
|
|
||||||
sidebar:
|
|
||||||
badge: '3'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Counting.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Design"
|
|
||||||
list: "https://leetcode.com/problem-list/design/"
|
|
||||||
icon: pencil-ruler
|
|
||||||
sidebar:
|
|
||||||
badge: '5'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Design.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Geometry"
|
|
||||||
list: "https://leetcode.com/problem-list/geometry/"
|
|
||||||
icon: shapes
|
|
||||||
sidebar:
|
|
||||||
badge: '1'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Geometry.
|
|
||||||
:::
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Math"
|
|
||||||
list: "https://leetcode.com/problem-list/math/"
|
|
||||||
icon: calculator
|
|
||||||
sidebar:
|
|
||||||
badge: '6'
|
|
||||||
---
|
|
||||||
|
|
||||||
:::note
|
|
||||||
This guide has not been written yet. The LeetCode list above has every problem tagged Math.
|
|
||||||
:::
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Techniques", order: 3 });
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { defineMeta } from "blume";
|
|
||||||
|
|
||||||
export default defineMeta({ title: "Appendix", icon: "library", order: 0, display: "page" });
|
|
||||||
@@ -1,377 +0,0 @@
|
|||||||
/**
|
|
||||||
* Dependency spine island — the "how the 24 topics build on each other" map
|
|
||||||
* from the README, redrawn as data (nodes on a 7×6 grid, edges as SVG path
|
|
||||||
* strings) so it is themed, zoomable, and clickable.
|
|
||||||
*
|
|
||||||
* - Wheel over the figure zooms about the cursor (trackpad pinch arrives as a
|
|
||||||
* ctrl+wheel and behaves the same); dragging pans; the toolbar and +/-/0
|
|
||||||
* keys do the same. Zooming OUT at the minimum scale is deliberately left
|
|
||||||
* to the page, so a reader scrolling past the map is never trapped in it.
|
|
||||||
* - A node is a link to its topic. Routes come from Blume's own navigation
|
|
||||||
* endpoint at mount: the topic's landing page when the folder has an
|
|
||||||
* `index.mdx`, else its first solution page. A topic with no pages yet is
|
|
||||||
* drawn inert rather than linked to a 404, so the map stays honest as the
|
|
||||||
* campaign fills in without a rebuild of this file.
|
|
||||||
*
|
|
||||||
* Invariants: the first frame is the figure at HOME_ZOOM, centred, with no
|
|
||||||
* links (server and client render identically); the geometry is the README
|
|
||||||
* SVG's, to the pixel, so `viewBox` maths below is in that 1472×648 space.
|
|
||||||
*/
|
|
||||||
import { useEffect, useRef, useState, type CSSProperties, type PointerEvent } from "react";
|
|
||||||
import { withBase } from "blume/components/islands/base-path.ts";
|
|
||||||
|
|
||||||
export const client = "visible";
|
|
||||||
|
|
||||||
// ── figure data (README dependency-spine geometry) ───────────────
|
|
||||||
|
|
||||||
const W = 1472;
|
|
||||||
const H = 648;
|
|
||||||
const MAX_ZOOM = 6;
|
|
||||||
const NODE_W = 144;
|
|
||||||
const NODE_H = 48;
|
|
||||||
const COL_X = [56, 248, 472, 664, 888, 1080, 1272] as const;
|
|
||||||
const ROW_Y = [72, 144, 216, 288, 416, 488] as const;
|
|
||||||
|
|
||||||
type Kind = "entry" | "topic" | "focal";
|
|
||||||
|
|
||||||
interface Topic {
|
|
||||||
n: number;
|
|
||||||
name: string;
|
|
||||||
sub?: string;
|
|
||||||
/** Sync's group slug for the README topic name — the docs folder. */
|
|
||||||
slug: string;
|
|
||||||
kind: Kind;
|
|
||||||
col: number;
|
|
||||||
row: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TOPICS: Topic[] = [
|
|
||||||
{ n: 5, name: "Binary Search", slug: "binary-search", kind: "entry", col: 0, row: 0 },
|
|
||||||
{ n: 3, name: "Two Pointers", slug: "two-pointers", kind: "entry", col: 0, row: 1 },
|
|
||||||
{ n: 4, name: "Sliding Window", slug: "sliding-window", kind: "topic", col: 1, row: 1 },
|
|
||||||
{ n: 6, name: "Stack", slug: "stack", kind: "entry", col: 0, row: 2 },
|
|
||||||
{ n: 7, name: "Monotonic Stack", slug: "monotonic-stack", kind: "topic", col: 1, row: 2 },
|
|
||||||
{ n: 1, name: "Hash Lookup", slug: "hash-table", kind: "entry", col: 0, row: 3 },
|
|
||||||
{ n: 2, name: "Prefix Sum", slug: "prefix-sum", kind: "topic", col: 1, row: 3 },
|
|
||||||
{ n: 11, name: "Tree DFS", slug: "tree-dfs", kind: "focal", col: 2, row: 0 },
|
|
||||||
{ n: 13, name: "Binary Search Tree", slug: "binary-search-tree", kind: "topic", col: 2, row: 1 },
|
|
||||||
{ n: 14, name: "Heap", sub: "priority queue", slug: "heap-priority-queue", kind: "topic", col: 3, row: 1 },
|
|
||||||
{ n: 15, name: "Trie", slug: "trie", kind: "topic", col: 3, row: 2 },
|
|
||||||
{ n: 12, name: "Tree BFS", slug: "tree-bfs", kind: "entry", col: 2, row: 3 },
|
|
||||||
{ n: 8, name: "Linked List", slug: "linked-list", kind: "entry", col: 2, row: 4 },
|
|
||||||
{ n: 9, name: "Hybrid Structures", slug: "hybrid-structures", kind: "topic", col: 3, row: 4 },
|
|
||||||
{ n: 10, name: "Matrix Index Math", slug: "matrix-index-math", kind: "topic", col: 2, row: 5 },
|
|
||||||
{ n: 16, name: "Graph DFS", slug: "graph-dfs", kind: "topic", col: 4, row: 0 },
|
|
||||||
{ n: 18, name: "Topological Sort", slug: "topological-sort", kind: "topic", col: 5, row: 0 },
|
|
||||||
{ n: 19, name: "Union-Find", slug: "union-find", kind: "topic", col: 4, row: 1 },
|
|
||||||
{ n: 20, name: "Backtracking", slug: "backtracking", kind: "topic", col: 5, row: 1 },
|
|
||||||
{ n: 21, name: "1-D DP", sub: "dynamic programming", slug: "1-d-dynamic-programming", kind: "focal", col: 5, row: 2 },
|
|
||||||
{ n: 22, name: "Multi-D / Grid DP", slug: "multi-d-grid-dp", kind: "topic", col: 6, row: 2 },
|
|
||||||
{ n: 17, name: "Graph BFS", slug: "graph-bfs", kind: "topic", col: 4, row: 3 },
|
|
||||||
{ n: 23, name: "Greedy", slug: "greedy", kind: "topic", col: 5, row: 3 },
|
|
||||||
{ n: 24, name: "Intervals", slug: "intervals", kind: "topic", col: 6, row: 3 },
|
|
||||||
];
|
|
||||||
|
|
||||||
/** "builds on" arrows, source → target, in the figure's coordinate space. */
|
|
||||||
const EDGES: [string, string][] = [
|
|
||||||
["3 → 4", "M200,168 H248"],
|
|
||||||
["6 → 7", "M200,240 H248"],
|
|
||||||
["1 → 2", "M200,312 H248"],
|
|
||||||
["8 → 9", "M616,440 H664"],
|
|
||||||
["16 → 18", "M1032,96 H1080"],
|
|
||||||
["21 → 22", "M1224,240 H1272"],
|
|
||||||
["23 → 24", "M1224,312 H1272"],
|
|
||||||
["11 → 16", "M616,84 H888"],
|
|
||||||
["12 → 17", "M616,312 H888"],
|
|
||||||
["11 → 13", "M544,120 V144"],
|
|
||||||
["16 → 19", "M960,120 V144"],
|
|
||||||
["18 → 20", "M1152,120 V144"],
|
|
||||||
["20 → 21", "M1152,192 V216"],
|
|
||||||
["21 → 23", "M1152,264 V288"],
|
|
||||||
["5 → 13", "M200,96 H416 Q424,96 424,104 V160 Q424,168 432,168 H472"],
|
|
||||||
["11 → 14", "M616,96 H640 Q648,96 648,104 V160 Q648,168 656,168 H664"],
|
|
||||||
["11 → 15", "M616,108 H624 Q632,108 632,116 V232 Q632,240 640,240 H664"],
|
|
||||||
["4 → 10", "M392,184 H432 Q440,184 440,192 V504 Q440,512 448,512 H472"],
|
|
||||||
["1 → 9", "M128,336 V560 Q128,568 136,568 H728 Q736,568 736,560 V464"],
|
|
||||||
];
|
|
||||||
|
|
||||||
const ZONES: [string, number, number, number, number, number][] = [
|
|
||||||
// label, x, y, w, h, label plate width
|
|
||||||
["PHASE I — LINEAR STRUCTURES", 40, 40, 368, 312, 168],
|
|
||||||
["PHASE III — HIERARCHICAL", 456, 40, 368, 312, 152],
|
|
||||||
["PHASE II — NODAL & GRID", 456, 384, 368, 168, 144],
|
|
||||||
["PHASES IV–V — RELATIONAL & DECISION-SPACE", 872, 40, 560, 312, 264],
|
|
||||||
];
|
|
||||||
|
|
||||||
// ── theme ────────────────────────────────────────────────────────
|
|
||||||
// Everything is a Blume token so the map follows the site's light/dark
|
|
||||||
// scheme instead of the README's fixed black; tints go through color-mix.
|
|
||||||
|
|
||||||
const fg = "var(--blume-foreground)";
|
|
||||||
const muted = "var(--blume-muted-foreground)";
|
|
||||||
const accent = "var(--blume-accent)";
|
|
||||||
const tint = (color: string, pct: number) => `color-mix(in oklab, ${color} ${pct}%, transparent)`;
|
|
||||||
|
|
||||||
const NODE_STYLE: Record<Kind, { fill: string; stroke: string; strokeWidth: number; tag: string }> = {
|
|
||||||
entry: { fill: tint(muted, 10), stroke: tint(muted, 70), strokeWidth: 1, tag: muted },
|
|
||||||
topic: { fill: tint(fg, 4), stroke: fg, strokeWidth: 1, tag: muted },
|
|
||||||
focal: { fill: tint(accent, 12), stroke: accent, strokeWidth: 1.2, tag: accent },
|
|
||||||
};
|
|
||||||
|
|
||||||
const mono: CSSProperties = { fontFamily: "var(--blume-font-mono, ui-monospace, monospace)" };
|
|
||||||
const sans: CSSProperties = { fontFamily: "var(--blume-font-sans, system-ui, sans-serif)" };
|
|
||||||
|
|
||||||
const toolButton: CSSProperties = {
|
|
||||||
width: "1.75rem",
|
|
||||||
height: "1.75rem",
|
|
||||||
border: "1px solid var(--blume-border)",
|
|
||||||
borderRadius: "9999px",
|
|
||||||
background: "var(--blume-background)",
|
|
||||||
color: muted,
|
|
||||||
font: "inherit",
|
|
||||||
lineHeight: 1,
|
|
||||||
cursor: "pointer",
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── navigation lookup ────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface NavGroup {
|
|
||||||
kind: "group";
|
|
||||||
path: string;
|
|
||||||
route?: string | null;
|
|
||||||
children: unknown[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const isGroup = (v: unknown): v is NavGroup =>
|
|
||||||
typeof v === "object" && v !== null && (v as NavGroup).kind === "group" &&
|
|
||||||
typeof (v as NavGroup).path === "string" && Array.isArray((v as NavGroup).children);
|
|
||||||
|
|
||||||
/** The first page route under a sidebar entry, depth-first. */
|
|
||||||
const firstRoute = (items: unknown[]): string | null => {
|
|
||||||
for (const item of items) {
|
|
||||||
if (typeof item !== "object" || item === null) continue;
|
|
||||||
const v = item as { route?: unknown; children?: unknown };
|
|
||||||
if (typeof v.route === "string") return v.route;
|
|
||||||
if (Array.isArray(v.children)) {
|
|
||||||
const inner = firstRoute(v.children);
|
|
||||||
if (inner) return inner;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** folder path (`/tree-dfs`) → the route a node should open. */
|
|
||||||
const topicRoutes = (nav: unknown): Record<string, string> => {
|
|
||||||
const out: Record<string, string> = {};
|
|
||||||
const sidebar = (nav as { sidebar?: unknown })?.sidebar;
|
|
||||||
if (!Array.isArray(sidebar)) return out;
|
|
||||||
for (const entry of sidebar) {
|
|
||||||
if (!isGroup(entry)) continue;
|
|
||||||
const route = entry.route ?? firstRoute(entry.children);
|
|
||||||
if (route) out[entry.path] = route;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── component ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface ViewBox { x: number; y: number; w: number; h: number }
|
|
||||||
/** The resting view — 1.6× on the centre so node labels read at the content width; 1× stays reachable by zooming out. */
|
|
||||||
const HOME_ZOOM = 1.6;
|
|
||||||
const HOME: ViewBox = { x: (W - W / HOME_ZOOM) / 2, y: (H - H / HOME_ZOOM) / 2, w: W / HOME_ZOOM, h: H / HOME_ZOOM };
|
|
||||||
const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi);
|
|
||||||
|
|
||||||
export default function DependencySpine() {
|
|
||||||
const svgRef = useRef<SVGSVGElement>(null);
|
|
||||||
const [vb, setVbState] = useState<ViewBox>(HOME);
|
|
||||||
// The native wheel listener and back-to-back events read the latest view
|
|
||||||
// from the ref, which is written synchronously — state alone lags a render.
|
|
||||||
const vbRef = useRef(vb);
|
|
||||||
const setVb = (next: ViewBox) => {
|
|
||||||
vbRef.current = next;
|
|
||||||
setVbState(next);
|
|
||||||
};
|
|
||||||
const [routes, setRoutes] = useState<Record<string, string> | null>(null);
|
|
||||||
const drag = useRef<{ x: number; y: number; moved: boolean } | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let live = true;
|
|
||||||
fetch(withBase("/api/docs/navigation.json"))
|
|
||||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
|
|
||||||
.then((nav) => live && setRoutes(topicRoutes(nav)))
|
|
||||||
.catch(() => live && setRoutes({}));
|
|
||||||
return () => { live = false; };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/** Zoom by `factor` (>1 in) about a client-space point; omitted = centre. */
|
|
||||||
const zoomAt = (factor: number, clientX?: number, clientY?: number) => {
|
|
||||||
const rect = svgRef.current?.getBoundingClientRect();
|
|
||||||
const cur = vbRef.current;
|
|
||||||
if (!rect) return;
|
|
||||||
const px = clientX === undefined ? cur.x + cur.w / 2 : cur.x + ((clientX - rect.left) / rect.width) * cur.w;
|
|
||||||
const py = clientY === undefined ? cur.y + cur.h / 2 : cur.y + ((clientY - rect.top) / rect.height) * cur.h;
|
|
||||||
const w = clamp(cur.w / factor, W / MAX_ZOOM, W);
|
|
||||||
const f = cur.w / w;
|
|
||||||
const h = H * (w / W);
|
|
||||||
setVb({ x: clamp(px - (px - cur.x) / f, 0, W - w), y: clamp(py - (py - cur.y) / f, 0, H - h), w, h });
|
|
||||||
};
|
|
||||||
|
|
||||||
// React registers `wheel` as passive, so preventDefault has to go native.
|
|
||||||
useEffect(() => {
|
|
||||||
const svg = svgRef.current;
|
|
||||||
if (!svg) return;
|
|
||||||
const onWheel = (e: WheelEvent) => {
|
|
||||||
if (e.deltaY > 0 && vbRef.current.w >= W) return; // fully zoomed out: let the page scroll
|
|
||||||
e.preventDefault();
|
|
||||||
zoomAt(Math.exp(-e.deltaY * 0.0015), e.clientX, e.clientY);
|
|
||||||
};
|
|
||||||
svg.addEventListener("wheel", onWheel, { passive: false });
|
|
||||||
return () => svg.removeEventListener("wheel", onWheel);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onPointerDown = (e: PointerEvent<SVGSVGElement>) => {
|
|
||||||
if (e.button !== 0) return;
|
|
||||||
drag.current = { x: e.clientX, y: e.clientY, moved: false };
|
|
||||||
};
|
|
||||||
const onPointerMove = (e: PointerEvent<SVGSVGElement>) => {
|
|
||||||
const d = drag.current;
|
|
||||||
const rect = svgRef.current?.getBoundingClientRect();
|
|
||||||
if (!d || !rect) return;
|
|
||||||
const dx = e.clientX - d.x;
|
|
||||||
const dy = e.clientY - d.y;
|
|
||||||
if (!d.moved && Math.hypot(dx, dy) < 4) return; // a click, not a drag
|
|
||||||
if (!d.moved) {
|
|
||||||
// Capture only once it is a drag: capturing on pointerdown would retarget
|
|
||||||
// pointerup (and so the click) to the <svg>, and a node's link would never fire.
|
|
||||||
d.moved = true;
|
|
||||||
e.currentTarget.setPointerCapture(e.pointerId);
|
|
||||||
}
|
|
||||||
d.x = e.clientX;
|
|
||||||
d.y = e.clientY;
|
|
||||||
const cur = vbRef.current;
|
|
||||||
const scale = cur.w / rect.width;
|
|
||||||
setVb({ ...cur, x: clamp(cur.x - dx * scale, 0, W - cur.w), y: clamp(cur.y - dy * scale, 0, H - cur.h) });
|
|
||||||
};
|
|
||||||
const onPointerUp = (e: PointerEvent<SVGSVGElement>) => {
|
|
||||||
if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
|
|
||||||
// Cleared on the next tick so a node's click handler can still see `moved`.
|
|
||||||
setTimeout(() => { drag.current = null; }, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const zoom = W / vb.w;
|
|
||||||
const atHome = vb.x === HOME.x && vb.y === HOME.y && vb.w === HOME.w;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<figure
|
|
||||||
className="not-prose"
|
|
||||||
style={{ margin: "1.5rem 0", border: "1px solid var(--blume-border)", borderRadius: "var(--blume-radius)", background: tint("var(--blume-muted)", 30), overflow: "hidden" }}
|
|
||||||
aria-label="Dependency spine: how the 24 topics build on each other"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "+" || e.key === "=") zoomAt(1.25);
|
|
||||||
else if (e.key === "-") zoomAt(0.8);
|
|
||||||
else if (e.key === "0") setVb(HOME);
|
|
||||||
else return;
|
|
||||||
e.preventDefault();
|
|
||||||
}}
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "0.75rem", padding: "0.5rem 0.75rem", borderBottom: "1px solid var(--blume-border)" }}>
|
|
||||||
<span style={{ ...mono, fontSize: "0.7rem", letterSpacing: "0.12em", textTransform: "uppercase", color: muted, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
|
||||||
scroll to zoom · drag to pan · click a topic
|
|
||||||
</span>
|
|
||||||
<span style={{ display: "flex", alignItems: "center", gap: "0.35rem", flexShrink: 0 }}>
|
|
||||||
<span style={{ ...mono, fontSize: "0.7rem", color: muted, minWidth: "4ch", textAlign: "right", whiteSpace: "nowrap" }}>{zoom.toFixed(1)}×</span>
|
|
||||||
<button type="button" style={toolButton} aria-label="Zoom out" onClick={() => zoomAt(0.8)} disabled={zoom <= 1.001}>−</button>
|
|
||||||
<button type="button" style={toolButton} aria-label="Zoom in" onClick={() => zoomAt(1.25)} disabled={zoom >= MAX_ZOOM - 0.001}>+</button>
|
|
||||||
<button type="button" style={toolButton} aria-label="Reset zoom" onClick={() => setVb(HOME)} disabled={atHome}>⟲</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<svg
|
|
||||||
ref={svgRef}
|
|
||||||
viewBox={`${vb.x} ${vb.y} ${vb.w} ${vb.h}`}
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
role="img"
|
|
||||||
aria-labelledby="dependency-spine-title dependency-spine-desc"
|
|
||||||
style={{ display: "block", width: "100%", height: "auto", touchAction: "none", cursor: drag.current?.moved ? "grabbing" : zoom > 1.001 ? "grab" : "default", userSelect: "none" }}
|
|
||||||
onPointerDown={onPointerDown}
|
|
||||||
onPointerMove={onPointerMove}
|
|
||||||
onPointerUp={onPointerUp}
|
|
||||||
onPointerCancel={onPointerUp}
|
|
||||||
onDoubleClick={() => setVb(HOME)}
|
|
||||||
>
|
|
||||||
<title id="dependency-spine-title">LeetCode topic dependency spine</title>
|
|
||||||
<desc id="dependency-spine-desc">
|
|
||||||
Dependency graph of 24 LeetCode study topics across five phases, showing which topics build on which, with Tree DFS and 1-D dynamic programming as the two central hubs.
|
|
||||||
</desc>
|
|
||||||
<defs>
|
|
||||||
<marker id="spine-arrow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
|
|
||||||
<polygon points="0 0, 8 3, 0 6" style={{ fill: muted }} />
|
|
||||||
</marker>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
{ZONES.map(([label, x, y, w, h, plate]) => (
|
|
||||||
<g key={label}>
|
|
||||||
<rect x={x} y={y} width={w} height={h} rx={8} style={{ fill: tint(fg, 2), stroke: tint(fg, 10), strokeWidth: 0.8 }} />
|
|
||||||
<rect x={x + 16} y={y + 4} width={plate} height={12} rx={2} style={{ fill: "var(--blume-background)" }} />
|
|
||||||
<text x={x + 20} y={y + 13} fontSize={7} letterSpacing="0.14em" style={{ ...mono, fill: tint(fg, 40) }}>{label}</text>
|
|
||||||
</g>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{EDGES.map(([id, d]) => (
|
|
||||||
<path key={id} d={d} fill="none" strokeWidth={1} markerEnd="url(#spine-arrow)" style={{ stroke: muted }}>
|
|
||||||
<title>{id}</title>
|
|
||||||
</path>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{TOPICS.map((t) => {
|
|
||||||
const x = COL_X[t.col]!;
|
|
||||||
const y = ROW_Y[t.row]!;
|
|
||||||
const s = NODE_STYLE[t.kind];
|
|
||||||
const route = routes?.[`/${t.slug}`];
|
|
||||||
const tag = String(t.n).padStart(2, "0");
|
|
||||||
const body = (
|
|
||||||
<>
|
|
||||||
<title>{route ? `${tag} · ${t.name} — open topic` : `${tag} · ${t.name} — no pages yet`}</title>
|
|
||||||
<rect x={x} y={y} width={NODE_W} height={NODE_H} rx={6} style={{ fill: "var(--blume-background)" }} />
|
|
||||||
<rect x={x} y={y} width={NODE_W} height={NODE_H} rx={6} style={{ fill: s.fill, stroke: s.stroke, strokeWidth: s.strokeWidth }} />
|
|
||||||
<rect x={x + 8} y={y + 6} width={24} height={12} rx={2} fill="transparent" style={{ stroke: tint(s.tag, 50), strokeWidth: 0.8 }} />
|
|
||||||
<text x={x + 20} y={y + 15} fontSize={7} textAnchor="middle" letterSpacing="0.08em" style={{ ...mono, fill: s.tag }}>{tag}</text>
|
|
||||||
<text x={x + NODE_W / 2} y={y + (t.sub ? 26 : 32)} fontSize={12} fontWeight={600} textAnchor="middle" style={{ ...sans, fill: fg }}>{t.name}</text>
|
|
||||||
{t.sub && <text x={x + NODE_W / 2} y={y + 40} fontSize={9} textAnchor="middle" style={{ ...mono, fill: muted }}>{t.sub}</text>}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
return route ? (
|
|
||||||
<a
|
|
||||||
key={t.n}
|
|
||||||
href={withBase(route)}
|
|
||||||
className="spine-node"
|
|
||||||
style={{ cursor: "pointer", textDecoration: "none" }}
|
|
||||||
onClick={(e) => { if (drag.current?.moved) e.preventDefault(); }}
|
|
||||||
>
|
|
||||||
{body}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<g key={t.n} style={{ opacity: routes ? 0.55 : 1 }} aria-disabled="true">{body}</g>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<line x1={40} y1={600} x2={1432} y2={600} strokeWidth={0.8} style={{ stroke: tint(fg, 10) }} />
|
|
||||||
<text x={40} y={620} fontSize={8} letterSpacing="0.14em" style={{ ...mono, fill: muted }}>LEGEND</text>
|
|
||||||
<rect x={160} y={608} width={20} height={12} rx={2} style={{ fill: NODE_STYLE.entry.fill, stroke: NODE_STYLE.entry.stroke, strokeWidth: 1 }} />
|
|
||||||
<text x={188} y={618} fontSize={8} letterSpacing="0.06em" style={{ ...mono, fill: muted }}>ENTRY — NO PREREQS</text>
|
|
||||||
<rect x={360} y={608} width={20} height={12} rx={2} style={{ fill: NODE_STYLE.topic.fill, stroke: NODE_STYLE.topic.stroke, strokeWidth: 1 }} />
|
|
||||||
<text x={388} y={618} fontSize={8} letterSpacing="0.06em" style={{ ...mono, fill: muted }}>TOPIC</text>
|
|
||||||
<rect x={480} y={608} width={20} height={12} rx={2} style={{ fill: NODE_STYLE.focal.fill, stroke: NODE_STYLE.focal.stroke, strokeWidth: 1.2 }} />
|
|
||||||
<text x={508} y={618} fontSize={8} letterSpacing="0.06em" style={{ ...mono, fill: muted }}>FOCAL HUB</text>
|
|
||||||
<line x1={624} y1={614} x2={656} y2={614} strokeWidth={1} markerEnd="url(#spine-arrow)" style={{ stroke: muted }} />
|
|
||||||
<text x={668} y={618} fontSize={8} letterSpacing="0.06em" style={{ ...mono, fill: muted }}>BUILDS ON</text>
|
|
||||||
<rect x={792} y={608} width={20} height={12} rx={2} style={{ fill: tint(fg, 2), stroke: tint(fg, 10), strokeWidth: 0.8 }} />
|
|
||||||
<text x={820} y={618} fontSize={8} letterSpacing="0.06em" style={{ ...mono, fill: muted }}>PHASE ZONE</text>
|
|
||||||
<rect x={1000} y={608} width={20} height={12} rx={2} style={{ fill: NODE_STYLE.topic.fill, stroke: NODE_STYLE.topic.stroke, strokeWidth: 1, opacity: 0.55 }} />
|
|
||||||
<text x={1028} y={618} fontSize={8} letterSpacing="0.06em" style={{ ...mono, fill: muted }}>NO PAGES YET</text>
|
|
||||||
</svg>
|
|
||||||
<style>{`.spine-node:hover rect:nth-of-type(2), .spine-node:focus-visible rect:nth-of-type(2) { stroke: ${accent}; stroke-width: 1.6; } .spine-node:focus { outline: none; }`}</style>
|
|
||||||
</figure>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
/**
|
|
||||||
* The ten most recently solved problems — LeetCode's "Recent AC" list. Each
|
|
||||||
* row is dated by the problem's first pass (an ET calendar date: the Worker
|
|
||||||
* logs days, not times), read relative to today in ET with the full date on
|
|
||||||
* hover. A row links to the problem's docs page when one exists: the
|
|
||||||
* sync-generated short links (`redirects.ts`) are exactly the pages built.
|
|
||||||
*/
|
|
||||||
import { StatsPending } from "../components/progress/shared";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
|
||||||
import { useStats, utcDate } from "../lib/stats";
|
|
||||||
import redirects from "../redirects";
|
|
||||||
|
|
||||||
export const client = "load";
|
|
||||||
|
|
||||||
const PAGES = new Map(redirects.map((r) => [r.from, r.to]));
|
|
||||||
|
|
||||||
const ET_TODAY = new Intl.DateTimeFormat("en-CA", { timeZone: "America/New_York" });
|
|
||||||
const RELATIVE = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
|
||||||
const FULL = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" });
|
|
||||||
const DAY_MS = 86_400_000;
|
|
||||||
|
|
||||||
export default function RecentSolves() {
|
|
||||||
const load = useStats();
|
|
||||||
|
|
||||||
if (load.phase !== "ready") {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Recently solved</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<StatsPending load={load} className="h-80 w-full" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const today = utcDate(ET_TODAY.format(new Date())).getTime();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Recently solved</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{load.stats.recent.length === 0 ? (
|
|
||||||
<p className="my-0! text-muted-foreground text-sm">Nothing solved yet.</p>
|
|
||||||
) : (
|
|
||||||
<ol className="my-0! flex flex-col">
|
|
||||||
{load.stats.recent.map((p) => {
|
|
||||||
const date = utcDate(p.date);
|
|
||||||
const page = PAGES.get(`/${p.lc}`);
|
|
||||||
const name = (
|
|
||||||
<>
|
|
||||||
<span className="text-muted-foreground tabular-nums">{p.lc}.</span> {p.title}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={p.lc}
|
|
||||||
className="flex items-center justify-between gap-4 rounded-lg px-4 py-3 text-sm odd:bg-muted"
|
|
||||||
>
|
|
||||||
{page ? (
|
|
||||||
<a href={page} className="truncate font-normal! text-foreground no-underline! hover:underline!">
|
|
||||||
{name}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<span className="truncate">{name}</span>
|
|
||||||
)}
|
|
||||||
<time dateTime={p.date} title={FULL.format(date)} className="shrink-0 text-muted-foreground">
|
|
||||||
{RELATIVE.format(Math.round((date.getTime() - today) / DAY_MS), "day")}
|
|
||||||
</time>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ol>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
/**
|
|
||||||
* Solved-by-difficulty ring — LeetCode's profile gauge. A 270° arc split into
|
|
||||||
* one segment per difficulty, each sized by that difficulty's share of the
|
|
||||||
* catalog; the faint track is the whole set, the solid arc the solved part.
|
|
||||||
* The centre reads the total, or one difficulty while its segment (or its
|
|
||||||
* stat box) is hovered. "Attempting" is the open-miss count: problems whose
|
|
||||||
* latest attempt failed.
|
|
||||||
*/
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Pie, PieChart } from "recharts";
|
|
||||||
import { StatsPending } from "../components/progress/shared";
|
|
||||||
import { Card, CardContent } from "../components/ui/card";
|
|
||||||
import { type ChartConfig, ChartContainer } from "../components/ui/chart";
|
|
||||||
import { DIFFICULTIES, type Difficulty, useStats } from "../lib/stats";
|
|
||||||
import { cn } from "../lib/utils";
|
|
||||||
|
|
||||||
export const client = "load";
|
|
||||||
|
|
||||||
const chartConfig = {
|
|
||||||
easy: { label: "Easy", color: "var(--chart-1)" },
|
|
||||||
medium: { label: "Med.", color: "var(--chart-2)" },
|
|
||||||
hard: { label: "Hard", color: "var(--chart-3)" },
|
|
||||||
} satisfies ChartConfig;
|
|
||||||
|
|
||||||
// Static class names, so Tailwind's scanner sees every one (the chart's
|
|
||||||
// --color-* variables exist only inside ChartContainer).
|
|
||||||
const DIFFICULTY_TEXT: Record<Difficulty, string> = {
|
|
||||||
easy: "text-chart-1",
|
|
||||||
medium: "text-chart-2",
|
|
||||||
hard: "text-chart-3",
|
|
||||||
};
|
|
||||||
|
|
||||||
// The gauge opens at the bottom: from 225° (lower left) clockwise to -45°
|
|
||||||
// (lower right), in Recharts' counter-clockwise-from-3-o'clock degrees.
|
|
||||||
const START = 225;
|
|
||||||
const SWEEP = 270;
|
|
||||||
const GAP = 8;
|
|
||||||
|
|
||||||
export default function SolvedRing() {
|
|
||||||
const load = useStats();
|
|
||||||
const [focus, setFocus] = useState<Difficulty | null>(null);
|
|
||||||
|
|
||||||
if (load.phase !== "ready") {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent>
|
|
||||||
<StatsPending load={load} className="h-52 w-full" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = DIFFICULTIES.map(
|
|
||||||
(difficulty) => load.stats.difficulty.find((d) => d.difficulty === difficulty) ?? { difficulty, total: 0, done: 0 },
|
|
||||||
);
|
|
||||||
const total = rows.reduce((n, r) => n + r.total, 0);
|
|
||||||
const done = rows.reduce((n, r) => n + r.done, 0);
|
|
||||||
const span = SWEEP - GAP * (rows.length - 1);
|
|
||||||
|
|
||||||
let cursor = START;
|
|
||||||
const segments = rows.map((r) => {
|
|
||||||
const size = total > 0 ? (span * r.total) / total : 0;
|
|
||||||
const from = cursor;
|
|
||||||
cursor -= size + GAP;
|
|
||||||
const solved = r.total > 0 ? size * (r.done / r.total) : 0;
|
|
||||||
return { ...r, from, to: from - size, solvedTo: from - solved };
|
|
||||||
});
|
|
||||||
|
|
||||||
const focused = focus ? rows.find((r) => r.difficulty === focus) : undefined;
|
|
||||||
const arc = { innerRadius: "80%", outerRadius: "88%", cornerRadius: 99, stroke: "none", dataKey: "value" } as const;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center gap-6 sm:flex-row">
|
|
||||||
<div className="relative w-full max-w-56 shrink-0">
|
|
||||||
<ChartContainer config={chartConfig} className="aspect-square w-full">
|
|
||||||
<PieChart>
|
|
||||||
{segments.map((s) => (
|
|
||||||
<Pie
|
|
||||||
key={`${s.difficulty}-track`}
|
|
||||||
{...arc}
|
|
||||||
data={[{ value: 1 }]}
|
|
||||||
startAngle={s.from}
|
|
||||||
endAngle={s.to}
|
|
||||||
fill={`var(--color-${s.difficulty})`}
|
|
||||||
fillOpacity={focus && focus !== s.difficulty ? 0.12 : 0.25}
|
|
||||||
isAnimationActive={false}
|
|
||||||
onMouseEnter={() => setFocus(s.difficulty)}
|
|
||||||
onMouseLeave={() => setFocus(null)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{segments
|
|
||||||
.filter((s) => s.done > 0)
|
|
||||||
.map((s) => (
|
|
||||||
<Pie
|
|
||||||
key={`${s.difficulty}-solved`}
|
|
||||||
{...arc}
|
|
||||||
data={[{ value: 1 }]}
|
|
||||||
startAngle={s.from}
|
|
||||||
endAngle={s.solvedTo}
|
|
||||||
fill={`var(--color-${s.difficulty})`}
|
|
||||||
fillOpacity={focus && focus !== s.difficulty ? 0.35 : 1}
|
|
||||||
onMouseEnter={() => setFocus(s.difficulty)}
|
|
||||||
onMouseLeave={() => setFocus(null)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</PieChart>
|
|
||||||
</ChartContainer>
|
|
||||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-1">
|
|
||||||
<div className="flex items-baseline">
|
|
||||||
<span className="font-semibold text-4xl tabular-nums">{focused ? focused.done : done}</span>
|
|
||||||
<span className="text-muted-foreground tabular-nums">/{focused ? focused.total : total}</span>
|
|
||||||
</div>
|
|
||||||
<span className={cn("text-sm", focused ? DIFFICULTY_TEXT[focused.difficulty] : "text-muted-foreground")}>
|
|
||||||
{focused ? chartConfig[focused.difficulty].label : "Solved"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-3 text-center text-muted-foreground text-xs">
|
|
||||||
<span className="font-semibold text-foreground tabular-nums">{load.stats.attempting}</span> Attempting
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-1">
|
|
||||||
{rows.map((r) => (
|
|
||||||
<div
|
|
||||||
key={r.difficulty}
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col items-center gap-0.5 rounded-lg bg-muted px-4 py-2 transition-opacity",
|
|
||||||
focus && focus !== r.difficulty && "opacity-50",
|
|
||||||
)}
|
|
||||||
onPointerEnter={() => setFocus(r.difficulty)}
|
|
||||||
onPointerLeave={() => setFocus(null)}
|
|
||||||
>
|
|
||||||
<span className={cn("font-medium text-sm", DIFFICULTY_TEXT[r.difficulty])}>
|
|
||||||
{chartConfig[r.difficulty].label}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm tabular-nums">
|
|
||||||
{r.done}/{r.total}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
/**
|
|
||||||
* This calendar year by difficulty — LeetCode's solved/submissions bar chart,
|
|
||||||
* sharing its row with the heatmap. Stacked Easy / Med. / Hard bars per month
|
|
||||||
* or per week (Monday-start, like the heatmap rows); "Solved" counts each
|
|
||||||
* problem once, on its first-pass date (the date `recent` uses), while
|
|
||||||
* "Submissions" counts every logged attempt. The totals under the chart are
|
|
||||||
* the year's, in the selected mode. Buckets are cut from `year`, which always
|
|
||||||
* covers January 1 to today.
|
|
||||||
*/
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
|
||||||
import { StatsPending } from "../components/progress/shared";
|
|
||||||
import { Card, CardAction, CardContent, CardFooter, CardHeader } from "../components/ui/card";
|
|
||||||
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "../components/ui/chart";
|
|
||||||
import { ToggleGroup, ToggleGroupItem } from "../components/ui/toggle-group";
|
|
||||||
import { DIFFICULTIES, type Day, type Difficulty, useStats, utcDate } from "../lib/stats";
|
|
||||||
|
|
||||||
export const client = "load";
|
|
||||||
|
|
||||||
const chartConfig = {
|
|
||||||
easy: { label: "Easy", color: "var(--chart-1)" },
|
|
||||||
medium: { label: "Med.", color: "var(--chart-2)" },
|
|
||||||
hard: { label: "Hard", color: "var(--chart-3)" },
|
|
||||||
} satisfies ChartConfig;
|
|
||||||
|
|
||||||
// Static class names, so Tailwind's scanner sees every one.
|
|
||||||
const DIFFICULTY_TEXT: Record<Difficulty, string> = {
|
|
||||||
easy: "text-chart-1",
|
|
||||||
medium: "text-chart-2",
|
|
||||||
hard: "text-chart-3",
|
|
||||||
};
|
|
||||||
|
|
||||||
type Mode = "solved" | "attempts";
|
|
||||||
type Grain = "month" | "week";
|
|
||||||
|
|
||||||
const DAY_MS = 86_400_000;
|
|
||||||
const MONTH = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" });
|
|
||||||
const MONTH_YEAR = new Intl.DateTimeFormat("en-US", { month: "short", year: "numeric", timeZone: "UTC" });
|
|
||||||
const SHORT = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
|
|
||||||
|
|
||||||
interface Bucket extends Record<Difficulty, number> {
|
|
||||||
index: number;
|
|
||||||
/** Axis tick: the month the bucket starts in. */
|
|
||||||
tick: string;
|
|
||||||
/** Tooltip heading. */
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The year's buckets, every one present (future ones empty), filled from `days`. */
|
|
||||||
function buckets(days: Day[], year: number, grain: Grain, mode: Mode): Bucket[] {
|
|
||||||
const jan1 = Date.UTC(year, 0, 1);
|
|
||||||
const lead = (new Date(jan1).getUTCDay() + 6) % 7; // Jan 1's Monday-based weekday
|
|
||||||
const length = Math.round((Date.UTC(year + 1, 0, 1) - jan1) / DAY_MS);
|
|
||||||
const out: Bucket[] = [];
|
|
||||||
if (grain === "month") {
|
|
||||||
for (let m = 0; m < 12; m++) {
|
|
||||||
const start = new Date(Date.UTC(year, m, 1));
|
|
||||||
out.push({ index: m, tick: MONTH.format(start), label: MONTH_YEAR.format(start), easy: 0, medium: 0, hard: 0 });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const weeks = Math.floor((length - 1 + lead) / 7) + 1;
|
|
||||||
for (let w = 0; w < weeks; w++) {
|
|
||||||
const start = new Date(Math.max(jan1, jan1 + (w * 7 - lead) * DAY_MS));
|
|
||||||
const end = new Date(Math.min(jan1 + (length - 1) * DAY_MS, jan1 + (w * 7 - lead + 6) * DAY_MS));
|
|
||||||
out.push({
|
|
||||||
index: w,
|
|
||||||
tick: MONTH.format(start),
|
|
||||||
label: `${SHORT.format(start)} – ${SHORT.format(end)}`,
|
|
||||||
easy: 0,
|
|
||||||
medium: 0,
|
|
||||||
hard: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const day of days) {
|
|
||||||
const date = utcDate(day.date);
|
|
||||||
if (date.getUTCFullYear() !== year) continue;
|
|
||||||
const i = grain === "month" ? date.getUTCMonth() : Math.floor(((date.getTime() - jan1) / DAY_MS + lead) / 7);
|
|
||||||
const bucket = out[i];
|
|
||||||
if (!bucket) continue;
|
|
||||||
for (const d of DIFFICULTIES) bucket[d] += day[mode][d];
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SubmissionChart() {
|
|
||||||
const load = useStats();
|
|
||||||
const [mode, setMode] = useState<Mode>("solved");
|
|
||||||
const [grain, setGrain] = useState<Grain>("month");
|
|
||||||
|
|
||||||
if (load.phase !== "ready") {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent>
|
|
||||||
<StatsPending load={load} className="h-40 w-full" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const year = utcDate(load.stats.year.at(-1)?.date ?? "1970-01-01").getUTCFullYear();
|
|
||||||
const data = buckets(load.stats.year, year, grain, mode);
|
|
||||||
const totals = Object.fromEntries(
|
|
||||||
DIFFICULTIES.map((d) => [d, data.reduce((n, b) => n + b[d], 0)]),
|
|
||||||
);
|
|
||||||
const peak = Math.max(...data.map((b) => b.easy + b.medium + b.hard));
|
|
||||||
// A round ceiling for the one labelled gridline, as LeetCode draws it.
|
|
||||||
const top = peak <= 5 ? 5 : Math.ceil(peak / 10) * 10;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<ToggleGroup
|
|
||||||
type="single"
|
|
||||||
size="sm"
|
|
||||||
spacing={1}
|
|
||||||
value={mode}
|
|
||||||
onValueChange={(value: string) => value && setMode(value as Mode)}
|
|
||||||
className="rounded-full bg-muted p-0.5"
|
|
||||||
aria-label="Count"
|
|
||||||
>
|
|
||||||
<ToggleGroupItem value="solved" className="h-7 rounded-full px-2.5 text-xs">
|
|
||||||
Solved
|
|
||||||
</ToggleGroupItem>
|
|
||||||
<ToggleGroupItem value="attempts" className="h-7 rounded-full px-2.5 text-xs">
|
|
||||||
Submissions
|
|
||||||
</ToggleGroupItem>
|
|
||||||
</ToggleGroup>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<ToggleGroup
|
|
||||||
type="single"
|
|
||||||
size="sm"
|
|
||||||
spacing={1}
|
|
||||||
value={grain}
|
|
||||||
onValueChange={(value: string) => value && setGrain(value as Grain)}
|
|
||||||
className="rounded-full bg-muted p-0.5"
|
|
||||||
aria-label="Bucket size"
|
|
||||||
>
|
|
||||||
<ToggleGroupItem value="month" className="h-7 rounded-full px-2.5 text-xs" aria-label="Per month">
|
|
||||||
M
|
|
||||||
</ToggleGroupItem>
|
|
||||||
<ToggleGroupItem value="week" className="h-7 rounded-full px-2.5 text-xs" aria-label="Per week">
|
|
||||||
W
|
|
||||||
</ToggleGroupItem>
|
|
||||||
</ToggleGroup>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex-1">
|
|
||||||
<ChartContainer config={chartConfig} className="aspect-auto h-40 w-full">
|
|
||||||
<BarChart accessibilityLayer data={data} margin={{ top: 8, right: 0, bottom: 0, left: 0 }}>
|
|
||||||
<CartesianGrid vertical={false} />
|
|
||||||
<XAxis
|
|
||||||
dataKey="index"
|
|
||||||
type="category"
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
tickMargin={6}
|
|
||||||
ticks={[0, data.length - 1]}
|
|
||||||
interval={0}
|
|
||||||
// First label anchored at its start, last at its end, so neither
|
|
||||||
// is clipped when a week band is only a few pixels wide.
|
|
||||||
tick={({ x, y, payload }) => (
|
|
||||||
<text
|
|
||||||
x={x}
|
|
||||||
y={y}
|
|
||||||
dy={10}
|
|
||||||
textAnchor={payload.value === 0 ? "start" : "end"}
|
|
||||||
className="fill-muted-foreground"
|
|
||||||
>
|
|
||||||
{data[Number(payload.value)]?.tick}
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
orientation="right"
|
|
||||||
domain={[0, top]}
|
|
||||||
ticks={[0, top]}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={false}
|
|
||||||
width={28}
|
|
||||||
allowDecimals={false}
|
|
||||||
/>
|
|
||||||
<ChartTooltip
|
|
||||||
cursor={false}
|
|
||||||
// Recharts applies itemSorter only in its default tooltip; this
|
|
||||||
// content gets the stack's bottom-up order, so flip it to read
|
|
||||||
// top-down, Easy first, like the bars.
|
|
||||||
content={({ active, label, payload }) => (
|
|
||||||
<ChartTooltipContent
|
|
||||||
active={active}
|
|
||||||
label={label}
|
|
||||||
payload={[...(payload ?? [])].reverse()}
|
|
||||||
labelFormatter={(_, items) => items[0]?.payload?.label}
|
|
||||||
indicator="dot"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{(["hard", "medium", "easy"] as const).map((d) => (
|
|
||||||
<Bar
|
|
||||||
key={d}
|
|
||||||
dataKey={d}
|
|
||||||
stackId="solved"
|
|
||||||
fill={`var(--color-${d})`}
|
|
||||||
radius={2}
|
|
||||||
maxBarSize={grain === "month" ? 10 : 5}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</BarChart>
|
|
||||||
</ChartContainer>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="gap-4 text-sm">
|
|
||||||
{DIFFICULTIES.map((d) => (
|
|
||||||
<span key={d} className="flex items-baseline gap-1">
|
|
||||||
<span className={DIFFICULTY_TEXT[d]}>{chartConfig[d].label}</span>
|
|
||||||
<span className="text-muted-foreground tabular-nums">{totals[d]}</span>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
<span className="ml-auto text-muted-foreground tabular-nums">{year}</span>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
/**
|
|
||||||
* The last six months of attempts — LeetCode's calendar heatmap, halved to
|
|
||||||
* share its row with the submissions chart: one cell per day, weeks as
|
|
||||||
* columns, each month its own block, and the window's attempt count, active
|
|
||||||
* days and longest streak above it. The figures are counted from exactly the
|
|
||||||
* cells drawn, so they cannot disagree with the grid. The colour ramp is the
|
|
||||||
* README/email heatmap's (theme.css `--heat-0..4`, level = attempts capped at
|
|
||||||
* 4) and rows start on Monday like it, so the three heatmaps read the same.
|
|
||||||
* Drawn as SVG in a viewBox, so it scales to its column instead of scrolling.
|
|
||||||
*/
|
|
||||||
import { StatsPending, TipRow, useHoverTip } from "../components/progress/shared";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
|
|
||||||
import { type Day, useStats, utcDate } from "../lib/stats";
|
|
||||||
|
|
||||||
export const client = "load";
|
|
||||||
|
|
||||||
const MONTHS = 6;
|
|
||||||
const CELL = 10;
|
|
||||||
const STEP = 13; // cell + gap
|
|
||||||
const MONTH_GAP = 8;
|
|
||||||
const LABEL_H = 16;
|
|
||||||
const HEAT = ["var(--heat-0)", "var(--heat-1)", "var(--heat-2)", "var(--heat-3)", "var(--heat-4)"];
|
|
||||||
|
|
||||||
const MONTH = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" });
|
|
||||||
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
|
|
||||||
|
|
||||||
interface Cell {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
date: Date;
|
|
||||||
attempts: number;
|
|
||||||
passes: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Block {
|
|
||||||
label: string;
|
|
||||||
x: number;
|
|
||||||
width: number;
|
|
||||||
cells: Cell[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Month blocks laid left to right over the last MONTHS calendar months. Within
|
|
||||||
* a block a day sits at row = weekday (Mon = 0) and column = its week counted
|
|
||||||
* from the 1st, so a month starting on a Thursday opens with a short column.
|
|
||||||
*/
|
|
||||||
function layout(year: Day[]): { blocks: Block[]; width: number } {
|
|
||||||
const last = utcDate(year.at(-1)?.date ?? "1970-01-01");
|
|
||||||
const from = Date.UTC(last.getUTCFullYear(), last.getUTCMonth() - (MONTHS - 1), 1);
|
|
||||||
const blocks: Block[] = [];
|
|
||||||
let x = 0;
|
|
||||||
for (const day of year) {
|
|
||||||
const date = utcDate(day.date);
|
|
||||||
if (date.getTime() < from) continue;
|
|
||||||
let block = blocks.at(-1);
|
|
||||||
if (date.getUTCDate() === 1 || !block) {
|
|
||||||
if (block) x += block.width + MONTH_GAP;
|
|
||||||
block = { label: MONTH.format(date), x, width: 0, cells: [] };
|
|
||||||
blocks.push(block);
|
|
||||||
}
|
|
||||||
const first = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
|
|
||||||
const index = (first.getUTCDay() + 6) % 7 + date.getUTCDate() - 1;
|
|
||||||
const col = Math.floor(index / 7);
|
|
||||||
block.cells.push({
|
|
||||||
x: x + col * STEP,
|
|
||||||
y: (index % 7) * STEP,
|
|
||||||
date,
|
|
||||||
attempts: day.attempts.easy + day.attempts.medium + day.attempts.hard,
|
|
||||||
passes: day.passes,
|
|
||||||
});
|
|
||||||
block.width = col * STEP + CELL;
|
|
||||||
}
|
|
||||||
const end = blocks.at(-1);
|
|
||||||
return { blocks, width: end ? end.x + end.width : 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SubmissionHeatmap() {
|
|
||||||
const load = useStats();
|
|
||||||
const hover = useHoverTip<HTMLDivElement>();
|
|
||||||
|
|
||||||
if (load.phase !== "ready") {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent>
|
|
||||||
<StatsPending load={load} className="h-40 w-full" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { blocks, width } = layout(load.stats.year);
|
|
||||||
const cells = blocks.flatMap((b) => b.cells);
|
|
||||||
let run = 0;
|
|
||||||
let streak = 0;
|
|
||||||
for (const c of cells) {
|
|
||||||
run = c.attempts > 0 ? run + 1 : 0;
|
|
||||||
streak = Math.max(streak, run);
|
|
||||||
}
|
|
||||||
const height = 7 * STEP - (STEP - CELL) + LABEL_H;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-baseline gap-2">
|
|
||||||
<span className="font-semibold text-2xl tabular-nums">{cells.reduce((n, c) => n + c.attempts, 0)}</span>
|
|
||||||
<span className="font-normal text-muted-foreground text-sm">attempts in the past {MONTHS} months</span>
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="flex gap-4">
|
|
||||||
<span>
|
|
||||||
Active days:{" "}
|
|
||||||
<span className="text-foreground tabular-nums">{cells.filter((c) => c.attempts > 0).length}</span>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
Max streak: <span className="text-foreground tabular-nums">{streak}</span>
|
|
||||||
</span>
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-1 items-center">
|
|
||||||
<div ref={hover.ref} className="relative w-full" onPointerLeave={hover.hide}>
|
|
||||||
<svg
|
|
||||||
viewBox={`0 0 ${width} ${height}`}
|
|
||||||
className="h-auto w-full"
|
|
||||||
role="img"
|
|
||||||
aria-label={`Attempts per day over the past ${MONTHS} months`}
|
|
||||||
>
|
|
||||||
{blocks.map((block) => (
|
|
||||||
<g key={`${block.label}-${block.x}`}>
|
|
||||||
{block.cells.map((cell) => (
|
|
||||||
<rect
|
|
||||||
key={cell.x * 1000 + cell.y}
|
|
||||||
x={cell.x}
|
|
||||||
y={cell.y}
|
|
||||||
width={CELL}
|
|
||||||
height={CELL}
|
|
||||||
rx={2}
|
|
||||||
fill={HEAT[Math.min(cell.attempts, 4)]}
|
|
||||||
onPointerMove={(event) =>
|
|
||||||
hover.show(
|
|
||||||
event,
|
|
||||||
<>
|
|
||||||
<div className="font-medium">{DAY.format(cell.date)}</div>
|
|
||||||
<TipRow color={HEAT[Math.min(cell.attempts, 4)]!} label="Attempts" value={cell.attempts} />
|
|
||||||
<TipRow color="var(--chart-4)" label="Passed" value={cell.passes} />
|
|
||||||
</>,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<text
|
|
||||||
x={block.x + block.width / 2}
|
|
||||||
y={height - 3}
|
|
||||||
textAnchor="middle"
|
|
||||||
className="fill-muted-foreground text-[9px]"
|
|
||||||
>
|
|
||||||
{block.label}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
))}
|
|
||||||
</svg>
|
|
||||||
{hover.node}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
/**
|
|
||||||
* The campaign's topics as packed bubbles — LeetCode's "All Topics" view,
|
|
||||||
* over our curriculum instead of LeetCode's tags. A bubble's area is the
|
|
||||||
* topic's problem count; it fills from the bottom like a glass as problems
|
|
||||||
* are solved, and turns solid once every one is. A topic the schedule has not
|
|
||||||
* reached yet is dimmed. Packing is d3-hierarchy's front-chain (packSiblings,
|
|
||||||
* largest first, so the layout is deterministic), drawn as SVG in a viewBox:
|
|
||||||
* Recharts has no packed layout.
|
|
||||||
*/
|
|
||||||
import { packEnclose, packSiblings } from "d3-hierarchy";
|
|
||||||
import { useId } from "react";
|
|
||||||
import { StatsPending, TipRow, useHoverTip } from "../components/progress/shared";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
|
|
||||||
import { type Stats, useStats } from "../lib/stats";
|
|
||||||
import { cn } from "../lib/utils";
|
|
||||||
|
|
||||||
export const client = "load";
|
|
||||||
|
|
||||||
const PAD = 2; // gap between bubbles, in layout units
|
|
||||||
const UNIT = 12; // radius of a one-problem bubble
|
|
||||||
|
|
||||||
type Topic = Stats["topics"][number];
|
|
||||||
type Bubble = Topic & { x: number; y: number; r: number };
|
|
||||||
|
|
||||||
/** Split a name across lines of at most ~10 characters, never breaking a word. */
|
|
||||||
function lines(name: string): string[] {
|
|
||||||
const out: string[] = [];
|
|
||||||
for (const word of name.split(" ")) {
|
|
||||||
const last = out.at(-1);
|
|
||||||
if (last !== undefined && `${last} ${word}`.length <= 10) out[out.length - 1] = `${last} ${word}`;
|
|
||||||
else out.push(word);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TopicBubbles() {
|
|
||||||
const load = useStats();
|
|
||||||
const hover = useHoverTip<HTMLDivElement>();
|
|
||||||
const clip = useId().replace(/:/g, "");
|
|
||||||
|
|
||||||
if (load.phase !== "ready") {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Topics</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<StatsPending load={load} className="aspect-square w-full" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const topics = load.stats.topics.filter((t) => t.total > 0);
|
|
||||||
const circles: Bubble[] = topics
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => b.total - a.total || a.issue - b.issue)
|
|
||||||
.map((t) => ({ ...t, x: 0, y: 0, r: UNIT * Math.sqrt(t.total) + PAD }));
|
|
||||||
packSiblings(circles);
|
|
||||||
const bound = packEnclose(circles) ?? { x: 0, y: 0, r: 1 };
|
|
||||||
const solved = topics.reduce((n, t) => n + t.done, 0);
|
|
||||||
const total = topics.reduce((n, t) => n + t.total, 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Topics</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{solved} of {total} problems across {topics.length} topics · size is problem count, fill is solved
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div ref={hover.ref} className="relative mx-auto w-full max-w-xl" onPointerLeave={hover.hide}>
|
|
||||||
<svg
|
|
||||||
viewBox={`${bound.x - bound.r} ${bound.y - bound.r} ${bound.r * 2} ${bound.r * 2}`}
|
|
||||||
className="h-auto w-full"
|
|
||||||
role="img"
|
|
||||||
aria-label="Campaign topics sized by problem count and filled by problems solved"
|
|
||||||
>
|
|
||||||
{circles.map((c) => {
|
|
||||||
const r = c.r - PAD;
|
|
||||||
const ratio = c.total > 0 ? c.done / c.total : 0;
|
|
||||||
const complete = c.done >= c.total;
|
|
||||||
const words = lines(c.name);
|
|
||||||
const size = Math.min(r / 3.2, (1.7 * r) / Math.max(...words.map((w) => w.length)));
|
|
||||||
const id = `${clip}-${c.issue}`;
|
|
||||||
return (
|
|
||||||
<g
|
|
||||||
key={c.issue}
|
|
||||||
className={cn("transition-opacity hover:opacity-90", !c.taught && "opacity-50")}
|
|
||||||
onPointerMove={(event) =>
|
|
||||||
hover.show(
|
|
||||||
event,
|
|
||||||
<>
|
|
||||||
<div className="font-medium">{c.name}</div>
|
|
||||||
<TipRow color="var(--chart-4)" label="Solved" value={`${c.done}/${c.total}`} />
|
|
||||||
{!c.taught && <div className="text-muted-foreground">Not reached yet</div>}
|
|
||||||
</>,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<clipPath id={id}>
|
|
||||||
<circle cx={c.x} cy={c.y} r={r} />
|
|
||||||
</clipPath>
|
|
||||||
<circle cx={c.x} cy={c.y} r={r} className="fill-muted stroke-border" strokeWidth={0.5} />
|
|
||||||
<rect
|
|
||||||
x={c.x - r}
|
|
||||||
y={c.y + r - 2 * r * ratio}
|
|
||||||
width={2 * r}
|
|
||||||
height={2 * r * ratio}
|
|
||||||
clipPath={`url(#${id})`}
|
|
||||||
fill="var(--chart-4)"
|
|
||||||
fillOpacity={complete ? 1 : 0.35}
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
x={c.x}
|
|
||||||
y={c.y - ((words.length - 1) * size * 1.15) / 2}
|
|
||||||
textAnchor="middle"
|
|
||||||
dominantBaseline="central"
|
|
||||||
clipPath={`url(#${id})`}
|
|
||||||
className={cn("pointer-events-none select-none", complete ? "fill-background" : "fill-foreground")}
|
|
||||||
style={{ fontSize: size }}
|
|
||||||
>
|
|
||||||
{words.map((w, i) => (
|
|
||||||
<tspan key={`${i}-${w}`} x={c.x} dy={i === 0 ? 0 : size * 1.15}>
|
|
||||||
{w}
|
|
||||||
</tspan>
|
|
||||||
))}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</svg>
|
|
||||||
{hover.node}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Vendored
-14
@@ -1,14 +0,0 @@
|
|||||||
// Islands and the Blume modules they import (blume/components/islands/
|
|
||||||
// base-path.ts) are bundled by Vite, which provides import.meta.env. Vite is
|
|
||||||
// Blume's dependency, not this workspace's, so its client types are not
|
|
||||||
// resolvable from here; this mirrors the slice of them (ImportMetaEnv) tsc needs.
|
|
||||||
interface ImportMeta {
|
|
||||||
readonly env: {
|
|
||||||
readonly [key: string]: unknown;
|
|
||||||
readonly BASE_URL: string;
|
|
||||||
readonly MODE: string;
|
|
||||||
readonly DEV: boolean;
|
|
||||||
readonly PROD: boolean;
|
|
||||||
readonly SSR: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
/**
|
|
||||||
* The `/changelog` page's data: every timever release apps/cli/release-topics.ts
|
|
||||||
* published — one per finished topic and per finished `Review <N>` issue —
|
|
||||||
* read from the GitHub API at build time (pages/changelog.astro awaits it).
|
|
||||||
*
|
|
||||||
* One page, no per-release subpages: each release becomes one timeline card,
|
|
||||||
* so the fields come from the release itself rather than a staged content
|
|
||||||
* entry. The date is the timever tag (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC — the
|
|
||||||
* instant the last required problem closed), never `published_at`, which the
|
|
||||||
* API cannot backdate and which reads the backfill day for every old release.
|
|
||||||
* The problems are the rows of the body's `### Closed …` tables, the shape
|
|
||||||
* release-topics.ts's render() writes: `| #issue | LC n · Name | set | diff |
|
|
||||||
* closed | [py](…) · [docs](…) |`. Only `### Closed …` sections are read, so
|
|
||||||
* a release published before open problems were dropped from the body still
|
|
||||||
* shows only what finished. The row's own `[docs]` link is not used: it is
|
|
||||||
* frozen at release time (a problem solved before the docs existed has
|
|
||||||
* none), while the page links each problem by its LC number to its page now.
|
|
||||||
*
|
|
||||||
* Auth: GITHUB_TOKEN when set (deploy.yml passes it), else unauthenticated —
|
|
||||||
* one request per 100 releases fits the anonymous limit. A failed request
|
|
||||||
* fails the build: a changelog silently missing its releases is worse.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const REPO = "prdlk/leetcode";
|
|
||||||
const MARKER = /<!-- release-topics: #(\d+) -->/u;
|
|
||||||
const TIMEVER = /^(\d{4})(\d{2})(\d{2})\.(\d{2})(\d{2})(\d{2})\.\d{9}$/u;
|
|
||||||
const KIND = /^(Topic|Review) \d+ — /u;
|
|
||||||
|
|
||||||
export interface ReleaseProblem {
|
|
||||||
issue: number;
|
|
||||||
/** The LeetCode number, the key of the docs `/<num>` short links. */
|
|
||||||
lc: number;
|
|
||||||
/** `LC 57 · Insert Interval`. */
|
|
||||||
title: string;
|
|
||||||
set: string;
|
|
||||||
/** `easy` / `medium` / `hard`, the problem's `diff:*` label. */
|
|
||||||
difficulty: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Release {
|
|
||||||
/** Topic or Review issue the release records. */
|
|
||||||
issue: number;
|
|
||||||
name: string;
|
|
||||||
kind: "Topic" | "Review";
|
|
||||||
/** UTC instant the last required problem closed, from the timever tag. */
|
|
||||||
finishedAt: Date;
|
|
||||||
url: string;
|
|
||||||
problems: ReleaseProblem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Rows of every `### Closed …` table in a release body. */
|
|
||||||
function closedProblems(body: string): ReleaseProblem[] {
|
|
||||||
const problems: ReleaseProblem[] = [];
|
|
||||||
let closed = false;
|
|
||||||
for (const line of body.split("\n")) {
|
|
||||||
if (line.startsWith("### ")) closed = line.startsWith("### Closed");
|
|
||||||
if (!closed || !line.startsWith("| #")) continue;
|
|
||||||
const [issue, title, set, difficulty] = line.slice(1, -1).split("|").map((cell) => cell.trim());
|
|
||||||
const lc = title?.match(/^LC (\d+) · /u)?.[1];
|
|
||||||
if (!issue || !title || !lc || !set || !difficulty) continue;
|
|
||||||
problems.push({ issue: Number(issue.slice(1)), lc: Number(lc), title, set, difficulty });
|
|
||||||
}
|
|
||||||
return problems;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readReleases(): Promise<Release[]> {
|
|
||||||
const headers: Record<string, string> = { accept: "application/vnd.github+json" };
|
|
||||||
if (process.env.GITHUB_TOKEN) headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
|
||||||
const releases: Release[] = [];
|
|
||||||
for (let page = 1; ; page++) {
|
|
||||||
const res = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=100&page=${page}`, { headers });
|
|
||||||
if (!res.ok) throw new Error(`GET ${REPO} releases -> ${res.status} ${await res.text()}`);
|
|
||||||
const batch = (await res.json()) as { tag_name: string; name: string | null; body: string | null; html_url: string }[];
|
|
||||||
for (const r of batch) {
|
|
||||||
const issue = r.body?.match(MARKER)?.[1];
|
|
||||||
const t = r.tag_name.match(TIMEVER);
|
|
||||||
const kind = r.name?.match(KIND)?.[1];
|
|
||||||
if (!issue || !t || !kind || !r.name || !r.body) continue;
|
|
||||||
releases.push({
|
|
||||||
issue: Number(issue),
|
|
||||||
name: r.name,
|
|
||||||
kind: kind as Release["kind"],
|
|
||||||
finishedAt: new Date(`${t[1]}-${t[2]}-${t[3]}T${t[4]}:${t[5]}:${t[6]}Z`),
|
|
||||||
url: r.html_url,
|
|
||||||
problems: closedProblems(r.body),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (batch.length < 100) break;
|
|
||||||
}
|
|
||||||
return releases.sort((a, b) => b.finishedAt.getTime() - a.finishedAt.getTime());
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
/**
|
|
||||||
* The progress page's data: GET /api/stats from the SRS Worker, fetched in the
|
|
||||||
* browser so `blume build` never depends on the API being up. API_BASE is the
|
|
||||||
* deployed Worker — do not inline it anywhere else.
|
|
||||||
*
|
|
||||||
* Four islands read this (SolvedRing, SubmissionHeatmap, RecentSolves,
|
|
||||||
* TopicBubbles). Each hydrates on its own, but they share this module, so the
|
|
||||||
* one in-flight request is shared too: the page asks the Worker once.
|
|
||||||
*
|
|
||||||
* Invariants: a malformed body is an error state, never a throw; the first
|
|
||||||
* frame is always the loading state, so server and client render identically.
|
|
||||||
*/
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
const API_BASE = "https://srs-api.prdlk.workers.dev";
|
|
||||||
|
|
||||||
// ── /api/stats response shape (the fields these islands read) ────
|
|
||||||
|
|
||||||
export const DIFFICULTIES = ["easy", "medium", "hard"] as const;
|
|
||||||
|
|
||||||
export type Difficulty = (typeof DIFFICULTIES)[number];
|
|
||||||
|
|
||||||
export interface Stats {
|
|
||||||
generated: string;
|
|
||||||
/** Per difficulty, catalog size and how many entered the ladder. */
|
|
||||||
difficulty: { difficulty: Difficulty; total: number; done: number }[];
|
|
||||||
/** Problems whose most recent attempt failed — the sweep day's miss log. */
|
|
||||||
attempting: number;
|
|
||||||
topics: { issue: number; name: string; total: number; done: number; taught: boolean }[];
|
|
||||||
/** The ten most recently solved, newest first, dated by first pass (ET). */
|
|
||||||
recent: { lc: number; title: string; difficulty: Difficulty; date: string }[];
|
|
||||||
/**
|
|
||||||
* The 365 days ending today (ET), zeros included — always the current
|
|
||||||
* calendar year to date. Per difficulty: every logged attempt, and every
|
|
||||||
* first solve (the first-pass date `recent` uses).
|
|
||||||
*/
|
|
||||||
year: Day[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Day {
|
|
||||||
date: string;
|
|
||||||
passes: number;
|
|
||||||
attempts: Record<Difficulty, number>;
|
|
||||||
solved: Record<Difficulty, number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isStats(value: unknown): value is Stats {
|
|
||||||
if (typeof value !== "object" || value === null) return false;
|
|
||||||
const v = value as Record<string, unknown>;
|
|
||||||
return (
|
|
||||||
Array.isArray(v.difficulty) &&
|
|
||||||
typeof v.attempting === "number" &&
|
|
||||||
Array.isArray(v.topics) &&
|
|
||||||
Array.isArray(v.recent) &&
|
|
||||||
Array.isArray(v.year)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── the shared request ───────────────────────────────────────────
|
|
||||||
|
|
||||||
export type Load = { phase: "loading" } | { phase: "error" } | { phase: "ready"; stats: Stats };
|
|
||||||
|
|
||||||
let request: Promise<Stats | null> | undefined;
|
|
||||||
|
|
||||||
/** The page's stats, fetched once however many islands ask. */
|
|
||||||
export function useStats(): Load {
|
|
||||||
const [load, setLoad] = useState<Load>({ phase: "loading" });
|
|
||||||
useEffect(() => {
|
|
||||||
request ??= fetch(`${API_BASE}/api/stats`)
|
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
|
||||||
.then((body: unknown) => (isStats(body) ? body : null))
|
|
||||||
.catch(() => null);
|
|
||||||
let live = true;
|
|
||||||
request.then((stats) => {
|
|
||||||
if (live) setLoad(stats ? { phase: "ready", stats } : { phase: "error" });
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
live = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
return load;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "YYYY-MM-DD" → a UTC-midnight Date, so calendar math never drifts with the reader's timezone. */
|
|
||||||
export function utcDate(iso: string): Date {
|
|
||||||
const [y, m, d] = iso.split("-").map(Number);
|
|
||||||
return new Date(Date.UTC(y!, m! - 1, d!));
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
/**
|
|
||||||
* One colour per topic tag, unique across the site: the problem-page badge
|
|
||||||
* row (`components/ProblemBadges.astro`) paints each tag with it, so a tag
|
|
||||||
* reads the same on every page and no two tags share a colour.
|
|
||||||
*
|
|
||||||
* Fifty-odd hues cannot all be told apart, so uniqueness alone is not the
|
|
||||||
* goal: tags that share a problem page must be told apart, since that is
|
|
||||||
* where they are seen together. The palette is a fixed grid in OKLCH, a few
|
|
||||||
* lightness rows by `HUE_STEP` hues, and tags are coloured greedily, most-used
|
|
||||||
* first. Each tag takes the free palette colour farthest (OKLab distance)
|
|
||||||
* from every colour its co-tags already hold, ties broken by distance from
|
|
||||||
* every colour taken at all. The answer is a pure function of the pages'
|
|
||||||
* tags, so it is identical on every page of one build; a newly solved problem
|
|
||||||
* can shift colours in the next.
|
|
||||||
*
|
|
||||||
* Blume's Badge takes a custom colour only as hex and uses that one value in
|
|
||||||
* both themes, as text over a 12% tint of itself, so the rows sit in the
|
|
||||||
* narrow lightness band where that text holds ≥ 3:1 contrast on the light and
|
|
||||||
* the dark background alike; wider rows spread tags further but fail one theme.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const LIGHTNESS = [0.55, 0.6];
|
|
||||||
const CHROMA = 0.15;
|
|
||||||
const HUE_STEP = 5;
|
|
||||||
|
|
||||||
interface Swatch {
|
|
||||||
hex: string;
|
|
||||||
lab: [number, number, number];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* slug → `#rrggbb`, pairwise distinct, for every slug in `slugs`; `pages` is
|
|
||||||
* each problem page's tag slugs, the co-occurrence the spread is judged by.
|
|
||||||
*/
|
|
||||||
export function tagColors(slugs: Iterable<string>, pages: readonly (readonly string[])[]): Map<string, string> {
|
|
||||||
const universe = new Set(slugs);
|
|
||||||
const uses = new Map<string, number>();
|
|
||||||
const cotags = new Map<string, Set<string>>();
|
|
||||||
for (const page of pages) {
|
|
||||||
const on = page.filter((slug) => universe.has(slug));
|
|
||||||
for (const slug of on) {
|
|
||||||
uses.set(slug, (uses.get(slug) ?? 0) + 1);
|
|
||||||
const set = cotags.get(slug) ?? new Set<string>();
|
|
||||||
for (const other of on) if (other !== slug) set.add(other);
|
|
||||||
cotags.set(slug, set);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const order = [...universe].sort((a, b) => (uses.get(b) ?? 0) - (uses.get(a) ?? 0) || a.localeCompare(b));
|
|
||||||
|
|
||||||
const palette = swatches(order.length);
|
|
||||||
const free = new Set(palette.keys());
|
|
||||||
const chosen = new Map<string, Swatch>();
|
|
||||||
for (const slug of order) {
|
|
||||||
const near = [...(cotags.get(slug) ?? [])].flatMap((other) => chosen.get(other) ?? []);
|
|
||||||
const all = [...chosen.values()];
|
|
||||||
let best = -1;
|
|
||||||
let bestScore: [number, number] = [-1, -1];
|
|
||||||
for (const i of free) {
|
|
||||||
const score: [number, number] = [nearest(palette[i]!, near), nearest(palette[i]!, all)];
|
|
||||||
if (score[0] > bestScore[0] || (score[0] === bestScore[0] && score[1] > bestScore[1])) {
|
|
||||||
best = i;
|
|
||||||
bestScore = score;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
free.delete(best);
|
|
||||||
chosen.set(slug, palette[best]!);
|
|
||||||
}
|
|
||||||
return new Map([...chosen].map(([slug, swatch]) => [slug, swatch.hex]));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Distance from `swatch` to the closest of `others`; Infinity when there are none. */
|
|
||||||
function nearest(swatch: Swatch, others: readonly Swatch[]): number {
|
|
||||||
let min = Number.POSITIVE_INFINITY;
|
|
||||||
for (const other of others) {
|
|
||||||
const d = Math.hypot(swatch.lab[0] - other.lab[0], swatch.lab[1] - other.lab[1], swatch.lab[2] - other.lab[2]);
|
|
||||||
if (d < min) min = d;
|
|
||||||
}
|
|
||||||
return min;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The palette: every `HUE_STEP` degrees in each `LIGHTNESS` row, the step
|
|
||||||
* shrunk when there are more tags than swatches. Duplicate hexes (possible
|
|
||||||
* only at a tiny step) are dropped, which keeps every assignment unique.
|
|
||||||
*/
|
|
||||||
function swatches(count: number): Swatch[] {
|
|
||||||
const step = Math.min(HUE_STEP, Math.max(1, Math.floor((360 * LIGHTNESS.length) / Math.max(count, 1))));
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const out: Swatch[] = [];
|
|
||||||
for (const lightness of LIGHTNESS) {
|
|
||||||
for (let hue = 0; hue < 360; hue += step) {
|
|
||||||
const swatch = oklch(lightness, hue);
|
|
||||||
if (seen.has(swatch.hex)) continue;
|
|
||||||
seen.add(swatch.hex);
|
|
||||||
out.push(swatch);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `oklch(lightness CHROMA hue)` as sRGB hex plus its OKLab coordinates. Hues
|
|
||||||
* sRGB cannot reach at full chroma (cyans) give up chroma until they fit,
|
|
||||||
* rather than being clipped per channel, which would shift the hue.
|
|
||||||
*/
|
|
||||||
function oklch(lightness: number, hue: number): Swatch {
|
|
||||||
const rad = (hue * Math.PI) / 180;
|
|
||||||
for (let chroma = CHROMA; ; chroma -= 0.005) {
|
|
||||||
const a = chroma * Math.cos(rad);
|
|
||||||
const b = chroma * Math.sin(rad);
|
|
||||||
const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3;
|
|
||||||
const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3;
|
|
||||||
const s = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3;
|
|
||||||
const rgb = [
|
|
||||||
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
|
||||||
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
|
||||||
-0.0041960863 * l - 0.7034186147 * m + 1.707614681 * s,
|
|
||||||
];
|
|
||||||
if (chroma > 0 && rgb.some((c) => c < 0 || c > 1)) continue;
|
|
||||||
const hex = rgb
|
|
||||||
.map((c) => {
|
|
||||||
const v = Math.min(1, Math.max(0, c));
|
|
||||||
const srgb = v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;
|
|
||||||
return Math.round(srgb * 255).toString(16).padStart(2, "0");
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
return { hex: `#${hex}`, lab: [lightness, a, b] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { type ClassValue, clsx } from "clsx";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
/** shadcn's class combiner: conditional classes, later Tailwind utilities win. */
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs));
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "docs",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "blume dev",
|
|
||||||
"build": "blume build",
|
|
||||||
"doctor": "blume doctor"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"blume": "^2.1.0",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"d3-hierarchy": "^3.1.2",
|
|
||||||
"radix-ui": "^1.6.7",
|
|
||||||
"react": "^19.2.8",
|
|
||||||
"react-dom": "^19.2.8",
|
|
||||||
"recharts": "3.8.0",
|
|
||||||
"tailwind-merge": "^3.7.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@fontsource-variable/inter": "^5.3.0",
|
|
||||||
"@fontsource/ibm-plex-mono": "^5.3.0",
|
|
||||||
"@types/d3-hierarchy": "^3.1.7",
|
|
||||||
"@types/react": "^19.3.0",
|
|
||||||
"@types/react-dom": "^19.3.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
---
|
|
||||||
/**
|
|
||||||
* `/changelog`: every topic and review release on one page, newest first — a
|
|
||||||
* vertical timeline, the date on the left and one card per release on the
|
|
||||||
* right, with no per-release subpages. Replaces Blume's generated changelog
|
|
||||||
* (a custom page wins its route); the header links here from
|
|
||||||
* `navigation.actions` in blume.config.ts. Data: lib/releases.ts.
|
|
||||||
*/
|
|
||||||
import PageLayout from "blume/components/layout/PageLayout.astro";
|
|
||||||
import data from "blume:data";
|
|
||||||
import { withBase } from "blume/components/islands/base-path.ts";
|
|
||||||
import redirects from "../redirects";
|
|
||||||
import { readReleases } from "../lib/releases.ts";
|
|
||||||
|
|
||||||
const { config } = data;
|
|
||||||
const releases = await readReleases();
|
|
||||||
const DATE = new Intl.DateTimeFormat("en-US", {
|
|
||||||
timeZone: "America/New_York",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
const issueUrl = (n: number) => `https://github.com/prdlk/leetcode/issues/${n}`;
|
|
||||||
// LC number -> its docs page route, off sync's `/<num>` short links — the
|
|
||||||
// page itself, not the redirect, so a click is one hop. A problem with no page
|
|
||||||
// (none yet) links to its issue instead.
|
|
||||||
const pages = new Map(redirects.map((r) => [Number(r.from.slice(1)), r.to]));
|
|
||||||
const problemHref = (lc: number, issue: number) => {
|
|
||||||
const route = pages.get(lc);
|
|
||||||
return route ? withBase(route) : issueUrl(issue);
|
|
||||||
};
|
|
||||||
// The /progress cards' difficulty palette (--chart-1..3 in theme.css), so a
|
|
||||||
// Hard reads the same colour here as in the SolvedRing gauge.
|
|
||||||
const DIFFICULTY: Record<string, { label: string; class: string }> = {
|
|
||||||
easy: { label: "Easy", class: "border-chart-1/40 bg-chart-1/10 text-chart-1" },
|
|
||||||
medium: { label: "Med.", class: "border-chart-2/40 bg-chart-2/10 text-chart-2" },
|
|
||||||
hard: { label: "Hard", class: "border-chart-3/40 bg-chart-3/10 text-chart-3" },
|
|
||||||
};
|
|
||||||
const description = "Every topic and review the campaign finished, newest first.";
|
|
||||||
---
|
|
||||||
|
|
||||||
<PageLayout
|
|
||||||
site={{ title: config.title, description: config.description }}
|
|
||||||
logo={config.logo}
|
|
||||||
favicon={config.favicon}
|
|
||||||
banner={config.banner}
|
|
||||||
analytics={config.analytics}
|
|
||||||
navigation={data.navigation}
|
|
||||||
fontCssVars={data.fontCssVars}
|
|
||||||
themeMode={config.theme.mode}
|
|
||||||
searchEnabled={config.search.enabled}
|
|
||||||
siteUrl={config.site}
|
|
||||||
ogEnabled={config.og.enabled}
|
|
||||||
structuredDataEnabled={config.structuredData}
|
|
||||||
page={{ title: `Changelog - ${config.title}`, description, route: "/changelog" }}
|
|
||||||
>
|
|
||||||
<section class="mx-auto w-full max-w-4xl px-6 py-16">
|
|
||||||
<h1 class="m-0 text-4xl font-semibold tracking-tight text-foreground">Changelog</h1>
|
|
||||||
<p class="mt-3 text-lg text-muted-foreground">{description}</p>
|
|
||||||
|
|
||||||
<ol class="relative mt-12 list-none p-0">
|
|
||||||
{
|
|
||||||
releases.map((r, i) => (
|
|
||||||
<li class="relative grid gap-4 pb-14 pl-8 sm:grid-cols-[11rem_1fr] sm:gap-8">
|
|
||||||
{/* The rail: a line down the left edge, a ring at each release. */}
|
|
||||||
{i < releases.length - 1 && (
|
|
||||||
<span class="absolute top-3 bottom-0 left-[5px] w-px bg-border" aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
class="absolute top-1.5 left-0 size-[11px] rounded-full border border-emerald-500 bg-background"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<div class="font-mono text-sm">
|
|
||||||
<time class="block uppercase tracking-wide text-foreground" datetime={r.finishedAt.toISOString()}>
|
|
||||||
{DATE.format(r.finishedAt)}
|
|
||||||
</time>
|
|
||||||
<a class="mt-1 block text-xs text-muted-foreground no-underline hover:underline" href={r.url}>
|
|
||||||
{r.kind} release ↗
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<article id={`issue-${r.issue}`} class="rounded-lg border border-border bg-muted/30 p-6">
|
|
||||||
<header class="flex items-baseline justify-between gap-4">
|
|
||||||
<h2 class="m-0 text-lg font-semibold text-foreground">{r.name}</h2>
|
|
||||||
<a
|
|
||||||
class="shrink-0 font-mono text-sm text-muted-foreground no-underline hover:underline"
|
|
||||||
href={issueUrl(r.issue)}
|
|
||||||
>
|
|
||||||
#{r.issue}
|
|
||||||
</a>
|
|
||||||
</header>
|
|
||||||
<ul class="mt-4 mb-0 list-none space-y-2 p-0">
|
|
||||||
{r.problems.map((p) => (
|
|
||||||
<li class="flex items-baseline gap-3 text-base leading-relaxed text-muted-foreground">
|
|
||||||
<span class="select-none text-border" aria-hidden="true">—</span>
|
|
||||||
<span class="min-w-0">
|
|
||||||
<span class="mr-2 font-mono text-xs uppercase tracking-wide">{p.set}</span>
|
|
||||||
<a class="text-foreground no-underline hover:underline" href={problemHref(p.lc, p.issue)}>
|
|
||||||
{p.title}
|
|
||||||
</a>
|
|
||||||
{DIFFICULTY[p.difficulty] && (
|
|
||||||
<span
|
|
||||||
class:list={[
|
|
||||||
"ml-2 inline-block rounded-full border px-2 py-px align-middle font-mono text-[11px] leading-4",
|
|
||||||
DIFFICULTY[p.difficulty]!.class,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{DIFFICULTY[p.difficulty]!.label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</article>
|
|
||||||
</li>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</ol>
|
|
||||||
</section>
|
|
||||||
</PageLayout>
|
|
||||||
Loaded 100 of 265 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user