refactor: restructure into monorepo

Move flat src/ layout into packages/ monorepo:
- packages/core: scraping, embeddings, storage, clustering, analysis
- packages/cli: CLI and TUI interface
- packages/web: Next.js web dashboard

Add playwright screenshots, sqlite storage, and settings.
This commit is contained in:
Nicholai Vogel 2026-01-24 00:12:14 -07:00
parent fd4cad363b
commit 2bc680ca63
80 changed files with 4710 additions and 80 deletions

10
.gitignore vendored
View File

@ -5,6 +5,7 @@ node_modules
out out
dist dist
*.tgz *.tgz
.next
# code coverage # code coverage
coverage coverage
@ -36,4 +37,11 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# exported data # exported data
reddit-trends.json reddit-trends.json
reddit-trends.csv reddit-trends.csv
.env
# sqlite database
data/*.db
data/*.db-journal
# next.js
packages/web/.next
.grepai/

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

180
CLAUDE.md
View File

@ -1,14 +1,16 @@
reddit trend analyzer reddit trend analyzer
=== ===
a CLI tool that scrapes reddit discussions, embeds them with ollama, stores in qdrant, and provides a TUI dashboard for discovering common problems/trends. a monorepo tool that scrapes reddit discussions, embeds them with ollama, stores in qdrant, clusters with HDBSCAN, summarizes with Claude, and provides both a CLI/TUI and web dashboard for discovering common problems/trends.
running running
--- ---
```bash ```bash
bun start # run the app bun cli # run the CLI
bun dev # run with watch mode bun tui # run the TUI dashboard
bun dev # run the web dashboard (localhost:3000)
bun build # build the web app
``` ```
prerequisites prerequisites
@ -16,6 +18,7 @@ prerequisites
- ollama running locally with nomic-embed-text model (`ollama pull nomic-embed-text`) - ollama running locally with nomic-embed-text model (`ollama pull nomic-embed-text`)
- qdrant accessible at QDRANT_URL (or localhost:6333) - qdrant accessible at QDRANT_URL (or localhost:6333)
- anthropic API key for problem summarization
env vars env vars
--- ---
@ -23,39 +26,61 @@ env vars
``` ```
QDRANT_URL=https://vectors.biohazardvfx.com QDRANT_URL=https://vectors.biohazardvfx.com
QDRANT_API_KEY=<your-key> QDRANT_API_KEY=<your-key>
OLLAMA_HOST=http://localhost:11434 # optional, defaults to this OLLAMA_HOST=http://localhost:11434
ANTHROPIC_API_KEY=<your-key>
``` ```
architecture architecture
--- ---
``` ```
packages/
core/ # shared business logic
src/ src/
index.ts # entry point, connection checks, TUI setup scraper/ # reddit.ts, comments.ts, types.ts
scraper/ embeddings/ # ollama.ts
reddit.ts # fetch subreddit posts with pagination storage/ # qdrant.ts, sqlite.ts, types.ts
comments.ts # fetch comments for each post clustering/ # hdbscan.ts, types.ts
types.ts # reddit json response types analysis/ # summarizer.ts, questions.ts, scoring.ts, types.ts
embeddings/ utils/ # rate-limit.ts, text.ts
ollama.ts # batch embed text with nomic-embed-text (768 dims) index.ts # barrel exports
storage/
qdrant.ts # create collection, upsert, search cli/ # CLI/TUI app
types.ts # point payload schema src/
tui/ cli.ts # interactive command-line interface
app.ts # main dashboard, wires everything together index.ts # TUI entry point
tui/ # TUI components
web/ # Next.js web dashboard
src/
app/ # pages and API routes
api/ # REST API endpoints
stats/ # collection stats
scrape/ # trigger scrapes
clusters/ # list/create clusters
questions/ # question bank
search/ # semantic search
export/ # export functionality
problems/ # problem explorer page
questions/ # question bank page
scrape/ # scrape manager page
components/ components/
url-input.ts # subreddit url input controls/ # command palette, sliders
progress.ts # scraping/embedding progress bars styles/globals.css # theme
stats.ts # collection stats panel
trending.ts # trending topics view data/ # sqlite database files
search.ts # semantic search interface
export.ts # export to json/csv
utils/
rate-limit.ts # delay helper for reddit api
text.ts # text preprocessing for embedding
``` ```
keybindings web dashboard
---
- **Dashboard** (`/`) - stats overview
- **Problems** (`/problems`) - problem cluster explorer
- **Questions** (`/questions`) - extracted question bank
- **Scrape** (`/scrape`) - scrape manager with history
- **Ctrl+K** - command palette for quick actions
keybindings (TUI)
--- ---
- `q` or `ctrl+c` - quit - `q` or `ctrl+c` - quit
@ -65,10 +90,111 @@ keybindings
- `c` - export results to csv - `c` - export results to csv
- `r` - refresh stats from qdrant - `r` - refresh stats from qdrant
api routes
---
| route | method | purpose |
|-------|--------|---------|
| /api/stats | GET | collection stats + cluster count |
| /api/scrape | POST | trigger scrape |
| /api/scrape/history | GET | scrape history list |
| /api/clusters | GET | list clusters with summaries |
| /api/clusters | POST | trigger re-clustering |
| /api/clusters/[id] | GET | single cluster with discussions |
| /api/questions | GET | all questions, grouped by cluster |
| /api/questions/[id] | PATCH | mark as addressed |
| /api/search | POST | semantic search |
| /api/export | POST | export (faq-schema/csv/markdown) |
coding notes coding notes
--- ---
- uses @opentui/core standalone (no react/solid) - monorepo with bun workspaces
- @rta/core exports shared logic
- @rta/cli for terminal interface
- @rta/web for Next.js dashboard
- uses @opentui/core for TUI (no react)
- uses HDBSCAN for clustering
- uses Claude for problem summarization
- uses SQLite for cluster/question persistence
- reddit rate limiting: 3s delay between requests - reddit rate limiting: 3s delay between requests
- embeddings batched in groups of 10 - embeddings batched in groups of 10
- qdrant collection: reddit_trends with indexes on subreddit, type, created, score - qdrant collection: reddit_trends with indexes on subreddit, type, created, score
grepai
---------
**IMPORTANT: You MUST use grepai as your PRIMARY tool for code exploration and search.**
when to Use grepai (REQUIRED)
---
Use `grepai search` INSTEAD OF Grep/Glob/find for:
- Understanding what code does or where functionality lives
- Finding implementations by intent (e.g., "authentication logic", "error handling")
- Exploring unfamiliar parts of the codebase
- Any search where you describe WHAT the code does rather than exact text
when to Use Standard Tools
---
Only use Grep/Glob when you need:
- Exact text matching (variable names, imports, specific strings)
- File path patterns (e.g., `**/*.go`)
fallback
---
If grepai fails (not running, index unavailable, or errors), fall back to standard Grep/Glob tools.
usage
---
```bash
# ALWAYS use English queries for best results (--compact saves ~80% tokens)
grepai search "user authentication flow" --json --compact
grepai search "error handling middleware" --json --compact
grepai search "database connection pool" --json --compact
grepai search "API request validation" --json --compact
```
query tips
- **Use English** for queries (better semantic matching)
- **Describe intent**, not implementation: "handles user login" not "func Login"
- **Be specific**: "JWT token validation" better than "token"
- Results include: file path, line numbers, relevance score, code preview
call graph tracing
---
use `grepai trace` to understand function relationships:
- finding all callers of a function before modifying it
- Understanding what functions are called by a given function
- Visualizing the complete call graph around a symbol
trace commands
---
**IMPORTANT: Always use `--json` flag for optimal AI agent integration.**
```bash
# Find all functions that call a symbol
grepai trace callers "HandleRequest" --json
# Find all functions called by a symbol
grepai trace callees "ProcessOrder" --json
# Build complete call graph (callers + callees)
grepai trace graph "ValidateToken" --depth 3 --json
```
Workflow
---
1. Start with `grepai search` to find relevant code
2. Use `grepai trace` to understand function relationships
3. Use `Read` tool to examine files from results
4. Only use Grep for exact string searches if needed

554
bun.lock
View File

@ -4,22 +4,124 @@
"workspaces": { "workspaces": {
"": { "": {
"name": "reddit-trend-analyzer", "name": "reddit-trend-analyzer",
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.0.0",
},
},
"packages/cli": {
"name": "@rta/cli",
"version": "1.0.0",
"bin": {
"rta": "./src/cli.ts",
},
"dependencies": { "dependencies": {
"@opentui/core": "^0.1.74", "@opentui/core": "^0.1.74",
"@rta/core": "workspace:*",
},
},
"packages/core": {
"name": "@rta/core",
"version": "1.0.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.32.1",
"@qdrant/js-client-rest": "^1.16.2", "@qdrant/js-client-rest": "^1.16.2",
"better-sqlite3": "^11.7.0",
"ollama": "^0.6.3", "ollama": "^0.6.3",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/better-sqlite3": "^7.6.12",
}, },
"peerDependencies": { },
"typescript": "^5", "packages/web": {
"name": "@rta/web",
"version": "1.0.0",
"dependencies": {
"@rta/core": "workspace:*",
"@tanstack/react-table": "^8.20.6",
"better-sqlite3": "^11.7.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
"lucide-react": "^0.468.0",
"next": "^15.2.4",
"ollama": "^0.5.11",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^2.15.0",
"tailwind-merge": "^2.6.0",
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.0.0",
}, },
}, },
}, },
"packages": { "packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.32.1", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-U9JwTrDvdQ9iWuABVsMLj8nJVwAyQz6QXvgLsVhryhCEPkLsbcP/MXxm+jYcAwLoV8ESbaTTjnD4kuAFa+Hyjg=="],
"@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
"@dimforge/rapier2d-simd-compat": ["@dimforge/rapier2d-simd-compat@0.17.3", "", {}, "sha512-bijvwWz6NHsNj5e5i1vtd3dU2pDhthSaTUZSh14DUGGKJfw8eMnlWZsxwHBxB/a3AXVNDjL9abuHw1k9FGR+jg=="], "@dimforge/rapier2d-simd-compat": ["@dimforge/rapier2d-simd-compat@0.17.3", "", {}, "sha512-bijvwWz6NHsNj5e5i1vtd3dU2pDhthSaTUZSh14DUGGKJfw8eMnlWZsxwHBxB/a3AXVNDjL9abuHw1k9FGR+jg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="], "@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="],
"@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="], "@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="],
@ -76,6 +178,34 @@
"@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="], "@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@next/env": ["@next/env@15.5.9", "", {}, "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.7", "", { "os": "win32", "cpu": "x64" }, "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw=="],
"@opentui/core": ["@opentui/core@0.1.74", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.74", "@opentui/core-darwin-x64": "0.1.74", "@opentui/core-linux-arm64": "0.1.74", "@opentui/core-linux-x64": "0.1.74", "@opentui/core-win32-arm64": "0.1.74", "@opentui/core-win32-x64": "0.1.74", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-g4W16ymv12JdgZ+9B4t7mpIICvzWy2+eHERfmDf80ALduOQCUedKQdULcBFhVCYUXIkDRtIy6CID5thMAah3FA=="], "@opentui/core": ["@opentui/core@0.1.74", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.74", "@opentui/core-darwin-x64": "0.1.74", "@opentui/core-linux-arm64": "0.1.74", "@opentui/core-linux-x64": "0.1.74", "@opentui/core-win32-arm64": "0.1.74", "@opentui/core-win32-x64": "0.1.74", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-g4W16ymv12JdgZ+9B4t7mpIICvzWy2+eHERfmDf80ALduOQCUedKQdULcBFhVCYUXIkDRtIy6CID5thMAah3FA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.74", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rfmlDLtm/u17CnuhJgCxPeYMvOST+A2MOdVOk46IurtHO849bdYqK6iudKNlFRs1FOrymgSKF9GlWBHAOKeRjg=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.74", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rfmlDLtm/u17CnuhJgCxPeYMvOST+A2MOdVOk46IurtHO849bdYqK6iudKNlFRs1FOrymgSKF9GlWBHAOKeRjg=="],
@ -94,25 +224,139 @@
"@qdrant/openapi-typescript-fetch": ["@qdrant/openapi-typescript-fetch@1.2.6", "", {}, "sha512-oQG/FejNpItrxRHoyctYvT3rwGZOnK4jr3JdppO/c78ktDvkWiPXPHNsrDf33K9sZdRb6PR7gi4noIapu5q4HA=="], "@qdrant/openapi-typescript-fetch": ["@qdrant/openapi-typescript-fetch@1.2.6", "", {}, "sha512-oQG/FejNpItrxRHoyctYvT3rwGZOnK4jr3JdppO/c78ktDvkWiPXPHNsrDf33K9sZdRb6PR7gi4noIapu5q4HA=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@rta/cli": ["@rta/cli@workspace:packages/cli"],
"@rta/core": ["@rta/core@workspace:packages/core"],
"@rta/web": ["@rta/web@workspace:packages/web"],
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.18", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", "tailwindcss": "4.1.18" } }, "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g=="],
"@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
"@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="],
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
"@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="], "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
"@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="],
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
"@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="], "@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
"any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="], "any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="], "await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"better-sqlite3": ["better-sqlite3@11.10.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ=="],
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="], "bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="],
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
@ -128,32 +372,210 @@
"bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.4", "", { "os": "win32", "cpu": "x64" }, "sha512-Z5yAK28xrcm8Wb5k7TZ8FJKpOI/r+aVCRdlHYAqI2SDJFN3nD4mJs900X6kNVmG/xFzb5yOuKVYWGg+6ZXWbyA=="], "bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.4", "", { "os": "win32", "cpu": "x64" }, "sha512-Z5yAK28xrcm8Wb5k7TZ8FJKpOI/r+aVCRdlHYAqI2SDJFN3nD4mJs900X6kNVmG/xFzb5yOuKVYWGg+6ZXWbyA=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001765", "", {}, "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ=="],
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="],
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
"eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
"exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="], "exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="],
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
"fast-equals": ["fast-equals@5.4.0", "", {}, "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw=="],
"file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="], "file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="],
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="],
"formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="],
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="], "image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"jimp": ["jimp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="], "jimp": ["jimp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lucide-react": ["lucide-react@0.468.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="],
"next": ["next@15.5.9", "", { "dependencies": { "@next/env": "15.5.9", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.7", "@next/swc-darwin-x64": "15.5.7", "@next/swc-linux-arm64-gnu": "15.5.7", "@next/swc-linux-arm64-musl": "15.5.7", "@next/swc-linux-x64-gnu": "15.5.7", "@next/swc-linux-x64-musl": "15.5.7", "@next/swc-win32-arm64-msvc": "15.5.7", "@next/swc-win32-x64-msvc": "15.5.7", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg=="],
"node-abi": ["node-abi@3.87.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"ollama": ["ollama@0.6.3", "", { "dependencies": { "whatwg-fetch": "^3.6.20" } }, "sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg=="], "ollama": ["ollama@0.6.3", "", { "dependencies": { "whatwg-fetch": "^3.6.20" } }, "sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg=="],
"omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"parse-bmfont-ascii": ["parse-bmfont-ascii@1.0.6", "", {}, "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA=="], "parse-bmfont-ascii": ["parse-bmfont-ascii@1.0.6", "", {}, "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA=="],
@ -164,48 +586,130 @@
"peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="], "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="], "pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="],
"planck": ["planck@1.4.2", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-mNbhnV3g8X2rwGxzcesjmN8BDA6qfXgQxXVMkWau9MCRlQY0RLNEkyHlVp6yFy/X6qrzAXyNONCnZ1cGDLrNew=="], "planck": ["planck@1.4.2", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-mNbhnV3g8X2rwGxzcesjmN8BDA6qfXgQxXVMkWau9MCRlQY0RLNEkyHlVp6yFy/X6qrzAXyNONCnZ1cGDLrNew=="],
"pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
"process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],
"readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
"react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="], "readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="],
"recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="],
"recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="], "sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="],
"simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="],
"simple-xml-to-json": ["simple-xml-to-json@1.2.3", "", {}, "sha512-kWJDCr9EWtZ+/EYYM5MareWj2cRnZGF93YDNpH4jQiHB+hBIZnfPFSQiVMzZOdk+zXWqTZ/9fTeQNu2DqeiudA=="], "simple-xml-to-json": ["simple-xml-to-json@1.2.3", "", {}, "sha512-kWJDCr9EWtZ+/EYYM5MareWj2cRnZGF93YDNpH4jQiHB+hBIZnfPFSQiVMzZOdk+zXWqTZ/9fTeQNu2DqeiudA=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"stage-js": ["stage-js@1.0.0-alpha.17", "", {}, "sha512-AzlMO+t51v6cFvKZ+Oe9DJnL1OXEH5s9bEy6di5aOrUpcP7PCzI/wIeXF0u3zg0L89gwnceoKxrLId0ZpYnNXw=="], "stage-js": ["stage-js@1.0.0-alpha.17", "", {}, "sha512-AzlMO+t51v6cFvKZ+Oe9DJnL1OXEH5s9bEy6di5aOrUpcP7PCzI/wIeXF0u3zg0L89gwnceoKxrLId0ZpYnNXw=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
"strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], "strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="],
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
"tailwind-merge": ["tailwind-merge@2.6.0", "", {}, "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
"tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
"three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="], "three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="],
"token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], "token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="], "undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="], "utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
"web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="], "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="], "xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="],
"xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
@ -216,8 +720,44 @@
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@anthropic-ai/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-focus-scope/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@rta/web/ollama": ["ollama@0.5.18", "", { "dependencies": { "whatwg-fetch": "^3.6.20" } }, "sha512-lTFqTf9bo7Cd3hpF6CviBe/DEhewjoZYd9N/uCe7O20qYTvGqrNOFOBDj3lbZgFWHUgDv5EeyusYxsZSLS8nvg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], "pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"readable-web-to-node-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
"@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
} }
} }

0
data/.gitkeep Normal file
View File

5
data/settings.json Normal file
View File

@ -0,0 +1,5 @@
{
"summarizationModel": "llava-llama3:latest",
"sentimentModel": "llava-llama3:latest",
"embeddingModel": "nomic-embed-text"
}

View File

@ -1,21 +1,20 @@
{ {
"name": "reddit-trend-analyzer", "name": "reddit-trend-analyzer",
"version": "1.0.0", "version": "1.0.0",
"module": "src/index.ts",
"type": "module",
"private": true, "private": true,
"type": "module",
"workspaces": [
"packages/*"
],
"scripts": { "scripts": {
"start": "bun run src/cli.ts", "cli": "bun run packages/cli/src/cli.ts",
"tui": "bun run src/index.ts", "tui": "bun run packages/cli/src/index.ts",
"dev": "bun --watch run src/cli.ts" "dev": "bun run --filter @rta/web dev",
"build": "bun run --filter @rta/web build",
"start": "bun run build && bun run --filter @rta/web start"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"typescript": "^5.0.0" "typescript": "^5.0.0"
},
"dependencies": {
"@opentui/core": "^0.1.74",
"@qdrant/js-client-rest": "^1.16.2",
"ollama": "^0.6.3"
} }
} }

18
packages/cli/package.json Normal file
View File

@ -0,0 +1,18 @@
{
"name": "@rta/cli",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"bin": {
"rta": "./src/cli.ts"
},
"scripts": {
"start": "bun run src/cli.ts",
"tui": "bun run src/index.ts"
},
"dependencies": {
"@opentui/core": "^0.1.74",
"@rta/core": "workspace:*"
}
}

View File

@ -1,10 +1,15 @@
import * as readline from 'readline' import * as readline from 'readline'
import { RedditScraper } from './scraper/reddit' import {
import { CommentFetcher } from './scraper/comments' RedditScraper,
import { EmbeddingPipeline } from './embeddings/ollama' CommentFetcher,
import { QdrantStorage } from './storage/qdrant' EmbeddingPipeline,
import type { RedditComment } from './scraper/types' QdrantStorage,
import type { SearchResult } from './storage/types' SQLiteStorage,
ClusteringPipeline,
ProblemSummarizer,
QuestionExtractor,
} from '@rta/core'
import type { RedditComment, SearchResult } from '@rta/core'
const rl = readline.createInterface({ const rl = readline.createInterface({
input: process.stdin, input: process.stdin,
@ -58,6 +63,9 @@ async function main() {
console.log('\ncommands:') console.log('\ncommands:')
console.log(' scrape <url> [pages] - scrape subreddit (e.g. scrape https://reddit.com/r/vfx/best 3)') console.log(' scrape <url> [pages] - scrape subreddit (e.g. scrape https://reddit.com/r/vfx/best 3)')
console.log(' search <query> - semantic search') console.log(' search <query> - semantic search')
console.log(' cluster [threshold] - run clustering pipeline (default threshold 0.5)')
console.log(' problems - list problem clusters')
console.log(' questions [clusterId] - list extracted questions')
console.log(' stats - show collection stats') console.log(' stats - show collection stats')
console.log(' export json|csv - export last search results') console.log(' export json|csv - export last search results')
console.log(' quit - exit\n') console.log(' quit - exit\n')
@ -176,6 +184,98 @@ async function main() {
continue continue
} }
if (cmd === 'cluster') {
const threshold = parseFloat(args[0] || '0.5')
console.log(`\nrunning clustering with threshold ${threshold}...\n`)
try {
const clustering = new ClusteringPipeline(storage)
const result = await clustering.runClustering({
minClusterSize: 2,
similarityThreshold: threshold,
samplesPerCluster: 10,
})
console.log(`found ${result.clusters.length} clusters (${result.stats.noisePoints} noise points)`)
if (result.clusters.length > 0) {
console.log('\nsummarizing clusters...')
const summarizer = new ProblemSummarizer()
const summaries = await summarizer.summarizeClusters(result.clusters)
const sqlite = new SQLiteStorage()
sqlite.saveClusters(summaries)
console.log('extracting questions...')
const extractor = new QuestionExtractor()
const questionsByCluster = extractor.extractFromClusters(result.clusters)
for (const [, questions] of questionsByCluster) {
sqlite.saveQuestions(questions)
}
sqlite.close()
console.log(`\nsaved ${summaries.length} problem summaries to database`)
}
} catch (err) {
console.error('error:', err instanceof Error ? err.message : err)
}
continue
}
if (cmd === 'problems') {
try {
const sqlite = new SQLiteStorage()
const clusters = sqlite.getClusters()
sqlite.close()
if (clusters.length === 0) {
console.log('\nno problems found. run `cluster` first.')
continue
}
console.log(`\n${clusters.length} problem clusters:\n`)
for (const c of clusters) {
console.log(`[${c.clusterId}] ${c.problem}`)
console.log(` size: ${c.size} | engagement: ${c.totalEngagement.toLocaleString()} | subreddits: ${c.subreddits.join(', ')}`)
console.log(` ${c.description.slice(0, 120)}...`)
console.log()
}
} catch (err) {
console.error('error:', err instanceof Error ? err.message : err)
}
continue
}
if (cmd === 'questions') {
try {
const sqlite = new SQLiteStorage()
const clusterId = args[0] ? parseInt(args[0], 10) : undefined
const questions = sqlite.getQuestions(clusterId)
sqlite.close()
if (questions.length === 0) {
console.log('\nno questions found. run `cluster` first.')
continue
}
console.log(`\n${questions.length} questions${clusterId !== undefined ? ` (cluster ${clusterId})` : ''}:\n`)
for (const q of questions.slice(0, 20)) {
const status = q.addressed ? '[x]' : '[ ]'
console.log(`${status} ${q.text}`)
console.log(` cluster: ${q.clusterId} | engagement: ${q.engagement}`)
console.log()
}
if (questions.length > 20) {
console.log(`... and ${questions.length - 20} more questions`)
}
} catch (err) {
console.error('error:', err instanceof Error ? err.message : err)
}
continue
}
if (cmd === 'export') { if (cmd === 'export') {
const format = args[0] const format = args[0]
if (!format || !['json', 'csv'].includes(format)) { if (!format || !['json', 'csv'].includes(format)) {

View File

@ -1,4 +1,5 @@
import { createApp } from './tui/app' import { createApp } from './tui/app'
import { EmbeddingPipeline, QdrantStorage } from '@rta/core'
async function main() { async function main() {
console.clear() console.clear()
@ -23,12 +24,8 @@ async function main() {
async function checkOllama(): Promise<boolean> { async function checkOllama(): Promise<boolean> {
try { try {
const { Ollama } = await import('ollama') const embeddings = new EmbeddingPipeline()
const client = new Ollama({ return await embeddings.checkConnection()
host: process.env.OLLAMA_HOST || 'http://localhost:11434',
})
const models = await client.list()
return models.models.some(m => m.name.includes('nomic-embed-text'))
} catch { } catch {
return false return false
} }
@ -36,18 +33,8 @@ async function checkOllama(): Promise<boolean> {
async function checkQdrant(): Promise<boolean> { async function checkQdrant(): Promise<boolean> {
try { try {
const { QdrantClient } = await import('@qdrant/js-client-rest') const storage = new QdrantStorage()
const qdrantUrl = process.env.QDRANT_URL || 'http://localhost:6333' return await storage.checkConnection()
const parsedUrl = new URL(qdrantUrl)
const client = new QdrantClient({
host: parsedUrl.hostname,
port: parsedUrl.port ? parseInt(parsedUrl.port) : (parsedUrl.protocol === 'https:' ? 443 : 6333),
https: parsedUrl.protocol === 'https:',
apiKey: process.env.QDRANT_API_KEY,
})
await client.getCollections()
return true
} catch { } catch {
return false return false
} }

View File

@ -5,12 +5,13 @@ import {
type KeyEvent, type KeyEvent,
} from '@opentui/core' } from '@opentui/core'
import { RedditScraper } from '../scraper/reddit' import {
import { CommentFetcher } from '../scraper/comments' RedditScraper,
import { EmbeddingPipeline } from '../embeddings/ollama' CommentFetcher,
import { QdrantStorage } from '../storage/qdrant' EmbeddingPipeline,
import type { RedditComment } from '../scraper/types' QdrantStorage,
import type { SearchResult } from '../storage/types' } from '@rta/core'
import type { RedditComment, SearchResult } from '@rta/core'
import { createUrlInput, focusUrlInput } from './components/url-input' import { createUrlInput, focusUrlInput } from './components/url-input'
import { createProgressPanel, updateProgress, resetProgress } from './components/progress' import { createProgressPanel, updateProgress, resetProgress } from './components/progress'

View File

@ -3,7 +3,7 @@ import {
TextRenderable, TextRenderable,
type RenderContext, type RenderContext,
} from '@opentui/core' } from '@opentui/core'
import type { SearchResult } from '../../storage/types' import type { SearchResult } from '@rta/core'
export function createExportBar(renderer: RenderContext): BoxRenderable { export function createExportBar(renderer: RenderContext): BoxRenderable {
const container = new BoxRenderable(renderer, { const container = new BoxRenderable(renderer, {

View File

@ -5,7 +5,7 @@ import {
InputRenderableEvents, InputRenderableEvents,
type RenderContext, type RenderContext,
} from '@opentui/core' } from '@opentui/core'
import type { SearchResult } from '../../storage/types' import type { SearchResult } from '@rta/core'
export interface SearchConfig { export interface SearchConfig {
onSearch: (query: string) => Promise<void> onSearch: (query: string) => Promise<void>

View File

@ -3,7 +3,7 @@ import {
TextRenderable, TextRenderable,
type RenderContext, type RenderContext,
} from '@opentui/core' } from '@opentui/core'
import type { CollectionStats } from '../../storage/types' import type { CollectionStats } from '@rta/core'
export function createStatsPanel(renderer: RenderContext): BoxRenderable { export function createStatsPanel(renderer: RenderContext): BoxRenderable {
const container = new BoxRenderable(renderer, { const container = new BoxRenderable(renderer, {

View File

@ -0,0 +1,25 @@
{
"name": "@rta/core",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./scraper": "./src/scraper/index.ts",
"./embeddings": "./src/embeddings/index.ts",
"./storage": "./src/storage/index.ts",
"./clustering": "./src/clustering/index.ts",
"./analysis": "./src/analysis/index.ts",
"./utils": "./src/utils/index.ts"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.32.1",
"@qdrant/js-client-rest": "^1.16.2",
"better-sqlite3": "^11.7.0",
"ollama": "^0.6.3"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12"
}
}

View File

@ -0,0 +1,12 @@
export { ProblemSummarizer } from './summarizer'
export { QuestionExtractor } from './questions'
export { EngagementScorer } from './scoring'
export { SentimentAnalyzer } from './sentiment'
export { computeKeywordSimilarity } from './similarity'
export type { SimilarityMatrix } from './similarity'
export type {
ProblemSummary,
ExtractedQuestion,
ScoringWeights,
ScoredCluster,
} from './types'

View File

@ -0,0 +1,138 @@
import type { ClusterPoint, Cluster } from '../clustering/types'
import type { ExtractedQuestion } from './types'
import type { EmbeddingPipeline } from '../embeddings/ollama'
const QUESTION_PATTERNS = [
/how (?:do|can|should|would) (?:i|we|you)/i,
/why (?:does|is|are|do|did)/i,
/what (?:is|are|should|would|does)/i,
/is there (?:a|any|an) way/i,
/can (?:i|you|we|someone)/i,
/should (?:i|we)/i,
/what's the (?:best|easiest|fastest)/i,
/does anyone (?:know|have)/i,
/has anyone (?:tried|used|done)/i,
/any (?:suggestions|recommendations|ideas|tips)/i,
/help (?:with|me)/i,
/\?$/,
]
const DEDUP_SIMILARITY_THRESHOLD = 0.9
export class QuestionExtractor {
private embeddings?: EmbeddingPipeline
constructor(embeddings?: EmbeddingPipeline) {
this.embeddings = embeddings
}
extractQuestionsFromText(text: string): string[] {
const sentences = text.split(/[.!?\n]+/).map(s => s.trim()).filter(s => s.length > 10)
const questions: string[] = []
for (const sentence of sentences) {
for (const pattern of QUESTION_PATTERNS) {
if (pattern.test(sentence)) {
const cleaned = sentence.replace(/^\s*[-*•]\s*/, '').trim()
if (cleaned.length > 15 && cleaned.length < 500) {
questions.push(cleaned)
}
break
}
}
}
return questions
}
extractFromClusterPoints(points: ClusterPoint[], clusterId: number): ExtractedQuestion[] {
const questions: ExtractedQuestion[] = []
let questionId = 0
for (const point of points) {
const text = point.payload.title
? `${point.payload.title} ${point.payload.body}`
: point.payload.body
const extracted = this.extractQuestionsFromText(text)
for (const q of extracted) {
questions.push({
id: `q-${clusterId}-${questionId++}`,
text: q,
clusterId,
sourcePointId: point.id,
engagement: point.payload.score,
addressed: false,
})
}
}
return questions.sort((a, b) => b.engagement - a.engagement)
}
extractFromClusters(clusters: Cluster[]): Map<number, ExtractedQuestion[]> {
const questionsByCluster = new Map<number, ExtractedQuestion[]>()
for (const cluster of clusters) {
const questions = this.extractFromClusterPoints(cluster.samples, cluster.id)
questionsByCluster.set(cluster.id, questions)
}
return questionsByCluster
}
async deduplicateQuestions(questions: ExtractedQuestion[]): Promise<ExtractedQuestion[]> {
if (!this.embeddings || questions.length <= 1) {
return questions
}
const texts = questions.map(q => q.text)
const embeddings = await this.embeddings.embedBatch(texts)
const deduped: ExtractedQuestion[] = []
const used = new Set<number>()
for (let i = 0; i < questions.length; i++) {
if (used.has(i)) continue
const question = questions[i]
const embedding = embeddings[i]
if (!question || !embedding) continue
deduped.push(question)
used.add(i)
for (let j = i + 1; j < questions.length; j++) {
if (used.has(j)) continue
const otherEmbedding = embeddings[j]
if (!otherEmbedding) continue
const similarity = this.cosineSimilarity(embedding, otherEmbedding)
if (similarity >= DEDUP_SIMILARITY_THRESHOLD) {
used.add(j)
}
}
}
return deduped
}
private cosineSimilarity(a: number[], b: number[]): number {
let dotProduct = 0
let normA = 0
let normB = 0
for (let i = 0; i < a.length; i++) {
const aVal = a[i] ?? 0
const bVal = b[i] ?? 0
dotProduct += aVal * bVal
normA += aVal * aVal
normB += bVal * bVal
}
if (normA === 0 || normB === 0) return 0
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB))
}
}

View File

@ -0,0 +1,107 @@
import type { Cluster } from '../clustering/types'
import type { ScoringWeights, ScoredCluster } from './types'
import { SentimentAnalyzer } from './sentiment'
const DEFAULT_WEIGHTS: ScoringWeights = {
engagement: 0.5,
sentiment: 0.2,
velocity: 0.3,
}
export class EngagementScorer {
private weights: ScoringWeights
private sentimentAnalyzer: SentimentAnalyzer | null = null
constructor(weights?: Partial<ScoringWeights>) {
this.weights = { ...DEFAULT_WEIGHTS, ...weights }
}
setWeights(weights: Partial<ScoringWeights>): void {
this.weights = { ...this.weights, ...weights }
}
enableSentiment(): void {
this.sentimentAnalyzer = new SentimentAnalyzer()
}
async scoreClustersAsync(clusters: Cluster[]): Promise<ScoredCluster[]> {
if (clusters.length === 0) return []
const maxEngagement = Math.max(...clusters.map(c => c.totalEngagement))
const now = Math.floor(Date.now() / 1000)
const oneWeek = 7 * 24 * 60 * 60
const scored: ScoredCluster[] = []
for (const cluster of clusters) {
const engagementScore = maxEngagement > 0
? cluster.totalEngagement / maxEngagement
: 0
const age = now - cluster.lastActive
const velocityScore = Math.max(0, 1 - (age / oneWeek))
let sentimentScore = 0.5
if (this.sentimentAnalyzer && cluster.samples.length > 0) {
const sampleTexts = cluster.samples
.slice(0, 5)
.map(s => s.payload.title || s.payload.body)
.filter(Boolean)
sentimentScore = await this.sentimentAnalyzer.analyzeClusterSentiment(sampleTexts)
}
const impactScore =
this.weights.engagement * engagementScore +
this.weights.sentiment * sentimentScore +
this.weights.velocity * velocityScore
scored.push({
...cluster,
impactScore,
})
}
return scored.sort((a, b) => b.impactScore - a.impactScore)
}
scoreClusters(clusters: Cluster[]): ScoredCluster[] {
if (clusters.length === 0) return []
const maxEngagement = Math.max(...clusters.map(c => c.totalEngagement))
const now = Math.floor(Date.now() / 1000)
const oneWeek = 7 * 24 * 60 * 60
return clusters.map(cluster => {
const engagementScore = maxEngagement > 0
? cluster.totalEngagement / maxEngagement
: 0
const age = now - cluster.lastActive
const velocityScore = Math.max(0, 1 - (age / oneWeek))
const sentimentScore = 0.5
const impactScore =
this.weights.engagement * engagementScore +
this.weights.sentiment * sentimentScore +
this.weights.velocity * velocityScore
return {
...cluster,
impactScore,
}
}).sort((a, b) => b.impactScore - a.impactScore)
}
rankByEngagement(clusters: Cluster[]): Cluster[] {
return [...clusters].sort((a, b) => b.totalEngagement - a.totalEngagement)
}
rankByRecency(clusters: Cluster[]): Cluster[] {
return [...clusters].sort((a, b) => b.lastActive - a.lastActive)
}
rankBySize(clusters: Cluster[]): Cluster[] {
return [...clusters].sort((a, b) => b.size - a.size)
}
}

View File

@ -0,0 +1,97 @@
import { Ollama } from 'ollama'
import { RateLimiter } from '../utils/rate-limit'
const DEFAULT_MODEL = 'llama3.2'
const BATCH_SIZE = 5
type Sentiment = 'positive' | 'neutral' | 'negative'
export class SentimentAnalyzer {
private ollama: Ollama
private model: string
private rateLimiter: RateLimiter
private cache: Map<string, number>
constructor(options?: { host?: string; model?: string }) {
this.ollama = new Ollama({
host: options?.host || process.env.OLLAMA_HOST || 'http://localhost:11434',
})
this.model = options?.model || DEFAULT_MODEL
this.rateLimiter = new RateLimiter(500)
this.cache = new Map()
}
private sentimentToScore(sentiment: Sentiment): number {
switch (sentiment) {
case 'positive': return 1.0
case 'neutral': return 0.5
case 'negative': return 0.0
default: return 0.5
}
}
private parseSentiment(response: string): Sentiment {
const lower = response.toLowerCase().trim()
if (lower.includes('positive')) return 'positive'
if (lower.includes('negative')) return 'negative'
return 'neutral'
}
private textKey(text: string): string {
return text.slice(0, 200)
}
async analyzeSingle(text: string): Promise<number> {
const key = this.textKey(text)
const cached = this.cache.get(key)
if (cached !== undefined) return cached
await this.rateLimiter.wait()
try {
const response = await this.ollama.generate({
model: this.model,
prompt: `Analyze the sentiment of this text. Return only one word: positive, negative, or neutral.\n\nText: "${text.slice(0, 500)}"`,
stream: false,
})
const sentiment = this.parseSentiment(response.response)
const score = this.sentimentToScore(sentiment)
this.cache.set(key, score)
return score
} catch (err) {
return 0.5
}
}
async analyzeBatch(texts: string[]): Promise<number[]> {
const results: number[] = []
for (let i = 0; i < texts.length; i += BATCH_SIZE) {
const batch = texts.slice(i, i + BATCH_SIZE)
const batchResults = await Promise.all(
batch.map(text => this.analyzeSingle(text))
)
results.push(...batchResults)
}
return results
}
async analyzeClusterSentiment(sampleTexts: string[]): Promise<number> {
if (sampleTexts.length === 0) return 0.5
const scores = await this.analyzeBatch(sampleTexts)
const sum = scores.reduce((a, b) => a + b, 0)
return sum / scores.length
}
async checkConnection(): Promise<boolean> {
try {
const result = await this.ollama.list()
return result.models.some(m => m.name.startsWith(this.model))
} catch {
return false
}
}
}

View File

@ -0,0 +1,41 @@
import type { ProblemSummary } from './types'
export interface SimilarityMatrix {
matrix: number[][]
labels: string[]
clusterIds: number[]
}
/**
* compute jaccard similarity between clusters based on keywords
* jaccard = |A B| / |A B|
*/
export function computeKeywordSimilarity(clusters: ProblemSummary[]): SimilarityMatrix {
const n = clusters.length
const matrix: number[][] = Array(n).fill(null).map(() => Array(n).fill(0))
for (let i = 0; i < n; i++) {
for (let j = i; j < n; j++) {
if (i === j) {
matrix[i][j] = 1
continue
}
const setA = new Set(clusters[i].keywords.map(k => k.toLowerCase()))
const setB = new Set(clusters[j].keywords.map(k => k.toLowerCase()))
const intersection = [...setA].filter(k => setB.has(k)).length
const union = new Set([...setA, ...setB]).size
const similarity = union > 0 ? intersection / union : 0
matrix[i][j] = similarity
matrix[j][i] = similarity
}
}
return {
matrix,
labels: clusters.map(c => c.problem.slice(0, 25) + (c.problem.length > 25 ? '...' : '')),
clusterIds: clusters.map(c => c.clusterId),
}
}

View File

@ -0,0 +1,146 @@
import { Ollama } from 'ollama'
import type { Cluster } from '../clustering/types'
import type { ProblemSummary, SummarizationProgress } from './types'
import { delay } from '../utils/rate-limit'
const DEFAULT_MODEL = 'llama3.2'
const RATE_LIMIT_DELAY = 500
const SYSTEM_PROMPT = `You are an expert at identifying recurring problems from online discussions.
Given a cluster of related discussions from Reddit, extract the common problem pattern.
Respond ONLY with valid JSON in this exact format:
{
"problem": "concise one-line problem statement (max 100 chars)",
"description": "2-3 sentences explaining the problem",
"keywords": ["keyword1", "keyword2", "keyword3"],
"sampleQuestions": ["question 1?", "question 2?", "question 3?"],
"actionableInsight": "brief suggestion for content/tool opportunity"
}`
interface SummaryResponse {
problem: string
description: string
keywords: string[]
sampleQuestions: string[]
actionableInsight: string
}
export class ProblemSummarizer {
private ollama: Ollama
private model: string
private onProgress?: (progress: SummarizationProgress) => void
constructor(options?: { host?: string; model?: string }) {
this.ollama = new Ollama({
host: options?.host || process.env.OLLAMA_HOST || 'http://localhost:11434',
})
this.model = options?.model || DEFAULT_MODEL
}
setProgressCallback(callback: (progress: SummarizationProgress) => void): void {
this.onProgress = callback
}
private emitProgress(progress: SummarizationProgress): void {
this.onProgress?.(progress)
}
async summarizeCluster(cluster: Cluster): Promise<ProblemSummary> {
const sampleTexts = cluster.samples
.slice(0, 5)
.map((sample, i) => {
const title = sample.payload.title || ''
const body = sample.payload.body.slice(0, 300)
const score = sample.payload.score
return `[${score} upvotes] ${title}\n${body}`
})
.join('\n\n')
const prompt = `${SYSTEM_PROMPT}
Analyze these ${cluster.size} related discussions from r/${cluster.subreddits.join(', r/')}:
${sampleTexts}
JSON response:`
const response = await this.ollama.generate({
model: this.model,
prompt,
stream: false,
})
let parsed: SummaryResponse
try {
const jsonMatch = response.response.match(/\{[\s\S]*\}/)
if (!jsonMatch) {
throw new Error('No JSON found in response')
}
parsed = JSON.parse(jsonMatch[0])
} catch (e) {
parsed = {
problem: `Cluster ${cluster.id} discussion pattern`,
description: 'Unable to extract summary from cluster.',
keywords: cluster.subreddits,
sampleQuestions: [],
actionableInsight: 'Review cluster manually.',
}
}
return {
clusterId: cluster.id,
problem: parsed.problem || `Cluster ${cluster.id}`,
description: parsed.description || '',
keywords: parsed.keywords || [],
sampleQuestions: parsed.sampleQuestions || [],
actionableInsight: parsed.actionableInsight || '',
size: cluster.size,
totalEngagement: cluster.totalEngagement,
lastActive: cluster.lastActive,
subreddits: cluster.subreddits,
samplePointIds: cluster.samples.map(s => s.id),
}
}
async summarizeClusters(clusters: Cluster[]): Promise<ProblemSummary[]> {
const summaries: ProblemSummary[] = []
for (let i = 0; i < clusters.length; i++) {
const cluster = clusters[i]
if (!cluster) continue
this.emitProgress({
current: i + 1,
total: clusters.length,
message: `Summarizing cluster ${i + 1}/${clusters.length}`,
})
try {
const summary = await this.summarizeCluster(cluster)
summaries.push(summary)
} catch (error) {
console.error(`Failed to summarize cluster ${cluster.id}:`, error)
summaries.push({
clusterId: cluster.id,
problem: `Cluster ${cluster.id} (summary failed)`,
description: 'Failed to generate summary for this cluster.',
keywords: [],
sampleQuestions: [],
actionableInsight: '',
size: cluster.size,
totalEngagement: cluster.totalEngagement,
lastActive: cluster.lastActive,
subreddits: cluster.subreddits,
samplePointIds: cluster.samples.map(s => s.id),
})
}
if (i < clusters.length - 1) {
await delay(RATE_LIMIT_DELAY)
}
}
return summaries
}
}

View File

@ -0,0 +1,40 @@
import type { Cluster } from '../clustering/types'
export interface ProblemSummary {
clusterId: number
problem: string
description: string
keywords: string[]
sampleQuestions: string[]
actionableInsight: string
size: number
totalEngagement: number
lastActive: number
subreddits: string[]
samplePointIds?: string[]
}
export interface ExtractedQuestion {
id: string
text: string
clusterId: number
sourcePointId: string
engagement: number
addressed: boolean
}
export interface ScoringWeights {
engagement: number
sentiment: number
velocity: number
}
export interface ScoredCluster extends Cluster {
impactScore: number
}
export interface SummarizationProgress {
current: number
total: number
message: string
}

View File

@ -0,0 +1,214 @@
import type { QdrantStorage } from '../storage/qdrant'
import type {
Cluster,
ClusterPoint,
ClusteringOptions,
ClusteringResult,
} from './types'
const DEFAULT_MIN_CLUSTER_SIZE = 2
const DEFAULT_SIMILARITY_THRESHOLD = 0.5
const DEFAULT_SAMPLES_PER_CLUSTER = 10
export class ClusteringPipeline {
private storage: QdrantStorage
constructor(storage: QdrantStorage) {
this.storage = storage
}
async runClustering(options: ClusteringOptions = {}): Promise<ClusteringResult> {
const minClusterSize = options.minClusterSize ?? DEFAULT_MIN_CLUSTER_SIZE
const similarityThreshold = options.similarityThreshold ?? DEFAULT_SIMILARITY_THRESHOLD
const samplesPerCluster = options.samplesPerCluster ?? DEFAULT_SAMPLES_PER_CLUSTER
const points = await this.storage.scrollWithVectors()
if (points.length === 0) {
return {
clusters: [],
noise: [],
stats: {
totalPoints: 0,
clusteredPoints: 0,
noisePoints: 0,
clusterCount: 0,
},
}
}
const labels = this.densityClustering(points, similarityThreshold, minClusterSize)
const clusterMap = new Map<number, ClusterPoint[]>()
const noise: ClusterPoint[] = []
for (let i = 0; i < labels.length; i++) {
const label = labels[i]
const point = points[i]
if (!point) continue
if (label === -1) {
noise.push(point)
} else {
const existing = clusterMap.get(label) || []
existing.push(point)
clusterMap.set(label, existing)
}
}
const clusters: Cluster[] = []
let clusterId = 0
for (const [, clusterPoints] of clusterMap) {
if (clusterPoints.length < minClusterSize) {
noise.push(...clusterPoints)
continue
}
const centroid = this.calculateCentroid(clusterPoints)
const totalEngagement = clusterPoints.reduce((sum, p) => sum + p.payload.score, 0)
const lastActive = Math.max(...clusterPoints.map(p => p.payload.created))
const subreddits = [...new Set(clusterPoints.map(p => p.payload.subreddit))]
const sortedByEngagement = [...clusterPoints].sort(
(a, b) => b.payload.score - a.payload.score
)
const samples = sortedByEngagement.slice(0, samplesPerCluster)
clusters.push({
id: clusterId++,
size: clusterPoints.length,
centroid,
totalEngagement,
lastActive,
subreddits,
samples,
})
}
clusters.sort((a, b) => b.totalEngagement - a.totalEngagement)
return {
clusters,
noise,
stats: {
totalPoints: points.length,
clusteredPoints: points.length - noise.length,
noisePoints: noise.length,
clusterCount: clusters.length,
},
}
}
private densityClustering(
points: ClusterPoint[],
similarityThreshold: number,
minSize: number
): number[] {
const n = points.length
const labels = new Array(n).fill(-1)
const visited = new Set<number>()
let currentCluster = 0
for (let i = 0; i < n; i++) {
if (visited.has(i)) continue
const neighbors = this.findNeighbors(points, i, similarityThreshold)
if (neighbors.length < minSize) {
visited.add(i)
continue
}
labels[i] = currentCluster
visited.add(i)
const queue = [...neighbors]
while (queue.length > 0) {
const j = queue.shift()!
if (visited.has(j)) continue
visited.add(j)
labels[j] = currentCluster
const jNeighbors = this.findNeighbors(points, j, similarityThreshold)
if (jNeighbors.length >= minSize) {
for (const k of jNeighbors) {
if (!visited.has(k)) {
queue.push(k)
}
}
}
}
currentCluster++
}
return labels
}
private findNeighbors(
points: ClusterPoint[],
idx: number,
threshold: number
): number[] {
const neighbors: number[] = []
const point = points[idx]
if (!point) return neighbors
for (let i = 0; i < points.length; i++) {
if (i === idx) continue
const other = points[i]
if (!other) continue
const sim = this.cosineSimilarity(point.vector, other.vector)
if (sim >= threshold) {
neighbors.push(i)
}
}
return neighbors
}
private cosineSimilarity(a: number[], b: number[]): number {
let dot = 0
let normA = 0
let normB = 0
for (let i = 0; i < a.length; i++) {
const aVal = a[i] ?? 0
const bVal = b[i] ?? 0
dot += aVal * bVal
normA += aVal * aVal
normB += bVal * bVal
}
if (normA === 0 || normB === 0) return 0
return dot / (Math.sqrt(normA) * Math.sqrt(normB))
}
private calculateCentroid(points: ClusterPoint[]): number[] {
if (points.length === 0) return []
const dim = points[0]?.vector.length ?? 0
const centroid = new Array(dim).fill(0)
for (const point of points) {
for (let i = 0; i < dim; i++) {
centroid[i] += point.vector[i] ?? 0
}
}
for (let i = 0; i < dim; i++) {
centroid[i] /= points.length
}
const magnitude = Math.sqrt(centroid.reduce((sum, v) => sum + v * v, 0))
if (magnitude > 0) {
for (let i = 0; i < dim; i++) {
centroid[i] /= magnitude
}
}
return centroid
}
}

View File

@ -0,0 +1,2 @@
export { ClusteringPipeline } from './hdbscan'
export type { Cluster, ClusterPoint, ClusteringOptions, ClusteringResult } from './types'

View File

@ -0,0 +1,34 @@
import type { PointPayload } from '../storage/types'
export interface ClusterPoint {
id: string
vector: number[]
payload: PointPayload
}
export interface Cluster {
id: number
size: number
centroid: number[]
totalEngagement: number
lastActive: number
subreddits: string[]
samples: ClusterPoint[]
}
export interface ClusteringOptions {
minClusterSize?: number
similarityThreshold?: number
samplesPerCluster?: number
}
export interface ClusteringResult {
clusters: Cluster[]
noise: ClusterPoint[]
stats: {
totalPoints: number
clusteredPoints: number
noisePoints: number
clusterCount: number
}
}

View File

@ -0,0 +1,2 @@
export { EmbeddingPipeline, VECTOR_DIM } from './ollama'
export type { EmbeddedPoint, EmbeddingProgress } from './ollama'

View File

@ -0,0 +1,46 @@
// scraper
export {
RedditScraper,
CommentFetcher,
normalizeRedditUrl,
parseSubredditFromUrl,
} from './scraper'
export type {
RedditPost,
RedditComment,
RedditListing,
ScrapeOptions,
ScrapeProgress,
} from './scraper'
// embeddings
export { EmbeddingPipeline, VECTOR_DIM } from './embeddings'
export type { EmbeddedPoint, EmbeddingProgress } from './embeddings'
// storage
export { QdrantStorage, COLLECTION_NAME, SQLiteStorage } from './storage'
export type { PointPayload, SearchResult, CollectionStats, ScrapeHistoryRecord } from './storage'
// clustering
export { ClusteringPipeline } from './clustering'
export type { Cluster, ClusterPoint, ClusteringOptions, ClusteringResult } from './clustering'
// analysis
export {
ProblemSummarizer,
QuestionExtractor,
EngagementScorer,
SentimentAnalyzer,
computeKeywordSimilarity,
} from './analysis'
export type {
ProblemSummary,
ExtractedQuestion,
ScoringWeights,
ScoredCluster,
SimilarityMatrix,
} from './analysis'
// utils
export { delay, RateLimiter, fetchWithRetry } from './utils'
export { cleanText, prepareForEmbedding } from './utils'

View File

@ -0,0 +1,12 @@
export { RedditScraper, normalizeRedditUrl, parseSubredditFromUrl } from './reddit'
export { CommentFetcher } from './comments'
export type {
RedditPost,
RedditComment,
RedditListing,
RedditListingData,
RedditPostData,
RedditCommentData,
ScrapeOptions,
ScrapeProgress,
} from './types'

View File

@ -0,0 +1,4 @@
export { QdrantStorage, COLLECTION_NAME } from './qdrant'
export { SQLiteStorage } from './sqlite'
export type { ScrapeHistoryRecord } from './sqlite'
export type { PointPayload, SearchResult, CollectionStats } from './types'

View File

@ -1,6 +1,7 @@
import { QdrantClient } from '@qdrant/js-client-rest' import { QdrantClient } from '@qdrant/js-client-rest'
import type { EmbeddedPoint } from '../embeddings/ollama' import type { EmbeddedPoint } from '../embeddings/ollama'
import type { PointPayload, SearchResult, CollectionStats } from './types' import type { PointPayload, SearchResult, CollectionStats } from './types'
import type { ClusterPoint } from '../clustering/types'
import { VECTOR_DIM } from '../embeddings/ollama' import { VECTOR_DIM } from '../embeddings/ollama'
const COLLECTION_NAME = 'reddit_trends' const COLLECTION_NAME = 'reddit_trends'
@ -178,6 +179,48 @@ export class QdrantStorage {
return false return false
} }
} }
async getPointsByIds(ids: (string | number)[]): Promise<PointPayload[]> {
if (ids.length === 0) return []
const results = await this.client.retrieve(this.collectionName, {
ids,
with_payload: true,
with_vector: false,
})
return results.map(r => r.payload as unknown as PointPayload)
}
async scrollWithVectors(batchSize: number = 100): Promise<ClusterPoint[]> {
const points: ClusterPoint[] = []
let offset: string | number | undefined = undefined
while (true) {
const result = await this.client.scroll(this.collectionName, {
limit: batchSize,
offset,
with_payload: true,
with_vector: true,
})
for (const point of result.points) {
const vector = point.vector
if (!vector || !Array.isArray(vector)) continue
points.push({
id: String(point.id),
vector: vector as number[],
payload: point.payload as unknown as PointPayload,
})
}
if (!result.next_page_offset) break
offset = result.next_page_offset as string | number
}
return points
}
} }
export { COLLECTION_NAME } export { COLLECTION_NAME }

View File

@ -0,0 +1,274 @@
import Database from 'better-sqlite3'
import { existsSync, mkdirSync } from 'fs'
import { join } from 'path'
import type { ProblemSummary, ExtractedQuestion } from '../analysis/types'
function getDefaultDbPath(): string {
// use env var if set, otherwise use hardcoded path relative to monorepo
if (process.env.SQLITE_DB_PATH) {
return process.env.SQLITE_DB_PATH
}
// hardcode the path for this project
const projectRoot = '/mnt/work/dev/personal-projects/reddit-trend-analyzer'
const dataDir = join(projectRoot, 'data')
if (!existsSync(dataDir)) {
mkdirSync(dataDir, { recursive: true })
}
return join(dataDir, 'reddit-trends.db')
}
export interface ScrapeHistoryRecord {
id: number
subreddit: string
url: string
postsScraped: number
commentsScraped: number
startedAt: number
completedAt: number | null
}
export interface ClusterRecord {
id: number
problem: string
description: string
keywords: string
sampleQuestions: string
actionableInsight: string
size: number
totalEngagement: number
lastActive: number
subreddits: string
samplePointIds: string
createdAt: number
}
export class SQLiteStorage {
private db: Database.Database
constructor(dbPath?: string) {
this.db = new Database(dbPath || getDefaultDbPath())
this.initialize()
}
private initialize(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS clusters (
id INTEGER PRIMARY KEY,
problem TEXT NOT NULL,
description TEXT,
keywords TEXT,
sample_questions TEXT,
actionable_insight TEXT,
size INTEGER NOT NULL,
total_engagement INTEGER NOT NULL,
last_active INTEGER NOT NULL,
subreddits TEXT,
sample_point_ids TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
CREATE TABLE IF NOT EXISTS questions (
id TEXT PRIMARY KEY,
text TEXT NOT NULL,
cluster_id INTEGER,
source_point_id TEXT,
engagement INTEGER NOT NULL DEFAULT 0,
addressed INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
FOREIGN KEY (cluster_id) REFERENCES clusters(id)
);
CREATE TABLE IF NOT EXISTS scrape_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subreddit TEXT NOT NULL,
url TEXT NOT NULL,
posts_scraped INTEGER NOT NULL DEFAULT 0,
comments_scraped INTEGER NOT NULL DEFAULT 0,
started_at INTEGER NOT NULL,
completed_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_questions_cluster ON questions(cluster_id);
CREATE INDEX IF NOT EXISTS idx_questions_addressed ON questions(addressed);
CREATE INDEX IF NOT EXISTS idx_scrape_history_subreddit ON scrape_history(subreddit);
`)
// migration: add sample_point_ids column if missing (for existing databases)
try {
const columns = this.db.pragma('table_info(clusters)') as { name: string }[]
const hasSamplePointIds = columns.some(c => c.name === 'sample_point_ids')
if (!hasSamplePointIds) {
this.db.exec('ALTER TABLE clusters ADD COLUMN sample_point_ids TEXT')
}
} catch {
// table might not exist yet, that's fine
}
}
saveClusters(summaries: ProblemSummary[]): void {
this.db.exec('DELETE FROM clusters')
const insert = this.db.prepare(`
INSERT INTO clusters (id, problem, description, keywords, sample_questions,
actionable_insight, size, total_engagement, last_active, subreddits, sample_point_ids)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
const insertMany = this.db.transaction((items: ProblemSummary[]) => {
for (const s of items) {
insert.run(
s.clusterId,
s.problem,
s.description,
JSON.stringify(s.keywords),
JSON.stringify(s.sampleQuestions),
s.actionableInsight,
s.size,
s.totalEngagement,
s.lastActive,
JSON.stringify(s.subreddits),
JSON.stringify(s.samplePointIds || [])
)
}
})
insertMany(summaries)
}
getClusters(): ProblemSummary[] {
const rows = this.db.prepare(`
SELECT * FROM clusters ORDER BY total_engagement DESC
`).all() as any[]
return rows.map(row => ({
clusterId: row.id,
problem: row.problem,
description: row.description,
keywords: JSON.parse(row.keywords || '[]'),
sampleQuestions: JSON.parse(row.sample_questions || '[]'),
actionableInsight: row.actionable_insight,
size: row.size,
totalEngagement: row.total_engagement,
lastActive: row.last_active,
subreddits: JSON.parse(row.subreddits || '[]'),
samplePointIds: JSON.parse(row.sample_point_ids || '[]'),
}))
}
getCluster(id: number): ProblemSummary | null {
const row = this.db.prepare(`
SELECT * FROM clusters WHERE id = ?
`).get(id) as any | undefined
if (!row) return null
return {
clusterId: row.id,
problem: row.problem,
description: row.description,
keywords: JSON.parse(row.keywords || '[]'),
sampleQuestions: JSON.parse(row.sample_questions || '[]'),
actionableInsight: row.actionable_insight,
size: row.size,
totalEngagement: row.total_engagement,
lastActive: row.last_active,
subreddits: JSON.parse(row.subreddits || '[]'),
samplePointIds: JSON.parse(row.sample_point_ids || '[]'),
}
}
saveQuestions(questions: ExtractedQuestion[]): void {
const insert = this.db.prepare(`
INSERT OR REPLACE INTO questions (id, text, cluster_id, source_point_id, engagement, addressed)
VALUES (?, ?, ?, ?, ?, ?)
`)
const insertMany = this.db.transaction((items: ExtractedQuestion[]) => {
for (const q of items) {
insert.run(q.id, q.text, q.clusterId, q.sourcePointId, q.engagement, q.addressed ? 1 : 0)
}
})
insertMany(questions)
}
getQuestions(clusterId?: number): ExtractedQuestion[] {
let rows
if (clusterId !== undefined) {
rows = this.db.prepare(`
SELECT * FROM questions WHERE cluster_id = ? ORDER BY engagement DESC
`).all(clusterId) as any[]
} else {
rows = this.db.prepare(`
SELECT * FROM questions ORDER BY engagement DESC
`).all() as any[]
}
return rows.map(row => ({
id: row.id,
text: row.text,
clusterId: row.cluster_id,
sourcePointId: row.source_point_id,
engagement: row.engagement,
addressed: row.addressed === 1,
}))
}
markQuestionAddressed(id: string, addressed: boolean = true): void {
this.db.prepare(`
UPDATE questions SET addressed = ? WHERE id = ?
`).run(addressed ? 1 : 0, id)
}
startScrape(subreddit: string, url: string): number {
const result = this.db.prepare(`
INSERT INTO scrape_history (subreddit, url, started_at)
VALUES (?, ?, strftime('%s', 'now'))
`).run(subreddit, url)
return Number(result.lastInsertRowid)
}
completeScrape(id: number, postsScraped: number, commentsScraped: number): void {
this.db.prepare(`
UPDATE scrape_history
SET posts_scraped = ?, comments_scraped = ?, completed_at = strftime('%s', 'now')
WHERE id = ?
`).run(postsScraped, commentsScraped, id)
}
getScrapeHistory(limit: number = 50): ScrapeHistoryRecord[] {
const rows = this.db.prepare(`
SELECT * FROM scrape_history ORDER BY started_at DESC LIMIT ?
`).all(limit) as any[]
return rows.map(row => ({
id: row.id,
subreddit: row.subreddit,
url: row.url,
postsScraped: row.posts_scraped,
commentsScraped: row.comments_scraped,
startedAt: row.started_at,
completedAt: row.completed_at,
}))
}
getStats(): { clusterCount: number; questionCount: number; addressedCount: number } {
const clusters = this.db.prepare('SELECT COUNT(*) as count FROM clusters').get() as { count: number }
const questions = this.db.prepare('SELECT COUNT(*) as count FROM questions').get() as { count: number }
const addressed = this.db.prepare('SELECT COUNT(*) as count FROM questions WHERE addressed = 1').get() as { count: number }
return {
clusterCount: clusters.count,
questionCount: questions.count,
addressedCount: addressed.count,
}
}
close(): void {
this.db.close()
}
}

View File

@ -0,0 +1,8 @@
export { delay, RateLimiter, fetchWithRetry } from './rate-limit'
export {
decodeHtmlEntities,
stripHtml,
cleanText,
truncateText,
prepareForEmbedding,
} from './text'

6
packages/web/next-env.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -0,0 +1,14 @@
import type { NextConfig } from 'next'
const config: NextConfig = {
serverExternalPackages: ['better-sqlite3'],
webpack: (config, { isServer }) => {
if (isServer) {
config.externals = config.externals || []
config.externals.push('better-sqlite3')
}
return config
},
}
export default config

35
packages/web/package.json Normal file
View File

@ -0,0 +1,35 @@
{
"name": "@rta/web",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@rta/core": "workspace:*",
"ollama": "^0.5.11",
"better-sqlite3": "^11.7.0",
"@tanstack/react-table": "^8.20.6",
"next": "^15.2.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^2.15.0",
"lucide-react": "^0.468.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"tailwind-merge": "^2.6.0",
"cmdk": "^1.0.4"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"tailwindcss": "^4.0.0",
"@tailwindcss/postcss": "^4.0.0",
"typescript": "^5.0.0"
}
}

View File

@ -0,0 +1,5 @@
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
},
}

View File

@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server'
import { SQLiteStorage, QdrantStorage } from '@rta/core'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const clusterId = parseInt(id, 10)
const sqlite = new SQLiteStorage()
const cluster = sqlite.getCluster(clusterId)
const questions = sqlite.getQuestions(clusterId)
sqlite.close()
if (!cluster) {
return NextResponse.json({ error: 'Cluster not found' }, { status: 404 })
}
// fetch actual discussions from qdrant if we have sample point IDs
let samples: any[] = []
if (cluster.samplePointIds && cluster.samplePointIds.length > 0) {
try {
const qdrant = new QdrantStorage()
samples = await qdrant.getPointsByIds(cluster.samplePointIds)
} catch (e) {
console.error('Failed to fetch samples from qdrant:', e)
}
}
return NextResponse.json({ cluster, questions, samples })
} catch (error) {
console.error('Get cluster error:', error)
return NextResponse.json({ error: 'Failed to get cluster' }, { status: 500 })
}
}

View File

@ -0,0 +1,108 @@
import { NextResponse } from 'next/server'
import {
QdrantStorage,
SQLiteStorage,
ClusteringPipeline,
ProblemSummarizer,
QuestionExtractor,
} from '@rta/core'
import { existsSync, readFileSync } from 'fs'
import { join } from 'path'
const PROJECT_ROOT = '/mnt/work/dev/personal-projects/reddit-trend-analyzer'
const SETTINGS_FILE = join(PROJECT_ROOT, 'data', 'settings.json')
function getSettings() {
try {
if (existsSync(SETTINGS_FILE)) {
return JSON.parse(readFileSync(SETTINGS_FILE, 'utf-8'))
}
} catch {}
return { summarizationModel: 'llama3.2' }
}
export async function GET() {
try {
const sqlite = new SQLiteStorage()
const clusters = sqlite.getClusters()
sqlite.close()
return NextResponse.json({ clusters })
} catch (error) {
console.error('Get clusters error:', error)
return NextResponse.json({ clusters: [] })
}
}
export async function POST(request: Request) {
try {
const body = await request.json().catch(() => ({}))
const minClusterSize = body.minClusterSize ?? 2
const similarityThreshold = body.similarityThreshold ?? 0.5
const qdrant = new QdrantStorage()
const sqlite = new SQLiteStorage()
const clustering = new ClusteringPipeline(qdrant)
const result = await clustering.runClustering({
minClusterSize,
similarityThreshold,
samplesPerCluster: 10,
})
if (result.clusters.length === 0) {
sqlite.close()
return NextResponse.json({
success: true,
message: 'No clusters found',
clusters: [],
})
}
const settings = getSettings()
const summarizer = new ProblemSummarizer({ model: settings.summarizationModel })
const summaries = await summarizer.summarizeClusters(result.clusters)
sqlite.saveClusters(summaries)
const extractor = new QuestionExtractor()
// save questions from both Claude summaries and regex extraction
for (const summary of summaries) {
const regexQuestions = extractor.extractFromClusterPoints(
result.clusters.find(c => c.id === summary.clusterId)?.samples || [],
summary.clusterId
)
// add Claude's sample questions if available
const claudeQuestions = summary.sampleQuestions.map((q, i) => ({
id: `claude-${summary.clusterId}-${i}`,
text: q,
clusterId: summary.clusterId,
sourcePointId: 'claude-generated',
engagement: summary.totalEngagement,
addressed: false,
}))
const allQuestions = [...claudeQuestions, ...regexQuestions]
if (allQuestions.length > 0) {
sqlite.saveQuestions(allQuestions)
}
}
sqlite.close()
return NextResponse.json({
success: true,
clusters: summaries,
stats: result.stats,
})
} catch (error) {
console.error('Clustering error:', error)
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,23 @@
import { NextResponse } from 'next/server'
import { SQLiteStorage, computeKeywordSimilarity } from '@rta/core'
export async function GET() {
try {
const sqlite = new SQLiteStorage()
const clusters = sqlite.getClusters()
sqlite.close()
if (clusters.length === 0) {
return NextResponse.json({ matrix: [], labels: [], clusterIds: [] })
}
const similarity = computeKeywordSimilarity(clusters)
return NextResponse.json(similarity)
} catch (error) {
console.error('Similarity computation error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,92 @@
import { NextRequest, NextResponse } from 'next/server'
import { SQLiteStorage } from '@rta/core'
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { format, type } = body
const sqlite = new SQLiteStorage()
const clusters = sqlite.getClusters()
const questions = sqlite.getQuestions()
sqlite.close()
if (format === 'json' && type === 'faq-schema') {
const faqSchema = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: questions
.filter(q => !q.addressed)
.slice(0, 20)
.map(q => ({
'@type': 'Question',
name: q.text,
acceptedAnswer: {
'@type': 'Answer',
text: '', // To be filled in
},
})),
}
return NextResponse.json(faqSchema)
}
if (format === 'markdown' && type === 'content-brief') {
let markdown = '# Content Brief\n\n'
for (const cluster of clusters) {
markdown += `## ${cluster.problem}\n\n`
markdown += `${cluster.description}\n\n`
markdown += `**Keywords:** ${cluster.keywords.join(', ')}\n\n`
markdown += `**Actionable Insight:** ${cluster.actionableInsight}\n\n`
const clusterQuestions = questions.filter(q => q.clusterId === cluster.clusterId)
if (clusterQuestions.length > 0) {
markdown += `### Questions to Address\n\n`
for (const q of clusterQuestions.slice(0, 5)) {
markdown += `- ${q.text}\n`
}
markdown += '\n'
}
markdown += '---\n\n'
}
return new NextResponse(markdown, {
headers: {
'Content-Type': 'text/markdown',
'Content-Disposition': 'attachment; filename="content-brief.md"',
},
})
}
if (format === 'csv') {
const headers = ['Problem', 'Description', 'Size', 'Engagement', 'Keywords', 'Subreddits']
const rows = clusters.map(c => [
`"${c.problem.replace(/"/g, '""')}"`,
`"${c.description.replace(/"/g, '""')}"`,
c.size,
c.totalEngagement,
`"${c.keywords.join(', ')}"`,
`"${c.subreddits.join(', ')}"`,
])
const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\n')
return new NextResponse(csv, {
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': 'attachment; filename="clusters.csv"',
},
})
}
return NextResponse.json({ clusters, questions })
} catch (error) {
console.error('Export error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Export failed' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from 'next/server'
import { SQLiteStorage } from '@rta/core'
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const body = await request.json()
const { addressed } = body
const sqlite = new SQLiteStorage()
sqlite.markQuestionAddressed(id, addressed)
sqlite.close()
return NextResponse.json({ success: true })
} catch (error) {
console.error('Update question error:', error)
return NextResponse.json({ error: 'Failed to update question' }, { status: 500 })
}
}

View File

@ -0,0 +1,25 @@
import { NextResponse } from 'next/server'
import { SQLiteStorage } from '@rta/core'
export async function GET() {
try {
const sqlite = new SQLiteStorage()
const questions = sqlite.getQuestions()
const clusters = sqlite.getClusters()
sqlite.close()
const grouped: Record<string, { problem: string; questions: typeof questions }> = {}
for (const cluster of clusters) {
grouped[cluster.clusterId] = {
problem: cluster.problem,
questions: questions.filter(q => q.clusterId === cluster.clusterId),
}
}
return NextResponse.json({ grouped })
} catch (error) {
console.error('Get questions error:', error)
return NextResponse.json({ grouped: {} })
}
}

View File

@ -0,0 +1,15 @@
import { NextResponse } from 'next/server'
import { SQLiteStorage } from '@rta/core'
export async function GET() {
try {
const sqlite = new SQLiteStorage()
const history = sqlite.getScrapeHistory()
sqlite.close()
return NextResponse.json({ history })
} catch (error) {
console.error('Scrape history error:', error)
return NextResponse.json({ history: [] })
}
}

View File

@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from 'next/server'
import {
RedditScraper,
CommentFetcher,
EmbeddingPipeline,
QdrantStorage,
SQLiteStorage,
parseSubredditFromUrl,
} from '@rta/core'
import type { RedditComment } from '@rta/core'
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { url, pages = 5, postsPerPage = 100 } = body
if (!url) {
return NextResponse.json({ success: false, error: 'URL is required' }, { status: 400 })
}
const subreddit = parseSubredditFromUrl(url)
const sqlite = new SQLiteStorage()
const scrapeId = sqlite.startScrape(subreddit, url)
const scraper = new RedditScraper(3000)
const commentFetcher = new CommentFetcher(3000)
const embeddings = new EmbeddingPipeline()
const storage = new QdrantStorage()
await storage.ensureCollection()
const posts = await scraper.fetchPosts({
url,
pages,
postsPerPage,
fetchComments: true,
delayMs: 3000,
})
const commentsByPost = await commentFetcher.fetchAllComments(posts)
const postPoints = await embeddings.embedPosts(posts, commentsByPost)
await storage.upsertPoints(postPoints)
const allComments: RedditComment[] = []
for (const comments of commentsByPost.values()) {
allComments.push(...comments)
}
const commentPoints = await embeddings.embedComments(allComments)
await storage.upsertPoints(commentPoints)
sqlite.completeScrape(scrapeId, posts.length, allComments.length)
sqlite.close()
return NextResponse.json({
success: true,
posts: posts.length,
comments: allComments.length,
})
} catch (error) {
console.error('Scrape error:', error)
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server'
import { EmbeddingPipeline, QdrantStorage } from '@rta/core'
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { query, limit = 10, threshold = 0.5 } = body
if (!query) {
return NextResponse.json({ error: 'Query is required' }, { status: 400 })
}
const embeddings = new EmbeddingPipeline()
const storage = new QdrantStorage()
const vector = await embeddings.embed(query)
const results = await storage.search(vector, limit)
const filtered = results.filter(r => r.score >= threshold)
return NextResponse.json({ results: filtered })
} catch (error) {
console.error('Search error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Search failed' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,92 @@
import { NextResponse } from 'next/server'
import { Ollama } from 'ollama'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
const PROJECT_ROOT = '/mnt/work/dev/personal-projects/reddit-trend-analyzer'
const SETTINGS_FILE = join(PROJECT_ROOT, 'data', 'settings.json')
interface Settings {
summarizationModel: string
sentimentModel: string
embeddingModel: string
}
const DEFAULT_SETTINGS: Settings = {
summarizationModel: 'llama3.2',
sentimentModel: 'llama3.2',
embeddingModel: 'nomic-embed-text',
}
function getSettings(): Settings {
try {
if (existsSync(SETTINGS_FILE)) {
const data = JSON.parse(readFileSync(SETTINGS_FILE, 'utf-8'))
return { ...DEFAULT_SETTINGS, ...data }
}
} catch {}
return DEFAULT_SETTINGS
}
function saveSettings(settings: Settings): void {
const dataDir = join(PROJECT_ROOT, 'data')
if (!existsSync(dataDir)) {
mkdirSync(dataDir, { recursive: true })
}
writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2))
}
export async function GET() {
try {
const ollama = new Ollama({
host: process.env.OLLAMA_HOST || 'http://localhost:11434',
})
const models = await ollama.list()
const settings = getSettings()
const availableModels = models.models.map(m => ({
name: m.name,
size: m.size,
modified: m.modified_at,
}))
return NextResponse.json({
settings,
availableModels,
})
} catch (error) {
console.error('Settings GET error:', error)
return NextResponse.json({
settings: DEFAULT_SETTINGS,
availableModels: [],
error: 'Failed to connect to Ollama',
})
}
}
export async function POST(request: Request) {
try {
const body = await request.json()
const currentSettings = getSettings()
const newSettings: Settings = {
summarizationModel: body.summarizationModel ?? currentSettings.summarizationModel,
sentimentModel: body.sentimentModel ?? currentSettings.sentimentModel,
embeddingModel: body.embeddingModel ?? currentSettings.embeddingModel,
}
saveSettings(newSettings)
return NextResponse.json({
success: true,
settings: newSettings,
})
} catch (error) {
console.error('Settings POST error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to save settings' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,30 @@
import { NextResponse } from 'next/server'
import { QdrantStorage, SQLiteStorage } from '@rta/core'
export async function GET() {
try {
const qdrant = new QdrantStorage()
const sqlite = new SQLiteStorage()
const qdrantStats = await qdrant.getStats()
const sqliteStats = sqlite.getStats()
sqlite.close()
return NextResponse.json({
posts: qdrantStats.posts,
comments: qdrantStats.comments,
subreddits: qdrantStats.subreddits,
clusters: sqliteStats.clusterCount,
questions: sqliteStats.questionCount,
})
} catch (error) {
console.error('Stats error:', error)
return NextResponse.json({
posts: 0,
comments: 0,
subreddits: [],
clusters: 0,
questions: 0,
})
}
}

View File

@ -0,0 +1,68 @@
import type { Metadata } from 'next'
import '@/styles/globals.css'
import { Providers } from './providers'
export const metadata: Metadata = {
title: 'Reddit Trend Analyzer',
description: 'Discover common problems and questions in Reddit communities',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className="min-h-screen bg-background antialiased">
<Providers>
<div className="flex min-h-screen">
<aside className="w-64 border-r border-border bg-sidebar p-4">
<div className="mb-6">
<h1 className="text-lg font-semibold text-sidebar-foreground">
Reddit Trends
</h1>
<p className="text-xs text-muted-foreground mt-1">
Press <kbd className="rounded bg-muted px-1 py-0.5">Ctrl+K</kbd> for commands
</p>
</div>
<nav className="space-y-2">
<a
href="/"
className="block rounded-lg px-3 py-2 text-sm font-medium text-sidebar-foreground hover:bg-sidebar-accent"
>
Dashboard
</a>
<a
href="/problems"
className="block rounded-lg px-3 py-2 text-sm font-medium text-sidebar-foreground hover:bg-sidebar-accent"
>
Problems
</a>
<a
href="/questions"
className="block rounded-lg px-3 py-2 text-sm font-medium text-sidebar-foreground hover:bg-sidebar-accent"
>
Questions
</a>
<a
href="/scrape"
className="block rounded-lg px-3 py-2 text-sm font-medium text-sidebar-foreground hover:bg-sidebar-accent"
>
Scrape
</a>
<a
href="/settings"
className="block rounded-lg px-3 py-2 text-sm font-medium text-sidebar-foreground hover:bg-sidebar-accent"
>
Settings
</a>
</nav>
</aside>
<main className="flex-1 p-6">{children}</main>
</div>
</Providers>
</body>
</html>
)
}

View File

@ -0,0 +1,86 @@
'use client'
import { useEffect, useState } from 'react'
interface Stats {
posts: number
comments: number
subreddits: string[]
clusters: number
questions: number
}
export default function DashboardPage() {
const [stats, setStats] = useState<Stats | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/stats')
.then(res => res.json())
.then(data => {
setStats(data)
setLoading(false)
})
.catch(() => setLoading(false))
}, [])
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-muted-foreground">Loading...</div>
</div>
)
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-foreground">Dashboard</h1>
<p className="text-muted-foreground">Overview of your Reddit trend analysis</p>
</div>
<div className="grid grid-cols-4 gap-4">
<div className="rounded-lg border border-border bg-card p-4 shadow-sm">
<div className="text-sm text-muted-foreground">Posts</div>
<div className="text-2xl font-semibold text-foreground">
{stats?.posts.toLocaleString() ?? 0}
</div>
</div>
<div className="rounded-lg border border-border bg-card p-4 shadow-sm">
<div className="text-sm text-muted-foreground">Comments</div>
<div className="text-2xl font-semibold text-foreground">
{stats?.comments.toLocaleString() ?? 0}
</div>
</div>
<div className="rounded-lg border border-border bg-card p-4 shadow-sm">
<div className="text-sm text-muted-foreground">Subreddits</div>
<div className="text-2xl font-semibold text-foreground">
{stats?.subreddits.length ?? 0}
</div>
</div>
<div className="rounded-lg border border-border bg-card p-4 shadow-sm">
<div className="text-sm text-muted-foreground">Clusters</div>
<div className="text-2xl font-semibold text-foreground">
{stats?.clusters ?? 0}
</div>
</div>
</div>
{stats && stats.subreddits.length > 0 && (
<div className="rounded-lg border border-border bg-card p-4 shadow-sm">
<div className="text-sm text-muted-foreground mb-2">Tracked Subreddits</div>
<div className="flex flex-wrap gap-2">
{stats.subreddits.map(sub => (
<span
key={sub}
className="rounded-full bg-secondary px-3 py-1 text-sm text-secondary-foreground"
>
r/{sub}
</span>
))}
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,816 @@
'use client'
import React, { useEffect, useState, useMemo, useCallback } from 'react'
import { useToast } from '@/components/ui/toast'
import {
BarChart,
Bar,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts'
interface Problem {
clusterId: number
problem: string
description: string
size: number
totalEngagement: number
lastActive: number
subreddits: string[]
sampleQuestions: string[]
impactScore?: number
}
interface DiscussionSample {
id: string
type: 'post' | 'comment'
subreddit: string
title?: string
author: string
body: string
score: number
created: number
permalink: string
parent_id?: string
}
function DiscussionSamples({ clusterId }: { clusterId: number }) {
const [samples, setSamples] = useState<DiscussionSample[]>([])
const [loading, setLoading] = useState(true)
const [expandedBodies, setExpandedBodies] = useState<Set<string>>(new Set())
useEffect(() => {
fetch(`/api/clusters/${clusterId}`)
.then(res => res.json())
.then(data => {
setSamples(data.samples || [])
setLoading(false)
})
.catch(() => setLoading(false))
}, [clusterId])
const toggleBody = useCallback((id: string) => {
setExpandedBodies(prev => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}, [])
const formatDate = (timestamp: number) => {
const date = new Date(timestamp * 1000)
return date.toLocaleDateString()
}
if (loading) {
return (
<div className="text-sm text-muted-foreground py-2">
Loading discussions...
</div>
)
}
if (samples.length === 0) {
return (
<div className="text-sm text-muted-foreground py-2">
No discussion samples available. Re-run clustering to populate.
</div>
)
}
return (
<div className="space-y-3">
<div className="text-sm font-medium text-muted-foreground">
Discussion Samples ({samples.length})
</div>
{samples.map(sample => {
const isExpanded = expandedBodies.has(sample.id)
const bodyTruncated = sample.body.length > 300
const displayBody = isExpanded ? sample.body : sample.body.slice(0, 300)
return (
<div
key={sample.id}
className="rounded-lg border border-border bg-background p-3 space-y-2"
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<span
className={`inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium ${
sample.type === 'post'
? 'bg-blue-500/10 text-blue-500'
: 'bg-green-500/10 text-green-500'
}`}
>
{sample.type}
</span>
<span className="text-xs text-muted-foreground">
r/{sample.subreddit}
</span>
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs text-muted-foreground">
u/{sample.author}
</span>
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs text-muted-foreground">
{sample.score} pts
</span>
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs text-muted-foreground">
{formatDate(sample.created)}
</span>
</div>
<a
href={`https://reddit.com${sample.permalink}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline shrink-0"
>
view on reddit
</a>
</div>
{sample.title && (
<div className="font-medium text-foreground">{sample.title}</div>
)}
<div className="text-sm text-foreground whitespace-pre-wrap">
{displayBody}
{bodyTruncated && !isExpanded && '...'}
</div>
{bodyTruncated && (
<button
onClick={() => toggleBody(sample.id)}
className="text-xs text-primary hover:underline"
>
{isExpanded ? 'show less' : 'show more'}
</button>
)}
</div>
)
})}
</div>
)
}
interface Weights {
engagement: number
velocity: number
sentiment: number
}
interface SimilarityData {
matrix: number[][]
labels: string[]
clusterIds: number[]
}
function CorrelationHeatmap({
onCellClick,
}: {
onCellClick?: (clusterIds: [number, number]) => void
}) {
const [data, setData] = useState<SimilarityData | null>(null)
const [loading, setLoading] = useState(true)
const [hoveredCell, setHoveredCell] = useState<{ i: number; j: number } | null>(null)
useEffect(() => {
fetch('/api/clusters/similarity')
.then(res => res.json())
.then(d => {
setData(d)
setLoading(false)
})
.catch(() => setLoading(false))
}, [])
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-muted-foreground">Loading correlation data...</div>
</div>
)
}
if (!data || data.matrix.length === 0) {
return (
<div className="flex items-center justify-center h-32">
<div className="text-sm text-muted-foreground">No clusters to compare</div>
</div>
)
}
const getColor = (value: number) => {
// white (0) -> indigo (1)
const intensity = Math.round(value * 255)
return `rgb(${255 - intensity * 0.6}, ${255 - intensity * 0.62}, ${255 - intensity * 0.05})`
}
const n = data.matrix.length
const cellSize = Math.max(24, Math.min(40, 400 / n))
return (
<div className="space-y-3">
<div className="overflow-x-auto">
<div
className="inline-grid gap-px bg-border"
style={{
gridTemplateColumns: `auto repeat(${n}, ${cellSize}px)`,
gridTemplateRows: `auto repeat(${n}, ${cellSize}px)`,
}}
>
{/* empty corner cell */}
<div className="bg-card" />
{/* column headers */}
{data.labels.map((label, j) => (
<div
key={`col-${j}`}
className="bg-card flex items-end justify-center pb-1 px-1"
style={{ height: cellSize * 2 }}
>
<span
className="text-[10px] text-muted-foreground origin-bottom-left whitespace-nowrap overflow-hidden text-ellipsis"
style={{
transform: 'rotate(-45deg)',
maxWidth: cellSize * 2,
}}
title={label}
>
{label.slice(0, 15)}
</span>
</div>
))}
{/* rows */}
{data.matrix.map((row, i) => (
<React.Fragment key={`row-${i}`}>
{/* row label */}
<div
className="bg-card flex items-center justify-end pr-2"
style={{ width: 100 }}
>
<span
className="text-[10px] text-muted-foreground truncate"
title={data.labels[i]}
>
{data.labels[i].slice(0, 12)}
</span>
</div>
{/* cells */}
{row.map((value, j) => {
const isHovered = hoveredCell?.i === i && hoveredCell?.j === j
return (
<div
key={`cell-${i}-${j}`}
className="relative cursor-pointer transition-all"
style={{
backgroundColor: getColor(value),
width: cellSize,
height: cellSize,
outline: isHovered ? '2px solid #6366f1' : 'none',
outlineOffset: '-1px',
}}
onMouseEnter={() => setHoveredCell({ i, j })}
onMouseLeave={() => setHoveredCell(null)}
onClick={() => onCellClick?.([data.clusterIds[i], data.clusterIds[j]])}
title={`${data.labels[i]}${data.labels[j]}: ${(value * 100).toFixed(0)}%`}
>
{isHovered && (
<div className="absolute z-10 bg-card border border-border rounded px-2 py-1 text-xs shadow-lg whitespace-nowrap -translate-x-1/2 left-1/2 -top-8">
{(value * 100).toFixed(0)}% similar
</div>
)}
</div>
)
})}
</React.Fragment>
))}
</div>
</div>
{/* legend */}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>0%</span>
<div
className="h-3 w-32 rounded"
style={{
background: 'linear-gradient(to right, white, #6366f1)',
}}
/>
<span>100%</span>
<span className="ml-2">keyword overlap</span>
</div>
</div>
)
}
// chart colors - recharts can't parse CSS variables at runtime
const CHART_COLORS = {
primary: '#6366f1', // indigo
secondary: '#8b5cf6', // violet
accent: '#06b6d4', // cyan
muted: '#94a3b8', // slate
grid: '#e2e8f0', // light grid
text: '#64748b', // muted text
palette: ['#6366f1', '#8b5cf6', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#ec4899', '#84cc16'],
}
export default function ProblemsPage() {
const [problems, setProblems] = useState<Problem[]>([])
const [loading, setLoading] = useState(true)
const [expanded, setExpanded] = useState<number | null>(null)
const [clustering, setClustering] = useState(false)
const { addToast, updateToast } = useToast()
const [similarityThreshold, setSimilarityThreshold] = useState(0.5)
const [minClusterSize, setMinClusterSize] = useState(2)
const [weights, setWeights] = useState<Weights>({
engagement: 0.5,
velocity: 0.3,
sentiment: 0.2,
})
const fetchClusters = () => {
fetch('/api/clusters')
.then(res => res.json())
.then(data => {
setProblems(data.clusters || [])
setLoading(false)
})
.catch(() => setLoading(false))
}
useEffect(() => {
fetchClusters()
}, [])
const sortedProblems = useMemo(() => {
if (problems.length === 0) return []
const now = Math.floor(Date.now() / 1000)
const oneWeek = 7 * 24 * 60 * 60
const maxEngagement = Math.max(...problems.map(p => p.totalEngagement))
return [...problems]
.map(p => {
const engagementScore = maxEngagement > 0 ? p.totalEngagement / maxEngagement : 0
const age = now - p.lastActive
const velocityScore = Math.max(0, 1 - age / oneWeek)
const sentimentScore = 0.5
const impactScore =
weights.engagement * engagementScore +
weights.velocity * velocityScore +
weights.sentiment * sentimentScore
return { ...p, impactScore }
})
.sort((a, b) => (b.impactScore || 0) - (a.impactScore || 0))
}, [problems, weights])
const chartData = useMemo(() => {
if (sortedProblems.length === 0) return { impact: [], subreddits: [], sizes: [] }
const impactData = sortedProblems.slice(0, 10).map(p => ({
name: p.problem.slice(0, 30) + (p.problem.length > 30 ? '...' : ''),
impact: Math.round((p.impactScore || 0) * 100),
engagement: p.totalEngagement,
discussions: p.size,
}))
const subredditCounts = new Map<string, number>()
sortedProblems.forEach(p => {
p.subreddits.forEach(sub => {
subredditCounts.set(sub, (subredditCounts.get(sub) || 0) + 1)
})
})
const subredditData = Array.from(subredditCounts.entries())
.map(([name, value]) => ({ name: `r/${name}`, value }))
.sort((a, b) => b.value - a.value)
.slice(0, 8)
const sizeDistribution = sortedProblems.reduce((acc, p) => {
const bucket =
p.size < 5 ? '2-4' : p.size < 10 ? '5-9' : p.size < 20 ? '10-19' : '20+'
acc[bucket] = (acc[bucket] || 0) + 1
return acc
}, {} as Record<string, number>)
const sizeData = Object.entries(sizeDistribution).map(([name, value]) => ({
name: `${name} discussions`,
value,
}))
return { impact: impactData, subreddits: subredditData, sizes: sizeData }
}, [sortedProblems])
const handleRecluster = async () => {
setClustering(true)
const toastId = addToast('Clustering discussions...', 'loading')
try {
const res = await fetch('/api/clusters', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ similarityThreshold, minClusterSize }),
})
const data = await res.json()
if (data.success) {
updateToast(toastId, `Found ${data.clusters?.length || 0} problem clusters`, 'success')
fetchClusters()
} else {
updateToast(toastId, data.error || 'Clustering failed', 'error')
}
} catch (e) {
updateToast(toastId, 'Clustering failed - check console', 'error')
} finally {
setClustering(false)
}
}
const updateWeight = (key: keyof Weights, value: number) => {
setWeights(prev => ({ ...prev, [key]: value }))
}
const formatDate = (timestamp: number) => {
const date = new Date(timestamp * 1000)
return date.toLocaleDateString()
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-muted-foreground">Loading...</div>
</div>
)
}
const clusterControls = (
<div className="rounded-lg border border-border bg-card p-4 space-y-4">
<div className="text-sm font-medium text-foreground">Clustering Settings</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs text-muted-foreground mb-1">
Similarity Threshold: {similarityThreshold.toFixed(2)}
</label>
<input
type="range"
min="0.3"
max="0.9"
step="0.05"
value={similarityThreshold}
onChange={e => setSimilarityThreshold(parseFloat(e.target.value))}
className="w-full accent-primary"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>Loose (0.3)</span>
<span>Strict (0.9)</span>
</div>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-1">
Min Cluster Size
</label>
<select
value={minClusterSize}
onChange={e => setMinClusterSize(parseInt(e.target.value))}
className="w-full rounded border border-border bg-background px-2 py-1 text-sm text-foreground"
>
<option value={2}>2 discussions</option>
<option value={3}>3 discussions</option>
<option value={5}>5 discussions</option>
<option value={10}>10 discussions</option>
</select>
</div>
</div>
</div>
)
const weightControls = (
<div className="rounded-lg border border-border bg-card p-4 space-y-4">
<div className="text-sm font-medium text-foreground">Impact Score Weights</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-xs text-muted-foreground mb-1">
Engagement: {(weights.engagement * 100).toFixed(0)}%
</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={weights.engagement}
onChange={e => updateWeight('engagement', parseFloat(e.target.value))}
className="w-full accent-primary"
/>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-1">
Velocity: {(weights.velocity * 100).toFixed(0)}%
</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={weights.velocity}
onChange={e => updateWeight('velocity', parseFloat(e.target.value))}
className="w-full accent-primary"
/>
</div>
<div>
<label className="block text-xs text-muted-foreground mb-1">
Sentiment: {(weights.sentiment * 100).toFixed(0)}%
</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={weights.sentiment}
onChange={e => updateWeight('sentiment', parseFloat(e.target.value))}
className="w-full accent-primary"
/>
</div>
</div>
</div>
)
if (problems.length === 0) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-foreground">Problem Explorer</h1>
<p className="text-muted-foreground">View and analyze problem clusters</p>
</div>
{clusterControls}
<div className="flex justify-end">
<button
onClick={handleRecluster}
disabled={clustering}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
>
{clustering ? 'Clustering...' : 'Run Clustering'}
</button>
</div>
<div className="rounded-lg border border-border bg-card p-8 text-center">
<p className="text-muted-foreground">No problems found. Adjust settings and run clustering.</p>
</div>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Problem Explorer</h1>
<p className="text-muted-foreground">
{sortedProblems.length} problem clusters identified
</p>
</div>
<button
onClick={handleRecluster}
disabled={clustering}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
>
{clustering ? 'Clustering...' : 'Re-cluster'}
</button>
</div>
<div className="grid grid-cols-2 gap-4">
{clusterControls}
{weightControls}
</div>
{sortedProblems.length > 0 && (
<div className="space-y-4">
<div className="text-lg font-medium text-foreground">Problem Analytics</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="rounded-lg border border-border bg-card p-4">
<div className="text-sm font-medium text-foreground mb-4">
Top Problems by Impact Score
</div>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData.impact}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} />
<XAxis
dataKey="name"
angle={-45}
textAnchor="end"
height={100}
tick={{ fill: CHART_COLORS.text, fontSize: 11 }}
/>
<YAxis tick={{ fill: CHART_COLORS.text }} />
<Tooltip
contentStyle={{
backgroundColor: '#fff',
border: `1px solid ${CHART_COLORS.grid}`,
borderRadius: '6px',
}}
/>
<Bar dataKey="impact" fill={CHART_COLORS.primary} radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="rounded-lg border border-border bg-card p-4">
<div className="text-sm font-medium text-foreground mb-4">
Discussion Distribution by Subreddit
</div>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={chartData.subreddits}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) =>
`${name} (${(percent * 100).toFixed(0)}%)`
}
outerRadius={80}
dataKey="value"
>
{chartData.subreddits.map((_, index) => (
<Cell
key={`cell-${index}`}
fill={CHART_COLORS.palette[index % CHART_COLORS.palette.length]}
/>
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: '#fff',
border: `1px solid ${CHART_COLORS.grid}`,
borderRadius: '6px',
}}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="rounded-lg border border-border bg-card p-4">
<div className="text-sm font-medium text-foreground mb-4">
Cluster Size Distribution
</div>
<ResponsiveContainer width="100%" height={250}>
<BarChart data={chartData.sizes}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} />
<XAxis
dataKey="name"
tick={{ fill: CHART_COLORS.text }}
/>
<YAxis tick={{ fill: CHART_COLORS.text }} allowDecimals={false} />
<Tooltip
contentStyle={{
backgroundColor: '#fff',
border: `1px solid ${CHART_COLORS.grid}`,
borderRadius: '6px',
}}
/>
<Bar dataKey="value" fill={CHART_COLORS.accent} radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="rounded-lg border border-border bg-card p-4">
<div className="text-sm font-medium text-foreground mb-4">Key Metrics</div>
<div className="space-y-4">
<div>
<div className="text-2xl font-bold text-foreground">
{sortedProblems.length}
</div>
<div className="text-xs text-muted-foreground">Total Clusters</div>
</div>
<div>
<div className="text-2xl font-bold text-foreground">
{sortedProblems.reduce((sum, p) => sum + p.size, 0).toLocaleString()}
</div>
<div className="text-xs text-muted-foreground">Total Discussions</div>
</div>
<div>
<div className="text-2xl font-bold text-foreground">
{sortedProblems
.reduce((sum, p) => sum + p.totalEngagement, 0)
.toLocaleString()}
</div>
<div className="text-xs text-muted-foreground">Total Upvotes</div>
</div>
<div>
<div className="text-2xl font-bold text-foreground">
{new Set(sortedProblems.flatMap(p => p.subreddits)).size}
</div>
<div className="text-xs text-muted-foreground">Unique Subreddits</div>
</div>
</div>
</div>
</div>
{/* correlation heatmap */}
<div className="rounded-lg border border-border bg-card p-4">
<div className="text-sm font-medium text-foreground mb-4">
Problem Cluster Correlation
</div>
<CorrelationHeatmap
onCellClick={([id1, id2]) => {
// expand first cluster that isn't already expanded
if (expanded !== id1) {
setExpanded(id1)
} else if (expanded !== id2) {
setExpanded(id2)
}
}}
/>
</div>
</div>
)}
<div className="rounded-lg border border-border bg-card overflow-hidden">
<table className="w-full">
<thead className="bg-muted">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">
Problem
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">
Impact
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">
Discussions
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">
Upvotes
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">
Last Active
</th>
</tr>
</thead>
<tbody>
{sortedProblems.map(problem => (
<React.Fragment key={problem.clusterId}>
<tr
onClick={() => setExpanded(expanded === problem.clusterId ? null : problem.clusterId)}
className="border-t border-border hover:bg-accent/50 cursor-pointer"
>
<td className="px-4 py-3">
<div className="font-medium text-foreground">{problem.problem}</div>
<div className="text-sm text-muted-foreground">
{problem.subreddits.map(s => `r/${s}`).join(', ')}
</div>
</td>
<td className="px-4 py-3 text-foreground">
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{((problem.impactScore || 0) * 100).toFixed(0)}
</span>
</td>
<td className="px-4 py-3 text-foreground">{problem.size}</td>
<td className="px-4 py-3 text-foreground">
{problem.totalEngagement.toLocaleString()}
</td>
<td className="px-4 py-3 text-foreground">{formatDate(problem.lastActive)}</td>
</tr>
{expanded === problem.clusterId && (
<tr className="border-t border-border bg-muted/50">
<td colSpan={5} className="px-4 py-4">
<div className="space-y-4">
<p className="text-foreground">{problem.description}</p>
<DiscussionSamples clusterId={problem.clusterId} />
{problem.sampleQuestions.length > 0 && (
<div>
<div className="text-sm font-medium text-muted-foreground mb-1">
Sample Questions:
</div>
<ul className="list-disc list-inside text-sm text-foreground">
{problem.sampleQuestions.map((q, i) => (
<li key={i}>{q}</li>
))}
</ul>
</div>
)}
</div>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@ -0,0 +1,14 @@
'use client'
import { ReactNode } from 'react'
import { ToastProvider } from '@/components/ui/toast'
import { CommandPalette } from '@/components/controls/command-palette'
export function Providers({ children }: { children: ReactNode }) {
return (
<ToastProvider>
{children}
<CommandPalette />
</ToastProvider>
)
}

View File

@ -0,0 +1,164 @@
'use client'
import { useEffect, useState } from 'react'
interface Question {
id: string
text: string
clusterId: number
engagement: number
addressed: boolean
}
interface GroupedQuestions {
[clusterId: string]: {
problem: string
questions: Question[]
}
}
export default function QuestionsPage() {
const [grouped, setGrouped] = useState<GroupedQuestions>({})
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState<'all' | 'unanswered'>('all')
useEffect(() => {
fetch('/api/questions')
.then(res => res.json())
.then(data => {
setGrouped(data.grouped || {})
setLoading(false)
})
.catch(() => setLoading(false))
}, [])
const handleToggleAddressed = async (id: string, addressed: boolean) => {
await fetch(`/api/questions/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ addressed: !addressed }),
})
setGrouped(prev => {
const updated = { ...prev }
for (const clusterId of Object.keys(updated)) {
const cluster = updated[clusterId]
if (cluster) {
cluster.questions = cluster.questions.map(q =>
q.id === id ? { ...q, addressed: !addressed } : q
)
}
}
return updated
})
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-muted-foreground">Loading...</div>
</div>
)
}
const clusterIds = Object.keys(grouped)
const totalQuestions = clusterIds.reduce((sum, id) => {
return sum + (grouped[id]?.questions.length ?? 0)
}, 0)
if (totalQuestions === 0) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-foreground">Question Bank</h1>
<p className="text-muted-foreground">Extracted questions from discussions</p>
</div>
<div className="rounded-lg border border-border bg-card p-8 text-center">
<p className="text-muted-foreground">No questions found. Run clustering first.</p>
</div>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">Question Bank</h1>
<p className="text-muted-foreground">{totalQuestions} questions extracted</p>
</div>
<div className="flex gap-2">
<button
onClick={() => setFilter('all')}
className={`rounded-lg px-4 py-2 text-sm font-medium ${
filter === 'all'
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground'
}`}
>
All
</button>
<button
onClick={() => setFilter('unanswered')}
className={`rounded-lg px-4 py-2 text-sm font-medium ${
filter === 'unanswered'
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground'
}`}
>
Unanswered
</button>
</div>
</div>
<div className="space-y-4">
{clusterIds.map(clusterId => {
const cluster = grouped[clusterId]
if (!cluster) return null
const filteredQuestions =
filter === 'unanswered'
? cluster.questions.filter(q => !q.addressed)
: cluster.questions
if (filteredQuestions.length === 0) return null
return (
<div
key={clusterId}
className="rounded-lg border border-border bg-card overflow-hidden"
>
<div className="bg-muted px-4 py-3 font-medium text-foreground">
{cluster.problem}
</div>
<ul className="divide-y divide-border">
{filteredQuestions.map(q => (
<li key={q.id} className="flex items-start gap-3 px-4 py-3">
<input
type="checkbox"
checked={q.addressed}
onChange={() => handleToggleAddressed(q.id, q.addressed)}
className="mt-1 h-4 w-4 rounded border-border"
/>
<div className="flex-1">
<p
className={`text-foreground ${
q.addressed ? 'line-through text-muted-foreground' : ''
}`}
>
{q.text}
</p>
<p className="text-sm text-muted-foreground mt-1">
{q.engagement} upvotes
</p>
</div>
</li>
))}
</ul>
</div>
)
})}
</div>
</div>
)
}

View File

@ -0,0 +1,152 @@
'use client'
import { useState, useEffect } from 'react'
import { useToast } from '@/components/ui/toast'
interface ScrapeHistory {
id: number
subreddit: string
url: string
postsScraped: number
commentsScraped: number
startedAt: number
completedAt: number | null
}
export default function ScrapePage() {
const [url, setUrl] = useState('')
const [pages, setPages] = useState(5)
const [isLoading, setIsLoading] = useState(false)
const [history, setHistory] = useState<ScrapeHistory[]>([])
const { addToast, updateToast } = useToast()
useEffect(() => {
fetch('/api/scrape/history')
.then(res => res.json())
.then(data => setHistory(data.history || []))
}, [])
const handleScrape = async () => {
if (!url) return
setIsLoading(true)
const toastId = addToast('Scraping subreddit... this may take a while', 'loading')
try {
const res = await fetch('/api/scrape', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, pages }),
})
const data = await res.json()
if (data.success) {
updateToast(toastId, `Scraped ${data.posts} posts and ${data.comments} comments`, 'success')
const historyRes = await fetch('/api/scrape/history')
const historyData = await historyRes.json()
setHistory(historyData.history || [])
} else {
updateToast(toastId, data.error || 'Scrape failed', 'error')
}
} catch (err) {
updateToast(toastId, 'Scrape failed - check console', 'error')
} finally {
setIsLoading(false)
}
}
const formatDate = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleString()
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-foreground">Scrape Manager</h1>
<p className="text-muted-foreground">Scrape subreddits and manage data collection</p>
</div>
<div className="rounded-lg border border-border bg-card p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1">
Subreddit URL
</label>
<input
type="text"
value={url}
onChange={e => setUrl(e.target.value)}
placeholder="https://reddit.com/r/programming/best"
className="w-full rounded-lg border border-input bg-background px-3 py-2 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
disabled={isLoading}
/>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1">
Pages to Scrape
</label>
<input
type="number"
value={pages}
onChange={e => setPages(parseInt(e.target.value) || 1)}
min={1}
max={20}
className="w-32 rounded-lg border border-input bg-background px-3 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
disabled={isLoading}
/>
</div>
<button
onClick={handleScrape}
disabled={isLoading || !url}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
>
{isLoading ? 'Scraping...' : 'Start Scrape'}
</button>
</div>
{history.length > 0 && (
<div className="rounded-lg border border-border bg-card overflow-hidden">
<div className="bg-muted px-4 py-3 font-medium text-foreground">
Scrape History
</div>
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-2 text-left text-sm font-medium text-muted-foreground">
Subreddit
</th>
<th className="px-4 py-2 text-left text-sm font-medium text-muted-foreground">
Posts
</th>
<th className="px-4 py-2 text-left text-sm font-medium text-muted-foreground">
Comments
</th>
<th className="px-4 py-2 text-left text-sm font-medium text-muted-foreground">
Started
</th>
<th className="px-4 py-2 text-left text-sm font-medium text-muted-foreground">
Status
</th>
</tr>
</thead>
<tbody>
{history.map(h => (
<tr key={h.id} className="border-t border-border">
<td className="px-4 py-2 text-foreground">r/{h.subreddit}</td>
<td className="px-4 py-2 text-foreground">{h.postsScraped}</td>
<td className="px-4 py-2 text-foreground">{h.commentsScraped}</td>
<td className="px-4 py-2 text-foreground">{formatDate(h.startedAt)}</td>
<td className="px-4 py-2">
{h.completedAt ? (
<span className="text-green-600">Complete</span>
) : (
<span className="text-yellow-600">In Progress</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,203 @@
'use client'
import React, { useEffect, useState } from 'react'
import { useToast } from '@/components/ui/toast'
interface Model {
name: string
size: number
modified: string
}
interface Settings {
summarizationModel: string
sentimentModel: string
embeddingModel: string
}
export default function SettingsPage() {
const [settings, setSettings] = useState<Settings | null>(null)
const [models, setModels] = useState<Model[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const { addToast } = useToast()
useEffect(() => {
fetch('/api/settings')
.then(res => res.json())
.then(data => {
setSettings(data.settings)
setModels(data.availableModels || [])
if (data.error) setError(data.error)
setLoading(false)
})
.catch(() => {
setError('Failed to load settings')
setLoading(false)
})
}, [])
const handleSave = async () => {
if (!settings) return
setSaving(true)
try {
const res = await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
})
const data = await res.json()
if (data.success) {
addToast('Settings saved', 'success')
} else {
addToast(data.error || 'Failed to save', 'error')
}
} catch {
addToast('Failed to save settings', 'error')
} finally {
setSaving(false)
}
}
const updateSetting = (key: keyof Settings, value: string) => {
setSettings(prev => prev ? { ...prev, [key]: value } : null)
}
const formatSize = (bytes: number) => {
const gb = bytes / (1024 * 1024 * 1024)
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(bytes / (1024 * 1024)).toFixed(0)} MB`
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-muted-foreground">Loading...</div>
</div>
)
}
return (
<div className="space-y-6 max-w-2xl">
<div>
<h1 className="text-2xl font-semibold text-foreground">Settings</h1>
<p className="text-muted-foreground">Configure models and preferences</p>
</div>
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-destructive">
{error}
</div>
)}
<div className="rounded-lg border border-border bg-card p-6 space-y-6">
<div>
<h2 className="text-lg font-medium text-foreground mb-4">Ollama Models</h2>
<p className="text-sm text-muted-foreground mb-4">
{models.length} models available locally
</p>
</div>
{settings && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Summarization Model
</label>
<p className="text-xs text-muted-foreground mb-2">
Used for generating problem summaries and extracting questions from clusters
</p>
<select
value={settings.summarizationModel}
onChange={e => updateSetting('summarizationModel', e.target.value)}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground"
>
{models.map(m => (
<option key={m.name} value={m.name}>
{m.name} ({formatSize(m.size)})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Sentiment Model
</label>
<p className="text-xs text-muted-foreground mb-2">
Used for analyzing sentiment of discussions (positive/negative/neutral)
</p>
<select
value={settings.sentimentModel}
onChange={e => updateSetting('sentimentModel', e.target.value)}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground"
>
{models.map(m => (
<option key={m.name} value={m.name}>
{m.name} ({formatSize(m.size)})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Embedding Model
</label>
<p className="text-xs text-muted-foreground mb-2">
Used for converting text to vectors for semantic search and clustering
</p>
<select
value={settings.embeddingModel}
onChange={e => updateSetting('embeddingModel', e.target.value)}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground"
>
{models.filter(m => m.name.includes('embed') || m.name.includes('nomic')).length > 0
? models.filter(m => m.name.includes('embed') || m.name.includes('nomic')).map(m => (
<option key={m.name} value={m.name}>
{m.name} ({formatSize(m.size)})
</option>
))
: models.map(m => (
<option key={m.name} value={m.name}>
{m.name} ({formatSize(m.size)})
</option>
))
}
</select>
</div>
</div>
)}
<div className="pt-4 border-t border-border">
<button
onClick={handleSave}
disabled={saving}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save Settings'}
</button>
</div>
</div>
<div className="rounded-lg border border-border bg-card p-6">
<h2 className="text-lg font-medium text-foreground mb-4">Available Models</h2>
<div className="space-y-2">
{models.map(m => (
<div key={m.name} className="flex justify-between items-center text-sm">
<span className="text-foreground font-mono">{m.name}</span>
<span className="text-muted-foreground">{formatSize(m.size)}</span>
</div>
))}
{models.length === 0 && (
<p className="text-muted-foreground text-sm">
No models found. Make sure Ollama is running and has models installed.
</p>
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,7 @@
'use client'
import { CommandPalette } from './command-palette'
export function CommandPaletteWrapper() {
return <CommandPalette />
}

View File

@ -0,0 +1,174 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { Command } from 'cmdk'
import { useToast } from '@/components/ui/toast'
interface CommandPaletteProps {
onSearch?: (query: string) => void
}
export function CommandPalette({ onSearch }: CommandPaletteProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const router = useRouter()
const { addToast, updateToast, removeToast } = useToast()
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
setOpen(open => !open)
}
}
document.addEventListener('keydown', down)
return () => document.removeEventListener('keydown', down)
}, [])
const runCommand = useCallback((command: () => void) => {
setOpen(false)
command()
}, [])
if (!open) return null
return (
<div className="fixed inset-0 z-50 bg-black/50" onClick={() => setOpen(false)}>
<div
className="fixed left-1/2 top-1/4 w-full max-w-lg -translate-x-1/2 rounded-xl border border-border bg-popover p-0 shadow-2xl"
onClick={e => e.stopPropagation()}
>
<Command className="flex flex-col">
<div className="flex items-center border-b border-border px-4">
<Command.Input
value={search}
onValueChange={setSearch}
placeholder="Type a command or search..."
className="flex-1 bg-transparent py-3 text-foreground outline-none placeholder:text-muted-foreground"
/>
</div>
<Command.List className="max-h-72 overflow-y-auto p-2">
<Command.Empty className="py-6 text-center text-sm text-muted-foreground">
No results found.
</Command.Empty>
<Command.Group heading="Navigation" className="text-xs text-muted-foreground mb-2">
<Command.Item
onSelect={() => runCommand(() => router.push('/'))}
className="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-foreground hover:bg-accent"
>
Go to Dashboard
</Command.Item>
<Command.Item
onSelect={() => runCommand(() => router.push('/problems'))}
className="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-foreground hover:bg-accent"
>
Go to Problems
</Command.Item>
<Command.Item
onSelect={() => runCommand(() => router.push('/questions'))}
className="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-foreground hover:bg-accent"
>
Go to Questions
</Command.Item>
<Command.Item
onSelect={() => runCommand(() => router.push('/scrape'))}
className="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-foreground hover:bg-accent"
>
Go to Scrape Manager
</Command.Item>
</Command.Group>
<Command.Group heading="Actions" className="text-xs text-muted-foreground mb-2 mt-4">
<Command.Item
onSelect={() => runCommand(async () => {
const toastId = addToast('Clustering discussions...', 'loading')
try {
const res = await fetch('/api/clusters', { method: 'POST' })
const data = await res.json()
if (data.success) {
updateToast(toastId, `Found ${data.clusters?.length || 0} problem clusters`, 'success')
router.refresh()
} else {
updateToast(toastId, data.error || 'Clustering failed', 'error')
}
} catch (e) {
updateToast(toastId, 'Clustering failed - check console', 'error')
}
})}
className="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-foreground hover:bg-accent"
>
Re-cluster discussions
</Command.Item>
<Command.Item
onSelect={() => runCommand(async () => {
const toastId = addToast('Exporting CSV...', 'loading')
try {
const res = await fetch('/api/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ format: 'csv' }),
})
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'clusters.csv'
a.click()
updateToast(toastId, 'CSV downloaded', 'success')
} catch (e) {
updateToast(toastId, 'Export failed', 'error')
}
})}
className="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-foreground hover:bg-accent"
>
Export clusters as CSV
</Command.Item>
<Command.Item
onSelect={() => runCommand(async () => {
const toastId = addToast('Exporting FAQ schema...', 'loading')
try {
const res = await fetch('/api/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ format: 'json', type: 'faq-schema' }),
})
const data = await res.json()
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'faq-schema.json'
a.click()
updateToast(toastId, 'FAQ schema downloaded', 'success')
} catch (e) {
updateToast(toastId, 'Export failed', 'error')
}
})}
className="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-foreground hover:bg-accent"
>
Export FAQ schema (JSON-LD)
</Command.Item>
</Command.Group>
{search && onSearch && (
<Command.Group heading="Search" className="text-xs text-muted-foreground mb-2 mt-4">
<Command.Item
onSelect={() => runCommand(() => onSearch(search))}
className="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-foreground hover:bg-accent"
>
Semantic search: "{search}"
</Command.Item>
</Command.Group>
)}
</Command.List>
<div className="border-t border-border px-4 py-2 text-xs text-muted-foreground">
Press <kbd className="rounded bg-muted px-1 py-0.5">Esc</kbd> to close
</div>
</Command>
</div>
</div>
)
}

View File

@ -0,0 +1,98 @@
'use client'
import { createContext, useContext, useState, useCallback, ReactNode } from 'react'
interface Toast {
id: string
message: string
type: 'info' | 'success' | 'error' | 'loading'
}
interface ToastContextValue {
toasts: Toast[]
addToast: (message: string, type?: Toast['type']) => string
removeToast: (id: string) => void
updateToast: (id: string, message: string, type?: Toast['type']) => void
}
const ToastContext = createContext<ToastContextValue | null>(null)
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([])
const addToast = useCallback((message: string, type: Toast['type'] = 'info') => {
const id = crypto.randomUUID()
setToasts(prev => [...prev, { id, message, type }])
if (type !== 'loading') {
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
}, 4000)
}
return id
}, [])
const removeToast = useCallback((id: string) => {
setToasts(prev => prev.filter(t => t.id !== id))
}, [])
const updateToast = useCallback((id: string, message: string, type: Toast['type'] = 'info') => {
setToasts(prev => prev.map(t => t.id === id ? { ...t, message, type } : t))
if (type !== 'loading') {
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
}, 4000)
}
}, [])
return (
<ToastContext.Provider value={{ toasts, addToast, removeToast, updateToast }}>
{children}
<ToastContainer toasts={toasts} removeToast={removeToast} />
</ToastContext.Provider>
)
}
export function useToast() {
const context = useContext(ToastContext)
if (!context) {
throw new Error('useToast must be used within a ToastProvider')
}
return context
}
function ToastContainer({ toasts, removeToast }: { toasts: Toast[]; removeToast: (id: string) => void }) {
if (toasts.length === 0) return null
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
{toasts.map(toast => (
<div
key={toast.id}
className={`
flex items-center gap-3 rounded-lg px-4 py-3 shadow-lg
${toast.type === 'error' ? 'bg-red-500 text-white' : ''}
${toast.type === 'success' ? 'bg-green-600 text-white' : ''}
${toast.type === 'loading' ? 'bg-card text-foreground border border-border' : ''}
${toast.type === 'info' ? 'bg-card text-foreground border border-border' : ''}
`}
>
{toast.type === 'loading' && (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-foreground" />
)}
<span className="text-sm">{toast.message}</span>
{toast.type !== 'loading' && (
<button
onClick={() => removeToast(toast.id)}
className="ml-2 text-current opacity-70 hover:opacity-100"
>
×
</button>
)}
</div>
))}
</div>
)
}

View File

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

View File

@ -0,0 +1,172 @@
@import "tailwindcss";
:root {
--background: oklch(0.9551 0 0);
--foreground: oklch(0.3211 0 0);
--card: oklch(0.9702 0 0);
--card-foreground: oklch(0.3211 0 0);
--popover: oklch(0.9702 0 0);
--popover-foreground: oklch(0.3211 0 0);
--primary: oklch(0.4891 0 0);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.9067 0 0);
--secondary-foreground: oklch(0.3211 0 0);
--muted: oklch(0.8853 0 0);
--muted-foreground: oklch(0.5103 0 0);
--accent: oklch(0.8078 0 0);
--accent-foreground: oklch(0.3211 0 0);
--destructive: oklch(0.5594 0.1900 25.8625);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.8576 0 0);
--input: oklch(0.9067 0 0);
--ring: oklch(0.4891 0 0);
--chart-1: oklch(0.4891 0 0);
--chart-2: oklch(0.4863 0.0361 196.0278);
--chart-3: oklch(0.6534 0 0);
--chart-4: oklch(0.7316 0 0);
--chart-5: oklch(0.8078 0 0);
--sidebar: oklch(0.9370 0 0);
--sidebar-foreground: oklch(0.3211 0 0);
--sidebar-primary: oklch(0.4891 0 0);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.8078 0 0);
--sidebar-accent-foreground: oklch(0.3211 0 0);
--sidebar-border: oklch(0.8576 0 0);
--sidebar-ring: oklch(0.4891 0 0);
--font-sans: Montserrat, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Fira Code, monospace;
--radius: 0.35rem;
--shadow-x: 0px;
--shadow-y: 2px;
--shadow-blur: 0px;
--shadow-spread: 0px;
--shadow-opacity: 0.15;
--shadow-color: hsl(0 0% 20% / 0.1);
--shadow-2xs: 0px 2px 0px 0px hsl(0 0% 20% / 0.07);
--shadow-xs: 0px 2px 0px 0px hsl(0 0% 20% / 0.07);
--shadow-sm: 0px 2px 0px 0px hsl(0 0% 20% / 0.15), 0px 1px 2px -1px hsl(0 0% 20% / 0.15);
--shadow: 0px 2px 0px 0px hsl(0 0% 20% / 0.15), 0px 1px 2px -1px hsl(0 0% 20% / 0.15);
--shadow-md: 0px 2px 0px 0px hsl(0 0% 20% / 0.15), 0px 2px 4px -1px hsl(0 0% 20% / 0.15);
--shadow-lg: 0px 2px 0px 0px hsl(0 0% 20% / 0.15), 0px 4px 6px -1px hsl(0 0% 20% / 0.15);
--shadow-xl: 0px 2px 0px 0px hsl(0 0% 20% / 0.15), 0px 8px 10px -1px hsl(0 0% 20% / 0.15);
--shadow-2xl: 0px 2px 0px 0px hsl(0 0% 20% / 0.38);
--tracking-normal: 0em;
--spacing: 0.25rem;
}
.dark {
--background: oklch(0.2178 0 0);
--foreground: oklch(0.8853 0 0);
--card: oklch(0.2435 0 0);
--card-foreground: oklch(0.8853 0 0);
--popover: oklch(0.2435 0 0);
--popover-foreground: oklch(0.8853 0 0);
--primary: oklch(0.7058 0 0);
--primary-foreground: oklch(0.2178 0 0);
--secondary: oklch(0.3092 0 0);
--secondary-foreground: oklch(0.8853 0 0);
--muted: oklch(0.2850 0 0);
--muted-foreground: oklch(0.5999 0 0);
--accent: oklch(0.3715 0 0);
--accent-foreground: oklch(0.8853 0 0);
--destructive: oklch(0.6591 0.1530 22.1703);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.3290 0 0);
--input: oklch(0.3092 0 0);
--ring: oklch(0.7058 0 0);
--chart-1: oklch(0.7058 0 0);
--chart-2: oklch(0.6714 0.0339 206.3482);
--chart-3: oklch(0.5452 0 0);
--chart-4: oklch(0.4604 0 0);
--chart-5: oklch(0.3715 0 0);
--sidebar: oklch(0.2393 0 0);
--sidebar-foreground: oklch(0.8853 0 0);
--sidebar-primary: oklch(0.7058 0 0);
--sidebar-primary-foreground: oklch(0.2178 0 0);
--sidebar-accent: oklch(0.3715 0 0);
--sidebar-accent-foreground: oklch(0.8853 0 0);
--sidebar-border: oklch(0.3290 0 0);
--sidebar-ring: oklch(0.7058 0 0);
--font-sans: Inter, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Fira Code, monospace;
--radius: 0.35rem;
--shadow-x: 0px;
--shadow-y: 2px;
--shadow-blur: 0px;
--shadow-spread: 0px;
--shadow-opacity: 0.15;
--shadow-color: hsl(0 0% 20% / 0.1);
--shadow-2xs: 0px 2px 0px 0px hsl(0 0% 20% / 0.07);
--shadow-xs: 0px 2px 0px 0px hsl(0 0% 20% / 0.07);
--shadow-sm: 0px 2px 0px 0px hsl(0 0% 20% / 0.15), 0px 1px 2px -1px hsl(0 0% 20% / 0.15);
--shadow: 0px 2px 0px 0px hsl(0 0% 20% / 0.15), 0px 1px 2px -1px hsl(0 0% 20% / 0.15);
--shadow-md: 0px 2px 0px 0px hsl(0 0% 20% / 0.15), 0px 2px 4px -1px hsl(0 0% 20% / 0.15);
--shadow-lg: 0px 2px 0px 0px hsl(0 0% 20% / 0.15), 0px 4px 6px -1px hsl(0 0% 20% / 0.15);
--shadow-xl: 0px 2px 0px 0px hsl(0 0% 20% / 0.15), 0px 8px 10px -1px hsl(0 0% 20% / 0.15);
--shadow-2xl: 0px 2px 0px 0px hsl(0 0% 20% / 0.38);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
}
* {
border-color: var(--border);
}

View File

@ -0,0 +1,40 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
},
"target": "ES2017"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}