GitHub Actions commited on
Commit
2bc9b4f
·
0 Parent(s):

Deploy to HuggingFace Spaces

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .github/workflows/ci.yml +131 -0
  2. .github/workflows/hf-deploy.yml +53 -0
  3. .gitignore +38 -0
  4. Dockerfile +13 -0
  5. LICENSE +21 -0
  6. Makefile +20 -0
  7. README.md +205 -0
  8. app/__init__.py +1 -0
  9. app/database.py +164 -0
  10. app/graph.py +211 -0
  11. app/ingestion.py +221 -0
  12. app/main.py +297 -0
  13. app/model.py +103 -0
  14. dashboard.py +953 -0
  15. frontend/.env.development +4 -0
  16. frontend/.env.production +4 -0
  17. frontend/dist/assets/index-CZMDWpNf.js +0 -0
  18. frontend/dist/assets/index-DgSDpXn3.css +1 -0
  19. frontend/dist/index.html +16 -0
  20. frontend/index.html +15 -0
  21. frontend/package.json +81 -0
  22. frontend/pnpm-lock.yaml +0 -0
  23. frontend/postcss.config.mjs +15 -0
  24. frontend/src/app/App.tsx +333 -0
  25. frontend/src/app/components/CoOccurrenceGraph.tsx +609 -0
  26. frontend/src/app/components/ExportTab.tsx +242 -0
  27. frontend/src/app/components/Header.tsx +140 -0
  28. frontend/src/app/components/OverviewTab.tsx +687 -0
  29. frontend/src/app/components/Sidebar.tsx +335 -0
  30. frontend/src/app/components/SplashScreen.tsx +279 -0
  31. frontend/src/app/components/TermComparisonTab.tsx +364 -0
  32. frontend/src/app/components/figma/ImageWithFallback.tsx +27 -0
  33. frontend/src/app/components/mockData.ts +198 -0
  34. frontend/src/app/components/ui/accordion.tsx +66 -0
  35. frontend/src/app/components/ui/alert-dialog.tsx +157 -0
  36. frontend/src/app/components/ui/alert.tsx +66 -0
  37. frontend/src/app/components/ui/aspect-ratio.tsx +11 -0
  38. frontend/src/app/components/ui/avatar.tsx +53 -0
  39. frontend/src/app/components/ui/badge.tsx +46 -0
  40. frontend/src/app/components/ui/breadcrumb.tsx +109 -0
  41. frontend/src/app/components/ui/button.tsx +58 -0
  42. frontend/src/app/components/ui/calendar.tsx +75 -0
  43. frontend/src/app/components/ui/card.tsx +92 -0
  44. frontend/src/app/components/ui/carousel.tsx +241 -0
  45. frontend/src/app/components/ui/chart.tsx +353 -0
  46. frontend/src/app/components/ui/checkbox.tsx +32 -0
  47. frontend/src/app/components/ui/collapsible.tsx +33 -0
  48. frontend/src/app/components/ui/command.tsx +177 -0
  49. frontend/src/app/components/ui/context-menu.tsx +252 -0
  50. frontend/src/app/components/ui/dialog.tsx +135 -0
.github/workflows/ci.yml ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # WHY GitHub Actions CI: catches broken imports, TypeScript errors, and test
2
+ # failures before they reach HuggingFace Spaces. Runs on every push to main.
3
+ name: CI
4
+
5
+ on:
6
+ push:
7
+ branches: [main]
8
+ pull_request:
9
+ branches: [main]
10
+
11
+ jobs:
12
+ test:
13
+ runs-on: ubuntu-latest
14
+
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - name: Detect C sources
19
+ id: detect-c
20
+ run: |
21
+ if find . -name "*.c" -o -name "*.h" | grep -q .; then
22
+ echo "present=true" >> "$GITHUB_OUTPUT"
23
+ else
24
+ echo "present=false" >> "$GITHUB_OUTPUT"
25
+ fi
26
+
27
+ - name: Install lcov
28
+ run: sudo apt-get update && sudo apt-get install -y lcov
29
+
30
+ - name: Install clang-format (if needed)
31
+ if: steps.detect-c.outputs.present == 'true'
32
+ run: sudo apt-get install -y clang-format
33
+
34
+ - name: Set up Python 3.12
35
+ id: setup-python
36
+ uses: actions/setup-python@v5
37
+ with:
38
+ python-version: "3.12"
39
+
40
+ - name: Cache pip
41
+ uses: actions/cache@v4
42
+ with:
43
+ path: ~/.cache/pip
44
+ key: ${{ runner.os }}-pip-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('requirements.txt', 'requirements-dev.txt') }}
45
+ restore-keys: |
46
+ ${{ runner.os }}-pip-${{ steps.setup-python.outputs.python-version }}-
47
+ ${{ runner.os }}-pip-
48
+
49
+ - name: Install Python dependencies
50
+ run: pip install -r requirements.txt -r requirements-dev.txt
51
+
52
+ - name: Python lint (ruff)
53
+ run: ruff check app/ tests/
54
+
55
+ - name: C style (clang-format)
56
+ if: steps.detect-c.outputs.present == 'true'
57
+ run: |
58
+ mapfile -d '' files < <(find . -name "*.c" -o -name "*.h" -print0)
59
+ clang-format --version
60
+ printf '%s\0' "${files[@]}" | xargs -0 clang-format --dry-run --Werror
61
+
62
+ - name: Check all Python modules import cleanly
63
+ run: |
64
+ python -c "from app.model import ToxicityClassifier"
65
+ python -c "from app.database import get_recent_posts, save_post, seed_if_empty"
66
+ python -c "from app.graph import build_cooccurrence_graph"
67
+ python -c "from app.ingestion import ALGOSPEAK_QUERIES"
68
+ python -c "from app.main import app"
69
+
70
+ - name: Run tests with coverage
71
+ env:
72
+ BLUESKY_HANDLE: "test@test.com"
73
+ BLUESKY_PASSWORD: "testpassword"
74
+ PYTHONPATH: .
75
+ run: |
76
+ python -m pytest tests/ -v --tb=short --cov=app --cov-report=xml
77
+
78
+ # ── Frontend build verification ──────────────────────────────────────────
79
+ # WHY build React in CI: catches TypeScript errors and missing imports
80
+ # before they cause a silent failure during Docker build on HuggingFace.
81
+ - name: Set up Node.js
82
+ uses: actions/setup-node@v4
83
+ with:
84
+ node-version: "20"
85
+
86
+ - name: Install pnpm
87
+ run: npm install -g pnpm
88
+
89
+ - name: Cache pnpm store
90
+ uses: actions/cache@v4
91
+ with:
92
+ path: ~/.local/share/pnpm/store
93
+ key: ${{ runner.os }}-pnpm-${{ hashFiles('frontend/pnpm-lock.yaml') }}
94
+ restore-keys: |
95
+ ${{ runner.os }}-pnpm-
96
+
97
+ - name: Install frontend dependencies
98
+ working-directory: frontend
99
+ run: pnpm install --frozen-lockfile
100
+
101
+ - name: Build frontend (verify no TypeScript errors)
102
+ working-directory: frontend
103
+ run: pnpm build
104
+
105
+ - name: Collect lcov coverage
106
+ id: lcov
107
+ run: |
108
+ if find . -name "*.gcda" -o -name "*.gcno" | grep -q .; then
109
+ lcov --capture --directory . --output-file coverage.info --ignore-errors unused --no-external
110
+ echo "generated=true" >> "$GITHUB_OUTPUT"
111
+ else
112
+ echo "No gcda/gcno files found; skipping lcov capture."
113
+ echo "generated=false" >> "$GITHUB_OUTPUT"
114
+ fi
115
+
116
+ - name: Upload Python coverage to Codecov
117
+ uses: codecov/codecov-action@v4
118
+ with:
119
+ files: ./coverage.xml
120
+ token: ${{ secrets.CODECOV_TOKEN }}
121
+ flags: python
122
+ fail_ci_if_error: false
123
+
124
+ - name: Upload C coverage to Codecov
125
+ if: steps.lcov.outputs.generated == 'true'
126
+ uses: codecov/codecov-action@v4
127
+ with:
128
+ files: ./coverage.info
129
+ token: ${{ secrets.CODECOV_TOKEN }}
130
+ flags: c
131
+ fail_ci_if_error: false
.github/workflows/hf-deploy.yml ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Deploy to HuggingFace Spaces
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+
7
+ jobs:
8
+ deploy:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - name: Checkout code
12
+ uses: actions/checkout@v4
13
+ with:
14
+ fetch-depth: 0
15
+
16
+ - name: Set up Node.js
17
+ uses: actions/setup-node@v4
18
+ with:
19
+ node-version: "20"
20
+
21
+ - name: Install pnpm
22
+ run: npm install -g pnpm
23
+
24
+ - name: Build React frontend
25
+ working-directory: frontend
26
+ run: |
27
+ pnpm install --frozen-lockfile
28
+ pnpm build
29
+
30
+ - name: Push to HuggingFace Space
31
+ env:
32
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
33
+ run: |
34
+ git config --global user.email "deploy@github-actions.com"
35
+ git config --global user.name "GitHub Actions"
36
+
37
+ git checkout --orphan hf-deploy
38
+
39
+ git rm -rf assets/ 2>/dev/null || true
40
+ git rm -r --cached frontend/src/ 2>/dev/null || true
41
+ git rm -r --cached frontend/node_modules/ 2>/dev/null || true
42
+ git rm -r --cached frontend/public/ 2>/dev/null || true
43
+
44
+ cp README_HF.md README.md
45
+ git rm --cached README_HF.md 2>/dev/null || true
46
+ rm README_HF.md
47
+
48
+ git add -f frontend/dist/
49
+ git add .
50
+
51
+ git commit -m "Deploy to HuggingFace Spaces"
52
+ git remote add space https://odeliyach:$HF_TOKEN@huggingface.co/spaces/odeliyach/Algoscope
53
+ git push space hf-deploy:main --force
.gitignore ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Credentials — NEVER commit
2
+ .env
3
+ .env.local
4
+ .env.*.local
5
+
6
+ # Database — contains scraped user data
7
+ algoscope.db
8
+ *.db
9
+
10
+ # Python
11
+ __pycache__/
12
+ *.py[cod]
13
+ *.pyo
14
+ venv/
15
+ .venv/
16
+ *.egg-info/
17
+ dist/
18
+ build/
19
+
20
+ # HuggingFace model cache
21
+ .cache/
22
+
23
+ # Pyvis auto-generated output — not application code
24
+ *.html
25
+ test_graph.html
26
+
27
+ # React build output — generated by `pnpm build`, not source code
28
+ frontend/dist/
29
+ frontend/node_modules/
30
+
31
+ # OS
32
+ .DS_Store
33
+ Thumbs.db
34
+
35
+ # IDE
36
+ .vscode/
37
+ .idea/
38
+ *.swp
Dockerfile ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY app/ ./app/
9
+ COPY frontend/dist/ ./frontend/dist/
10
+
11
+ EXPOSE 7860
12
+
13
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860", "--log-level", "debug"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Odeliya Charitonova
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
Makefile ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .PHONY: install run-api run-dashboard test lint clean
2
+
3
+ install:
4
+ pip install -r requirements.txt
5
+
6
+ run-api:
7
+ uvicorn app.main:app --reload --port 8000
8
+
9
+ run-dashboard:
10
+ streamlit run dashboard.py
11
+
12
+ test:
13
+ python -m pytest tests/ -v --tb=short
14
+
15
+ lint:
16
+ ruff check app/ dashboard.py tests/
17
+
18
+ clean:
19
+ find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
20
+ rm -f algoscope.db
README.md ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: AlgoScope
3
+ emoji: 🔍
4
+ colorFrom: red
5
+ colorTo: yellow
6
+ sdk: docker
7
+ pinned: false
8
+ ---
9
+
10
+ <div align="center">
11
+
12
+ # 🔍 AlgoScope
13
+
14
+ **Real-time algospeak & toxicity detection on Bluesky**
15
+
16
+ [![Build](https://img.shields.io/github/actions/workflow/status/odeliyach/Algoscope/ci.yml?branch=main&label=Build)](https://github.com/odeliyach/Algoscope/actions/workflows/ci.yml)
17
+ [![Coverage](https://img.shields.io/codecov/c/github/odeliyach/Algoscope?branch=main&label=Coverage)](https://codecov.io/gh/odeliyach/Algoscope)
18
+ [![Linting/Style](https://img.shields.io/github/actions/workflow/status/odeliyach/Algoscope/ci.yml?branch=main&label=Linting%2FStyle)](https://github.com/odeliyach/Algoscope/actions/workflows/ci.yml)
19
+ [![Python 3.12](https://img.shields.io/badge/Python-3.12-blue?logo=python)](https://python.org)
20
+ [![HuggingFace Model](https://img.shields.io/badge/Model-AlgoShield-orange?logo=huggingface)](https://huggingface.co/odeliyach/AlgoShield-Algospeak-Detection)
21
+ [![HuggingFace Space](https://img.shields.io/badge/Live%20Demo-Spaces-yellow?logo=huggingface)](https://huggingface.co/spaces/odeliyach/algoscope)
22
+ [![Streamlit](https://img.shields.io/badge/Dashboard-Streamlit-red?logo=streamlit)](https://streamlit.io)
23
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green)](LICENSE)
24
+
25
+ *Odeliya Charitonova · Tel Aviv University, School of CS & AI · 2026*
26
+
27
+ </div>
28
+
29
+ ---
30
+
31
+ ## What is AlgoScope?
32
+
33
+ Algospeak is the evolving coded language people use to evade content moderation — "unalive" instead of suicide, "seggs" instead of sex, "le dollar bean" instead of lesbian. Standard toxicity APIs score these near zero because they look benign to classifiers trained on explicit language.
34
+
35
+ AlgoScope is a live dashboard that catches them anyway. It ingests posts from the Bluesky social network in real time, classifies each one with a fine-tuned DistilBERT model trained specifically on algospeak, and visualizes toxicity patterns, co-occurrence networks, and trend spikes in an interactive dashboard.
36
+
37
+ > **Why this matters:** Algospeak evasion is an active research problem in content moderation. This project turns published NLP research into a live, clickable product.
38
+
39
+ ---
40
+
41
+ ## Live Demo
42
+
43
+ | Resource | Link |
44
+ |----------|------|
45
+ | 🖥️ Live dashboard | [huggingface.co/spaces/odeliyach/algoscope](https://huggingface.co/spaces/odeliyach/algoscope) |
46
+ | 🤗 Fine-tuned model | [odeliyach/AlgoShield-Algospeak-Detection](https://huggingface.co/odeliyach/AlgoShield-Algospeak-Detection) |
47
+ | 💻 GitHub | [github.com/odeliyach/Algoscope](https://github.com/odeliyach/Algoscope) |
48
+
49
+ ---
50
+
51
+ ---
52
+
53
+ ## Features
54
+
55
+ - **🚨 Spike alerts** — red banner when a tracked term exceeds 80% toxic in the last hour
56
+ - **📊 Toxicity over time** — hourly line chart with color-coded data points (green/orange/red by toxicity level)
57
+ - **🕸️ Co-occurrence graph** — interactive word graph built with NetworkX + Pyvis; nodes colored by toxicity rate
58
+ - **⚖️ Term comparison** — side-by-side toxicity profiles for any two tracked terms
59
+ - **📥 Export** — download all analyzed posts as CSV or JSON
60
+ - **🎛️ Threshold slider** — tune precision/recall tradeoff at inference time without retraining
61
+
62
+ ---
63
+
64
+ ## Architecture
65
+
66
+ ```
67
+ ┌─────────────────┐ AT Protocol ┌───────────────────┐
68
+ │ Bluesky API │ ───────────────▶ │ ingestion.py │
69
+ └─────────────────┘ │ dedup + preproc │
70
+ └─────────┬─────────┘
71
+
72
+ ┌─────────▼─────────┐
73
+ │ model.py │
74
+ │ DistilBERT │
75
+ │ singleton + batch│
76
+ └─────────┬─────────┘
77
+
78
+ ┌────────────────────▼──────────────────────┐
79
+ │ database.py │
80
+ │ SQLite · URI-keyed deduplication │
81
+ └────────────────────┬──────────────────────┘
82
+
83
+ ┌────────────────────────────────▼────────────────────────────┐
84
+ │ dashboard.py │
85
+ │ Streamlit �� Plotly · NetworkX · Pyvis (4 tabs) │
86
+ └─────────────────────────────────────────────────────────────┘
87
+ ```
88
+
89
+ **Stack:** Python 3.12 · FastAPI · Streamlit · SQLite · NetworkX · Pyvis · Plotly · HuggingFace Transformers · AT Protocol (Bluesky)
90
+
91
+ ---
92
+
93
+ ## Model — AlgoShield
94
+
95
+ The classifier powering AlgoScope is **AlgoShield**, a DistilBERT model fine-tuned on the [MADOC dataset](https://arxiv.org/abs/2306.01976) (Multimodal Algospeak Detection and Offensive Content). It was trained and evaluated separately — full training code, dataset preprocessing, and evaluation notebooks are in the [AlgoShield repository](https://huggingface.co/odeliyach/AlgoShield-Algospeak-Detection).
96
+
97
+ | Metric | Baseline DistilBERT | AlgoShield (fine-tuned) |
98
+ |--------|---------------------|------------------------|
99
+ | Precision | 70.3% | 61.2% |
100
+ | Recall | 33.2% | **73.2% (+40 pts)** |
101
+ | F1 | 49.0% | **66.7% (+17.7 pts)** |
102
+
103
+ The +40-point recall improvement comes at the cost of ~9 points of precision — a deliberate tradeoff. In content moderation, a false negative (missing a toxic post) causes real harm; a false positive just means a human reviews something innocent. The threshold slider in AlgoScope lets operators tune this tradeoff at deployment time without retraining.
104
+
105
+ > Want to understand how AlgoShield was built? See the [model card and training details →](https://huggingface.co/odeliyach/AlgoShield-Algospeak-Detection)
106
+
107
+ ---
108
+
109
+ ## Key Engineering Decisions
110
+
111
+ **Train/serve parity** — The same `preprocess_text()` function used during AlgoShield's training is applied at inference time in AlgoScope. Without this, the model sees out-of-distribution input on every prediction — a production ML bug called train/serve skew.
112
+
113
+ **Threshold separation** — The model outputs a raw confidence score; a threshold slider converts it to a binary label. This separates the ML model from business policy — the same pattern used in Gmail spam and YouTube moderation. One model, multiple thresholds tuned per context.
114
+
115
+ **Graph construction order** — The co-occurrence graph filters to the 1-hop neighborhood of algospeak seed words *before* frequency ranking. The naive approach (top-30 globally, then filter) always returns generic English function words ("get", "like", "know") — useless for the project's purpose.
116
+
117
+ **Physics disabled** — Pyvis force-directed layout is O(n²) per animation frame. With 30+ nodes it froze the browser for 2+ minutes. A fixed `randomSeed` layout loads instantly with reproducible positions.
118
+
119
+ **SQLite with clean abstraction** — All persistence is isolated in `database.py`. No other file imports `sqlite3` directly. Replacing SQLite with PostgreSQL or Cassandra requires changing only that one file.
120
+
121
+ ---
122
+
123
+ ## Running Locally
124
+
125
+ **Requirements:** Python 3.12, a Bluesky account
126
+
127
+ ```bash
128
+ git clone https://github.com/odeliyach/Algoscope
129
+ cd Algoscope
130
+ python -m venv venv
131
+ venv\Scripts\activate # Windows
132
+ # source venv/bin/activate # Mac/Linux
133
+ pip install -r requirements.txt
134
+ ```
135
+
136
+ Or with Make:
137
+ ```bash
138
+ make install
139
+ make run-dashboard # in one terminal
140
+ make run-api # in another
141
+ ```
142
+
143
+ Create `.env` in the project root:
144
+ ```env
145
+ BLUESKY_HANDLE=yourhandle.bsky.social
146
+ BLUESKY_PASSWORD=yourpassword
147
+ ```
148
+
149
+ ---
150
+
151
+ ## Project Structure
152
+
153
+ ```
154
+ Algoscope/
155
+ ├── app/
156
+ │ ├── main.py # FastAPI endpoints (/health, /predict)
157
+ │ ├── model.py # ToxicityClassifier — singleton load, batch inference
158
+ │ ├── ingestion.py # Bluesky AT Protocol client + preprocessing
159
+ │ ├── database.py # SQLite persistence — isolated for easy swap
160
+ │ └── graph.py # NetworkX co-occurrence graph + Pyvis HTML export
161
+ ├── assets/
162
+ │ ├── overview.png # Dashboard overview screenshot
163
+ │ ├── graph.png # Co-occurrence graph screenshot
164
+ │ └── term_comparison.png # Term comparison screenshot
165
+ ├── tests/
166
+ │ └── test_core.py # Preprocessing parity, DB round-trip, stopwords
167
+ ├── dashboard.py # Streamlit dashboard — 4 tabs
168
+ ├── Makefile # install / run / test / lint shortcuts
169
+ ├── requirements.txt # Runtime dependencies
170
+ ├── pyproject.toml # Project metadata + tooling config
171
+ ├── Dockerfile # python:3.12-slim, non-root user
172
+ ├── .github/workflows/
173
+ │ └── ci.yml # Import checks + syntax + pytest on every push
174
+ └── .env # Credentials — not committed
175
+ ```
176
+
177
+ ---
178
+
179
+ ## Deployment (HuggingFace Spaces)
180
+
181
+ 1. Push this repo to GitHub (verify `.env` and `algoscope.db` are in `.gitignore`)
182
+ 2. Go to [huggingface.co](https://huggingface.co) → New Space → Streamlit → connect this GitHub repo
183
+ 3. In Space Settings → Secrets, add `BLUESKY_HANDLE` and `BLUESKY_PASSWORD`
184
+ 4. The Space auto-deploys on every push to `main`
185
+
186
+ ---
187
+
188
+ ## Limitations & Future Work
189
+
190
+ - **Bluesky-only** — the ingestion layer is modular; adding Reddit or Mastodon requires only a new adapter in `ingestion.py`
191
+ - **Fetch-on-click** — a background ingestion loop would keep data flowing continuously without user interaction
192
+ - **Static model** — algospeak evolves; periodic retraining or drift detection would maintain coverage over time
193
+ - **SQLite single-writer** — replacing with PostgreSQL or Cassandra enables concurrent multi-worker ingestion
194
+
195
+ ---
196
+
197
+ ## License
198
+
199
+ MIT — see [LICENSE](LICENSE)
200
+
201
+ ---
202
+
203
+ <div align="center">
204
+ <sub>AlgoScope · Tel Aviv University, School of CS & AI · Odeliya Charitonova · 2026</sub>
205
+ </div>
app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
app/database.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SQLite database for storing post classification results.
3
+
4
+ ARCHITECTURE NOTE (interview talking point):
5
+ All persistence is isolated in this file. No other module imports sqlite3
6
+ directly. This means swapping SQLite for PostgreSQL or any other store
7
+ requires changing only this one file — the rest of the codebase is
8
+ completely unaware of how data is stored.
9
+ """
10
+
11
+ import logging
12
+ import os
13
+ import sqlite3
14
+ from typing import Any
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ _temp_dir = os.environ.get("TMPDIR")
19
+ if not _temp_dir:
20
+ _temp_dir = os.environ.get("TEMP") or os.environ.get("TMP") or "/tmp"
21
+ DB_PATH = os.environ.get(
22
+ "ALGOSCOPE_DB_PATH",
23
+ os.path.join(_temp_dir, "algoscope.db"),
24
+ )
25
+
26
+ _db_initialized = False
27
+
28
+
29
+ def _get_connection() -> sqlite3.Connection:
30
+ conn = sqlite3.connect(DB_PATH)
31
+ conn.row_factory = sqlite3.Row
32
+ return conn
33
+
34
+
35
+ def init_db() -> None:
36
+ """Create tables if they don't exist. Safe to call multiple times."""
37
+ with _get_connection() as conn:
38
+ conn.execute(
39
+ """
40
+ CREATE TABLE IF NOT EXISTS posts (
41
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
42
+ text TEXT NOT NULL,
43
+ label TEXT NOT NULL,
44
+ score REAL NOT NULL,
45
+ platform TEXT NOT NULL,
46
+ query_term TEXT NOT NULL DEFAULT '',
47
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
48
+ )
49
+ """
50
+ )
51
+ try:
52
+ conn.execute("ALTER TABLE posts ADD COLUMN query_term TEXT NOT NULL DEFAULT ''")
53
+ except sqlite3.OperationalError:
54
+ pass
55
+ conn.commit()
56
+
57
+
58
+ def _ensure_init() -> None:
59
+ """Initialize DB once per process, not on every call."""
60
+ global _db_initialized
61
+ if not _db_initialized:
62
+ init_db()
63
+ _db_initialized = True
64
+
65
+
66
+ def save_post(
67
+ text: str,
68
+ label: str,
69
+ score: float,
70
+ platform: str,
71
+ query_term: str = "",
72
+ ) -> None:
73
+ """Insert a classified post into the posts table."""
74
+ _ensure_init()
75
+ with _get_connection() as conn:
76
+ conn.execute(
77
+ "INSERT INTO posts (text, label, score, platform, query_term) VALUES (?, ?, ?, ?, ?)",
78
+ (text, label, score, platform, query_term),
79
+ )
80
+ conn.commit()
81
+
82
+
83
+ def get_recent_posts(limit: int = 100) -> list[dict[str, Any]]:
84
+ """Return the most recent posts as a list of dicts, newest first."""
85
+ _ensure_init()
86
+ with _get_connection() as conn:
87
+ cursor = conn.execute(
88
+ """
89
+ SELECT id, text, label, score, platform, query_term, created_at
90
+ FROM posts
91
+ ORDER BY created_at DESC
92
+ LIMIT ?
93
+ """,
94
+ (limit,),
95
+ )
96
+ rows = cursor.fetchall()
97
+ return [dict(row) for row in rows]
98
+
99
+
100
+ def get_post_count() -> int:
101
+ """Return total number of posts in the DB."""
102
+ _ensure_init()
103
+ with _get_connection() as conn:
104
+ return conn.execute("SELECT COUNT(*) FROM posts").fetchone()[0]
105
+
106
+
107
+ def seed_if_empty() -> None:
108
+ """
109
+ If the DB is empty (cold start or HF ephemeral filesystem wipe), fetch
110
+ a small batch of real posts from Bluesky and classify them so the
111
+ dashboard has data immediately without requiring the user to click FETCH.
112
+
113
+ WHY this is safe now (it was disabled before):
114
+ Previously this ran at module import time, triggering a model download
115
+ before uvicorn bound to port 7860, killing the container with no logs.
116
+ Now it is called from lifespan() AFTER the server is up and AFTER the
117
+ classifier has loaded. A failure here is non-fatal.
118
+
119
+ WHY 4 queries at limit=32 (not all queries at full limit):
120
+ Seeding is best-effort background work. ~30 posts is enough to populate
121
+ all dashboard widgets. Seeding all queries would add 10-30s to cold
122
+ start time, unacceptable for a free-tier Space that restarts often.
123
+ """
124
+ _ensure_init()
125
+ count = get_post_count()
126
+ if count > 0:
127
+ logger.info("seed_if_empty: DB has %d posts, skipping seed", count)
128
+ return
129
+
130
+ logger.info("seed_if_empty: DB is empty, seeding from Bluesky...")
131
+ try:
132
+ from app.ingestion import ALGOSPEAK_QUERIES, fetch_posts
133
+ from app.model import ToxicityClassifier
134
+
135
+ classifier = ToxicityClassifier()
136
+ if classifier._pipeline is None:
137
+ logger.warning("seed_if_empty: classifier not ready, skipping seed")
138
+ return
139
+
140
+ seed_queries = ALGOSPEAK_QUERIES[:4]
141
+ posts = fetch_posts(query=seed_queries[0], limit=32, queries=seed_queries)
142
+ if not posts:
143
+ logger.warning("seed_if_empty: no posts returned from Bluesky")
144
+ return
145
+
146
+ texts = [t for t, _ in posts]
147
+ timestamps = [ts for _, ts in posts]
148
+ predictions = classifier.predict_batch(texts)
149
+
150
+ for text, ts, pred in zip(texts, timestamps, predictions):
151
+ score = float(pred.get("score", 0.0) or 0.0)
152
+ label = "toxic" if score >= 0.70 else "non-toxic"
153
+ matched = next(
154
+ (q for q in seed_queries if q and q.lower() in text.lower()),
155
+ seed_queries[0],
156
+ )
157
+ save_post(text=text, label=label, score=score, platform="bluesky", query_term=matched)
158
+
159
+ logger.info("seed_if_empty: seeded %d posts", len(texts))
160
+ except Exception as exc:
161
+ # WHY catch-all: Bluesky credentials may not be set, the network may
162
+ # be unavailable, or the model may not have loaded. The app must start
163
+ # regardless - the user can always click FETCH manually.
164
+ logger.warning("seed_if_empty: failed (non-fatal): %s", exc)
app/graph.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Graph utilities for exploring algospeak co-occurrence patterns.
3
+
4
+ Co-occurrence graphs are a classic NLP exploratory tool: by connecting words
5
+ that frequently appear together, we can surface clusters of related slang
6
+ or emergent euphemisms that would be hard to spot from raw text alone.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from itertools import combinations
13
+ from typing import Dict, List
14
+
15
+ import networkx as nx
16
+ from pyvis.network import Network
17
+
18
+ from app.database import get_recent_posts
19
+
20
+ # All words to exclude from the graph. Centralizing here makes it easy to tune.
21
+ STOPWORDS = {
22
+ # English function words
23
+ "the", "and", "or", "but", "if", "then", "else", "when", "where",
24
+ "what", "which", "who", "whom", "this", "that", "these", "those",
25
+ "a", "an", "of", "in", "on", "for", "to", "from", "by", "with",
26
+ "at", "as", "is", "am", "are", "was", "were", "be", "been", "being",
27
+ "it", "its", "he", "she", "they", "them", "we", "us", "you", "your",
28
+ "yours", "i", "me", "my", "mine", "our", "ours", "their", "theirs",
29
+ "do", "does", "did", "doing", "done", "have", "has", "had",
30
+ "will", "would", "can", "could", "should", "must", "may", "might",
31
+ "just", "like", "so", "very", "too", "not", "no", "yes",
32
+ "there", "here", "than", "then", "also", "even", "more", "most",
33
+ "get", "got", "go", "going", "say", "said", "out", "now", "day",
34
+ "because", "some", "people", "love", "social", "really", "while",
35
+ "think", "know", "want", "see", "make", "take", "come", "look",
36
+ "good", "new", "first", "last", "long", "great", "little", "own",
37
+ "right", "big", "high", "small", "large", "next", "early", "old",
38
+ "well", "still", "way", "every", "never", "always", "much", "need",
39
+ "feel", "put", "keep", "let", "ask", "seem", "show", "try", "call",
40
+ "back", "other", "free", "real", "best", "true", "about", "after",
41
+ "again", "dont", "isnt", "cant", "wont", "didnt", "doesnt", "youre",
42
+ "theyre", "whats", "thats", "dont", "thing", "things", "time",
43
+ # Spanish function words (common on Bluesky)
44
+ "de", "que", "con", "como", "para", "una", "uno", "los", "las",
45
+ "por", "del", "sus", "pero", "todo", "esta", "este", "son", "hay",
46
+ "nos", "han", "fue", "ser", "ver", "vez", "sin", "sobre", "entre",
47
+ "cuando", "bien", "solo", "puede", "tiene", "desde", "hasta",
48
+ # Web / file tokens
49
+ "jpg", "jpeg", "png", "gif", "webp", "www", "http", "https",
50
+ "com", "org", "net", "html", "php", "amp", "via", "bit",
51
+ }
52
+
53
+
54
+ def _tokenize(text: str) -> List[str]:
55
+ """
56
+ Lightweight tokenizer for short social posts.
57
+ Drops stopwords, short tokens, and tokens with digits.
58
+ """
59
+ text = text.lower()
60
+ raw_tokens = re.split(r"\W+", text)
61
+ tokens = []
62
+ for tok in raw_tokens:
63
+ if not tok or len(tok) <= 2:
64
+ continue
65
+ if any(ch.isdigit() for ch in tok):
66
+ continue
67
+ if tok in STOPWORDS:
68
+ continue
69
+ tokens.append(tok)
70
+ return tokens
71
+
72
+
73
+ def build_cooccurrence_graph(min_cooccurrence: int = 2) -> nx.Graph:
74
+ """
75
+ Build a word co-occurrence graph from all posts in the database.
76
+
77
+ WHY co-occurrence graphs for algospeak: slang evolves in clusters.
78
+ 'unalive' tends to appear with 'suicide', 'depression', 'mental'.
79
+ Mapping these clusters reveals semantic neighborhoods of evasive language
80
+ that a simple keyword list would miss.
81
+ """
82
+ posts = get_recent_posts(limit=10_000_000)
83
+
84
+ G = nx.Graph()
85
+ word_counts: Dict[str, int] = {}
86
+ toxic_word_counts: Dict[str, int] = {}
87
+
88
+ for row in posts:
89
+ text = (row.get("text") or "").strip()
90
+ label = (row.get("label") or "non-toxic").lower()
91
+ if not text:
92
+ continue
93
+ tokens = _tokenize(text)
94
+ if not tokens:
95
+ continue
96
+
97
+ # Use a set so repeated words in one post don't inflate edge weights
98
+ unique_words = set(tokens)
99
+
100
+ for w in unique_words:
101
+ word_counts[w] = word_counts.get(w, 0) + 1
102
+ if label == "toxic":
103
+ toxic_word_counts[w] = toxic_word_counts.get(w, 0) + 1
104
+
105
+ for w1, w2 in combinations(sorted(unique_words), 2):
106
+ if G.has_edge(w1, w2):
107
+ G[w1][w2]["weight"] += 1
108
+ else:
109
+ G.add_edge(w1, w2, weight=1)
110
+
111
+ # Remove weak edges and isolated nodes
112
+ G.remove_edges_from([
113
+ (u, v) for u, v, d in G.edges(data=True)
114
+ if d.get("weight", 0) < min_cooccurrence
115
+ ])
116
+ G.remove_nodes_from(list(nx.isolates(G)))
117
+
118
+ # Attach node metadata for visualization
119
+ for word, count in word_counts.items():
120
+ if word not in G:
121
+ continue
122
+ G.nodes[word]["count"] = count
123
+ G.nodes[word]["toxic_count"] = toxic_word_counts.get(word, 0)
124
+
125
+ # STEP 1: Filter to algospeak neighborhood FIRST.
126
+ # WHY order matters: filtering before top-30 ensures we get the most
127
+ # frequent *algospeak-related* words, not the most frequent generic words.
128
+ from app.ingestion import ALGOSPEAK_QUERIES
129
+ seed_words = {w for q in ALGOSPEAK_QUERIES for w in q.lower().split()}
130
+
131
+ relevant = set()
132
+ for seed in seed_words:
133
+ if seed in G:
134
+ relevant.add(seed)
135
+ relevant.update(G.neighbors(seed))
136
+
137
+ if relevant:
138
+ G = G.subgraph(relevant).copy()
139
+
140
+ # STEP 2: Take top 30 by frequency from the algospeak neighborhood
141
+ if G.number_of_nodes() > 30:
142
+ top_nodes = sorted(
143
+ G.nodes(data=True),
144
+ key=lambda x: x[1].get("count", 0),
145
+ reverse=True,
146
+ )[:30]
147
+ G = G.subgraph({n[0] for n in top_nodes}).copy()
148
+
149
+ return G
150
+
151
+
152
+ def graph_to_pyvis(graph: nx.Graph, toxic_only: bool = False) -> str:
153
+ """
154
+ Convert a NetworkX graph into interactive Pyvis HTML.
155
+ Physics disabled for instant rendering (animation caused 2min load times).
156
+ """
157
+ net = Network(height="600px", width="100%", directed=False, notebook=False)
158
+ net.set_options('''{
159
+ "physics": {"enabled": false},
160
+ "configure": {"enabled": false},
161
+ "layout": {"randomSeed": 42}
162
+ }''')
163
+
164
+ included_nodes = set()
165
+ for node, data in graph.nodes(data=True):
166
+ count = int(data.get("count", 1) or 1)
167
+ toxic_count = int(data.get("toxic_count", 0) or 0)
168
+ toxic_ratio = toxic_count / count if count else 0.0
169
+
170
+ if toxic_only and toxic_count == 0:
171
+ continue
172
+
173
+ # 3-color system: red=mostly toxic, orange=mixed, green=mostly benign
174
+ # More informative than binary because it shows usage context gradient
175
+ if toxic_ratio > 0.7:
176
+ color = "#ff4b4b"
177
+ elif toxic_ratio >= 0.4:
178
+ color = "#ff9f43"
179
+ else:
180
+ color = "#2ecc71"
181
+
182
+ net.add_node(node, label=node, color=color, value=count)
183
+ included_nodes.add(node)
184
+
185
+ for u, v, data in graph.edges(data=True):
186
+ if u not in included_nodes or v not in included_nodes:
187
+ continue
188
+ net.add_edge(u, v, value=int(data.get("weight", 1) or 1))
189
+
190
+ # Replace local file reference that breaks in Streamlit's sandboxed iframe
191
+ html = net.generate_html()
192
+
193
+ import json # noqa: F401
194
+ center_script = """
195
+ <script>
196
+ window.addEventListener('load', function() {
197
+ setTimeout(function() {
198
+ if (typeof network !== 'undefined') {
199
+ network.fit({animation: false});
200
+ }
201
+ }, 300);
202
+ });
203
+ </script>
204
+ """
205
+ html = html.replace("</body>", center_script + "</body>")
206
+
207
+ html = html.replace(
208
+ '<script src="lib/bindings/utils.js"></script>',
209
+ '<script>function neighbourhoodHighlight(){} function filterHighlight(){} function resetFilter(){}</script>'
210
+ )
211
+ return html
app/ingestion.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Bluesky post ingestion via the atproto library.
3
+ """
4
+
5
+ import os
6
+ import re
7
+ from concurrent.futures import ThreadPoolExecutor, TimeoutError
8
+ from typing import Any, Iterable, Optional, Set
9
+ import logging
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # Using multiple queries lets us cover different algospeak variants that
14
+ # users employ to evade simple keyword filters. Centralizing them here makes
15
+ # it easy to tune the "vocabulary" without changing the rest of the pipeline.
16
+ ALGOSPEAK_QUERIES: list[str] = [
17
+ "unalive",
18
+ "le dollar bean",
19
+ "seggs",
20
+ "cornhole",
21
+ "spicy eggplant",
22
+ "cope harder",
23
+ "ratio",
24
+ "touch grass",
25
+ "based",
26
+ "sus",
27
+ ]
28
+
29
+ # Cache the atproto client so we only pay the authentication cost once.
30
+ # In practice this can save ~10 seconds per fetch because login requires
31
+ # multiple network round trips, while the resulting session stays valid
32
+ # for many minutes to hours.
33
+ _client = None
34
+
35
+
36
+ def get_client():
37
+ """Return a logged-in atproto Client, cached at module level."""
38
+ global _client
39
+ if _client is not None:
40
+ return _client
41
+
42
+ from atproto import Client
43
+
44
+ handle = os.environ.get("BLUESKY_HANDLE")
45
+ password = os.environ.get("BLUESKY_PASSWORD")
46
+ if not handle or not password:
47
+ raise RuntimeError("BLUESKY_HANDLE or BLUESKY_PASSWORD not set")
48
+
49
+ logger.info("Authenticating with Bluesky as %s", handle)
50
+ client = Client()
51
+ client.login(handle, password)
52
+ _client = client
53
+ logger.info("Bluesky authentication successful")
54
+ return client
55
+
56
+
57
+ def preprocess_text(text: str) -> str:
58
+ """
59
+ Normalize text to match the training-time preprocessing.
60
+
61
+ A very common ML bug is to train with one preprocessing pipeline and
62
+ serve with another. Keeping train and inference transforms aligned is
63
+ critical; otherwise, the model is effectively seeing out-of-distribution
64
+ data at serving time even if it "looks" similar to humans.
65
+
66
+ Args:
67
+ text: Raw post text from Bluesky.
68
+
69
+ Returns:
70
+ Cleaned text string, or empty string if the post should be discarded.
71
+ """
72
+ if not text:
73
+ return ""
74
+
75
+ # Remove URLs so we don't overfit to specific domains not in training data.
76
+ text = re.sub(r"https?://\S+", " ", text)
77
+
78
+ # Drop non-ASCII characters (including most emojis) to mimic training preprocessing.
79
+ text = text.encode("ascii", errors="ignore").decode("ascii")
80
+
81
+ # Collapse repeated whitespace and trim.
82
+ text = re.sub(r"\s+", " ", text).strip()
83
+
84
+ # Strip hashtags before word analysis — hashtags are metadata, not content.
85
+ text_no_hashtags = re.sub(r"#\S+", "", text).strip()
86
+
87
+ # Filter posts with no real linguistic content (filenames, hashtag spam).
88
+ # WHY: These posts add noise to the model and co-occurrence graph
89
+ # without contributing meaningful signal about algospeak patterns.
90
+ NON_WORDS = {"jpg", "png", "gif", "com", "www", "http", "https", "the", "and"}
91
+ real_words = [
92
+ w for w in re.findall(r"[a-zA-Z]{3,}", text_no_hashtags)
93
+ if w.lower() not in NON_WORDS
94
+ ]
95
+ if len(real_words) < 3:
96
+ return ""
97
+
98
+ # Enforce minimum length consistent with the training filter from the paper.
99
+ if len(text) < 10:
100
+ return ""
101
+
102
+ return text
103
+
104
+
105
+ def _dedupe_texts(texts: Iterable[tuple[str, str | None]]) -> list[tuple[str, str | None]]:
106
+ """
107
+ Deduplicate posts while preserving order.
108
+
109
+ In real moderation systems, the same content can appear multiple times
110
+ (reposts, quote-posts, different queries). Deduplication avoids wasting
111
+ model budget on identical texts and keeps metrics from being biased by
112
+ repeated copies of the same post.
113
+
114
+ Args:
115
+ texts: Iterable of text strings (may contain duplicates).
116
+
117
+ Returns:
118
+ List of unique strings in original order.
119
+ """
120
+ seen: Set[str] = set()
121
+ result: list[tuple[str, str | None]] = []
122
+ for text, ts in texts:
123
+ if text in seen:
124
+ continue
125
+ seen.add(text)
126
+ result.append((text, ts))
127
+ return result
128
+
129
+
130
+ def fetch_posts(
131
+ query: str,
132
+ limit: int = 50,
133
+ queries: Optional[list[str]] = None,
134
+ ) -> list[tuple[str, str | None]]:
135
+ """
136
+ Search Bluesky for posts and return their text content.
137
+
138
+ If `queries` is provided, we search for each query term independently
139
+ and merge the results. This fan-out pattern covers different algospeak
140
+ variants without relying on a single brittle keyword.
141
+
142
+ Args:
143
+ query: Primary search term (used if queries is None).
144
+ limit: Maximum number of posts to return across all queries.
145
+ queries: Optional list of terms to fan out across.
146
+
147
+ Returns:
148
+ Deduplicated list of preprocessed post texts.
149
+ Returns empty list on any error (credentials, network, API).
150
+ """
151
+ all_texts: list[str] = []
152
+
153
+ def _worker() -> None:
154
+ """
155
+ Perform the actual API calls in a worker thread.
156
+
157
+ WHY a thread + timeout: if Bluesky's API hangs, the request thread
158
+ would block indefinitely without a timeout, degrading UX and
159
+ potentially exhausting the worker pool in a multi-user deployment.
160
+ """
161
+ try:
162
+ client = get_client()
163
+ query_list = queries if queries is not None else [query]
164
+ logger.info("Fetching posts for %d queries (limit=%d)", len(query_list), limit)
165
+
166
+ for q in query_list:
167
+ # Divide the total limit evenly across all queries so that the
168
+ # sum of per-query fetches equals the requested limit.
169
+ # WHY: previously every query fetched min(limit, 10) posts,
170
+ # so 10 queries × 10 posts = up to 100 posts regardless of
171
+ # what the user requested. Now 25 limit ÷ 10 queries = 2-3
172
+ # posts each, giving ~25 total before dedup — matching the UI.
173
+ per_query_limit = max(1, limit // len(query_list)) if queries is not None else limit
174
+ params: dict[str, Any] = {
175
+ "q": q,
176
+ "limit": min(max(1, per_query_limit), 100),
177
+ }
178
+ response = client.app.bsky.feed.search_posts(params=params)
179
+
180
+ if not response or not getattr(response, "posts", None):
181
+ logger.warning("No results for query: %r", q)
182
+ continue
183
+
184
+ for post in response.posts:
185
+ record = getattr(post, "record", None)
186
+ if record is None:
187
+ continue
188
+ raw_text = getattr(post.record, "text", None)
189
+ if raw_text is None or not isinstance(raw_text, str):
190
+ continue
191
+ cleaned = preprocess_text(raw_text)
192
+ if not cleaned:
193
+ continue
194
+ post_ts = getattr(post.record, "created_at", None)
195
+ all_texts.append((cleaned, post_ts))
196
+
197
+ logger.info("Fetched %d posts before deduplication", len(all_texts))
198
+
199
+ except Exception as exc:
200
+ # WHY reset client: if auth expired the next fetch should
201
+ # re-authenticate from scratch, not retry with a stale session.
202
+ logger.error("Fetch failed: %s — resetting client", exc, exc_info=True)
203
+ global _client
204
+ _client = None
205
+
206
+ try:
207
+ with ThreadPoolExecutor(max_workers=1) as executor:
208
+ future = executor.submit(_worker)
209
+ try:
210
+ future.result(timeout=30)
211
+ except TimeoutError:
212
+ logger.warning("Bluesky fetch timed out after 30s")
213
+ future.cancel()
214
+
215
+ deduped = _dedupe_texts(all_texts)
216
+ logger.info("Returning %d posts after deduplication", len(deduped))
217
+ return deduped
218
+
219
+ except Exception as exc:
220
+ logger.error("Unexpected error in fetch_posts: %s", exc, exc_info=True)
221
+ return []
app/main.py ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI backend for AlgoScope toxicity detection.
3
+
4
+ ARCHITECTURE NOTE (interview talking point):
5
+ This file is the boundary between the ML layer and the outside world.
6
+ The React frontend talks exclusively to these endpoints — it has no direct
7
+ access to the database, the model, or the Bluesky client. That separation
8
+ means you can swap any layer (swap SQLite for Postgres, swap the model,
9
+ swap the frontend framework) without touching the others.
10
+ """
11
+
12
+ import logging
13
+ import os
14
+ import threading
15
+ import time
16
+ from contextlib import asynccontextmanager
17
+ from typing import Any
18
+
19
+ from fastapi import FastAPI, Query
20
+ from fastapi.middleware.cors import CORSMiddleware
21
+ from fastapi.responses import FileResponse
22
+ from fastapi.staticfiles import StaticFiles
23
+ from pydantic import BaseModel
24
+ from dotenv import load_dotenv
25
+
26
+ from app.database import get_post_count, get_recent_posts, save_post, seed_if_empty
27
+ from app.graph import build_cooccurrence_graph
28
+ from app.ingestion import ALGOSPEAK_QUERIES, fetch_posts
29
+ from app.model import ToxicityClassifier
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ # Load local environment variables for Bluesky credentials in development.
34
+ load_dotenv()
35
+
36
+ # WHY None here: initializing ToxicityClassifier() at module scope triggers
37
+ # a 250MB model download before uvicorn binds to port 7860. HuggingFace Spaces
38
+ # sees no response on the port and kills the container with no logs.
39
+ # We initialize inside lifespan() instead, after the server is already up.
40
+ classifier: ToxicityClassifier | None = None
41
+
42
+
43
+ def _background_init() -> None:
44
+ """
45
+ Load the model and seed the DB in a background thread.
46
+
47
+ WHY background thread and not lifespan:
48
+ HuggingFace Spaces kills containers that don't respond on port 7860
49
+ within ~30 seconds. Loading a 250MB model + fetching Bluesky posts
50
+ easily exceeds that. Running both in a daemon thread lets uvicorn bind
51
+ immediately; the model and seed data become available a few seconds
52
+ later. Requests that arrive before the model is ready return default
53
+ predictions (non-toxic / 0.0) which is acceptable for a brief window.
54
+ """
55
+ global classifier
56
+ try:
57
+ classifier = ToxicityClassifier()
58
+ classifier._load_model()
59
+ logger.info("ToxicityClassifier ready")
60
+ except Exception as exc:
61
+ logger.warning("Model load failed — predictions will return defaults: %s", exc)
62
+ classifier = None
63
+ try:
64
+ seed_if_empty()
65
+ logger.info("DB seed check complete")
66
+ except Exception as exc:
67
+ logger.warning("Seed skipped (likely missing credentials): %s", exc)
68
+
69
+
70
+ @asynccontextmanager
71
+ async def lifespan(app: FastAPI):
72
+ # WHY daemon=True: daemon threads are killed automatically when the main
73
+ # process exits, so we don't block graceful shutdown waiting for a slow
74
+ # model download or Bluesky fetch.
75
+ t = threading.Thread(target=_background_init, daemon=True)
76
+ t.start()
77
+ logger.info("AlgoScope API starting up — model loading in background")
78
+ yield
79
+ logger.info("AlgoScope API shutting down")
80
+
81
+
82
+ app = FastAPI(
83
+ title="AlgoScope API",
84
+ description="Real-time algospeak and toxicity detection for Bluesky posts.",
85
+ version="0.2.0",
86
+ lifespan=lifespan,
87
+ )
88
+
89
+ app.add_middleware(
90
+ CORSMiddleware,
91
+ allow_origins=["*"],
92
+ allow_credentials=True,
93
+ allow_methods=["*"],
94
+ allow_headers=["*"],
95
+ )
96
+
97
+
98
+ class PredictRequest(BaseModel):
99
+ text: str
100
+
101
+
102
+ class PredictResponse(BaseModel):
103
+ label: str
104
+ score: float
105
+
106
+
107
+ class FetchRequest(BaseModel):
108
+ queries: list[str] = ALGOSPEAK_QUERIES
109
+ limit: int = 25
110
+ threshold: float = 0.70
111
+
112
+
113
+ class PostOut(BaseModel):
114
+ id: int
115
+ text: str
116
+ label: str
117
+ score: float
118
+ platform: str
119
+ created_at: str
120
+ query_term: str = ""
121
+
122
+
123
+ @app.get("/health")
124
+ def health() -> dict[str, str]:
125
+ """Liveness check — used by HuggingFace Spaces and load balancers."""
126
+ return {"status": "ok"}
127
+
128
+
129
+ @app.post("/predict", response_model=PredictResponse)
130
+ def predict(request: PredictRequest) -> dict[str, str | float]:
131
+ """Classify a single text as toxic or non-toxic."""
132
+ if classifier is None:
133
+ return {"label": "non-toxic", "score": 0.0}
134
+ logger.info("Predicting for text (len=%d)", len(request.text))
135
+ result = classifier.predict(request.text)
136
+ logger.info("Result: label=%s score=%.3f", result["label"], result["score"])
137
+ return {"label": result["label"], "score": result["score"]}
138
+
139
+
140
+ @app.get("/posts")
141
+ def get_posts(
142
+ limit: int = Query(default=100, ge=1, le=10000),
143
+ ) -> dict[str, Any]:
144
+ """Return recent posts from the database for the React frontend."""
145
+ rows = get_recent_posts(limit=limit)
146
+ for row in rows:
147
+ if "query_term" not in row:
148
+ row["query_term"] = ""
149
+ if not row.get("created_at"):
150
+ row["created_at"] = ""
151
+ # WHY get_post_count() instead of len(rows):
152
+ # rows is capped at `limit`, so len(rows) always equals min(limit, n_posts).
153
+ # The frontend calls GET /posts?limit=1 to get the true total for the
154
+ # "Posts Analyzed" counter — returning len(rows)=1 there was causing
155
+ # the counter to reset to 1 after every fetch.
156
+ return {"posts": rows, "total": get_post_count()}
157
+
158
+
159
+ @app.post("/fetch-and-analyze")
160
+ def fetch_and_analyze(request: FetchRequest) -> dict[str, Any]:
161
+ """
162
+ Fetch posts from Bluesky, run batch inference, save to DB, return results.
163
+ """
164
+ if classifier is None:
165
+ return {"posts": [], "fetch_time": 0.0, "infer_time": 0.0, "count": 0,
166
+ "message": "Model not loaded yet, please try again in a moment."}
167
+
168
+ logger.info(
169
+ "fetch-and-analyze: queries=%s limit=%d threshold=%.2f",
170
+ request.queries, request.limit, request.threshold,
171
+ )
172
+
173
+ t0 = time.time()
174
+ posts_text = fetch_posts(
175
+ query=request.queries[0] if request.queries else "unalive",
176
+ limit=request.limit,
177
+ queries=request.queries or None,
178
+ )
179
+ fetch_time = time.time() - t0
180
+
181
+ if not posts_text:
182
+ return {
183
+ "posts": [],
184
+ "fetch_time": fetch_time,
185
+ "infer_time": 0.0,
186
+ "count": 0,
187
+ "message": "No posts fetched. Check Bluesky credentials or try again.",
188
+ }
189
+
190
+ texts_only = [text for text, _ts in posts_text]
191
+ timestamps = [ts for _text, ts in posts_text]
192
+
193
+ t1 = time.time()
194
+ predictions = classifier.predict_batch(texts_only)
195
+ infer_time = time.time() - t1
196
+
197
+ batch_ts_iso = time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime())
198
+ result_posts: list[dict[str, Any]] = []
199
+
200
+ for i, (text, post_ts, pred) in enumerate(zip(texts_only, timestamps, predictions)):
201
+ score = float(pred.get("score", 0.0) or 0.0)
202
+ label = "toxic" if score >= request.threshold else "non-toxic"
203
+ matched_term = next(
204
+ (t for t in request.queries if t and t.lower() in text.lower()),
205
+ request.queries[0] if request.queries else "",
206
+ )
207
+ save_post(text=text, label=label, score=score, platform="bluesky", query_term=matched_term)
208
+ result_posts.append({
209
+ "id": int(time.time() * 1000) + i,
210
+ "text": text,
211
+ "label": label,
212
+ "score": score,
213
+ "platform": "bluesky",
214
+ "created_at": post_ts or batch_ts_iso,
215
+ "query_term": matched_term,
216
+ })
217
+
218
+ logger.info(
219
+ "fetch-and-analyze: %d posts, fetch=%.2fs infer=%.2fs",
220
+ len(result_posts), fetch_time, infer_time,
221
+ )
222
+
223
+ return {
224
+ "posts": result_posts,
225
+ "fetch_time": fetch_time,
226
+ "infer_time": infer_time,
227
+ "count": len(result_posts),
228
+ }
229
+
230
+
231
+ @app.get("/graph-data")
232
+ def graph_data(
233
+ min_cooccurrence: int = Query(default=2, ge=1, le=20),
234
+ toxic_only: bool = Query(default=False),
235
+ ) -> dict[str, Any]:
236
+ """Return co-occurrence graph as JSON nodes + edges."""
237
+ graph = build_cooccurrence_graph(min_cooccurrence=min_cooccurrence)
238
+
239
+ nodes = []
240
+ for node, data in graph.nodes(data=True):
241
+ count = int(data.get("count", 1) or 1)
242
+ toxic_count = int(data.get("toxic_count", 0) or 0)
243
+ toxic_ratio = toxic_count / count if count else 0.0
244
+ if toxic_only and toxic_count == 0:
245
+ continue
246
+ nodes.append({
247
+ "id": node,
248
+ "count": count,
249
+ "toxic_count": toxic_count,
250
+ "toxic_ratio": round(toxic_ratio, 3),
251
+ })
252
+
253
+ included = {n["id"] for n in nodes}
254
+ edges = [
255
+ {"source": u, "target": v, "weight": int(data.get("weight", 1) or 1)}
256
+ for u, v, data in graph.edges(data=True)
257
+ if u in included and v in included
258
+ ]
259
+
260
+ return {"nodes": nodes, "edges": edges, "node_count": len(nodes), "edge_count": len(edges)}
261
+
262
+
263
+ @app.get("/stats")
264
+ def stats() -> dict[str, Any]:
265
+ """Aggregate statistics for the Overview tab metric cards."""
266
+ rows = get_recent_posts(limit=100_000)
267
+ total = len(rows)
268
+ toxic = sum(1 for r in rows if (r.get("label") or "").lower() == "toxic")
269
+ term_counts: dict[str, int] = {}
270
+ for row in rows:
271
+ term = row.get("query_term") or ""
272
+ if term:
273
+ term_counts[term] = term_counts.get(term, 0) + 1
274
+ return {
275
+ "total_posts": total,
276
+ "toxic_posts": toxic,
277
+ "toxic_rate": round(toxic / total * 100, 2) if total else 0.0,
278
+ "term_counts": term_counts,
279
+ }
280
+
281
+
282
+ # ── Static file serving (React build) ─────────────────────────────────────────
283
+ _FRONTEND_DIST = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist")
284
+
285
+ if os.path.exists(_FRONTEND_DIST):
286
+ _assets_dir = os.path.join(_FRONTEND_DIST, "assets")
287
+ if os.path.exists(_assets_dir):
288
+ app.mount("/assets", StaticFiles(directory=_assets_dir), name="assets")
289
+
290
+ @app.get("/", response_class=FileResponse, include_in_schema=False)
291
+ def serve_frontend_root():
292
+ return FileResponse(os.path.join(_FRONTEND_DIST, "index.html"))
293
+
294
+ @app.get("/{full_path:path}", response_class=FileResponse, include_in_schema=False)
295
+ def serve_frontend_spa(full_path: str):
296
+ """Catch-all for React Router — prevents 404 on page refresh."""
297
+ return FileResponse(os.path.join(_FRONTEND_DIST, "index.html"))
app/model.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Toxicity classifier using the AlgoShield-Algospeak-Detection HuggingFace model.
3
+ """
4
+
5
+ from typing import Any
6
+ import logging
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class ToxicityClassifier:
12
+ """
13
+ Wrapper around the fine-tuned DistilBERT toxicity model.
14
+
15
+ WHY lazy loading: calling _load_model() inside __init__ triggers a 250MB
16
+ model download at import time, before uvicorn binds to port 7860.
17
+ HuggingFace Spaces sees no response and kills the container with no logs.
18
+ Instead, main.py calls _load_model() explicitly inside lifespan(), after
19
+ the server is already up and logging.
20
+ """
21
+
22
+ def __init__(self) -> None:
23
+ self._pipeline = None
24
+
25
+ def _load_model(self) -> None:
26
+ """Load the HuggingFace pipeline. Called once by lifespan() in main.py."""
27
+ if self._pipeline is not None:
28
+ return
29
+
30
+ try:
31
+ import torch # noqa: F401
32
+ from transformers import pipeline
33
+
34
+ logger.info("Loading AlgoShield-Algospeak-Detection model...")
35
+ self._pipeline = pipeline(
36
+ "text-classification",
37
+ model="odeliyach/AlgoShield-Algospeak-Detection",
38
+ )
39
+ logger.info("Model loaded successfully")
40
+ except Exception as exc:
41
+ logger.error("Failed to load model: %s", exc, exc_info=True)
42
+ self._pipeline = None
43
+
44
+ def predict(self, text: str) -> dict[str, Any]:
45
+ """Classify a single text as toxic or non-toxic."""
46
+ default: dict[str, Any] = {"label": "non-toxic", "score": 0.0}
47
+
48
+ if not text or not text.strip():
49
+ return default
50
+
51
+ try:
52
+ if self._pipeline is None:
53
+ logger.error("Pipeline is None — model was not loaded correctly")
54
+ return default
55
+
56
+ results = self._pipeline(text, truncation=True, max_length=512)
57
+ if not results:
58
+ return default
59
+
60
+ raw = results[0]
61
+ raw_label = str(raw.get("label", "")).lower()
62
+ raw_score = float(raw.get("score", 0.0))
63
+
64
+ if "toxic" in raw_label or raw_label in ("1", "label_1", "positive"):
65
+ label = "toxic"
66
+ else:
67
+ label = "non-toxic"
68
+
69
+ return {"label": label, "score": raw_score}
70
+
71
+ except Exception as exc:
72
+ logger.error("predict() failed: %s", exc, exc_info=True)
73
+ return default
74
+
75
+ def predict_batch(self, texts: list[str]) -> list[dict[str, Any]]:
76
+ """Classify a list of texts in a single forward pass."""
77
+ if not texts:
78
+ return []
79
+
80
+ cleaned = [t for t in texts if isinstance(t, str) and t.strip()]
81
+ if not cleaned or self._pipeline is None:
82
+ return []
83
+
84
+ try:
85
+ outputs = self._pipeline(cleaned, truncation=True, max_length=512)
86
+ except Exception as exc:
87
+ logger.error("predict_batch() failed: %s", exc, exc_info=True)
88
+ return []
89
+
90
+ results: list[dict[str, Any]] = []
91
+ for raw in outputs:
92
+ raw_label = str(raw.get("label", "")).lower()
93
+ raw_score = float(raw.get("score", 0.0))
94
+
95
+ if "toxic" in raw_label or raw_label in ("1", "label_1", "positive"):
96
+ label = "toxic"
97
+ else:
98
+ label = "non-toxic"
99
+
100
+ results.append({"label": label, "score": raw_score})
101
+
102
+ logger.info("predict_batch: classified %d texts", len(results))
103
+ return results
dashboard.py ADDED
@@ -0,0 +1,953 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AlgoScope Streamlit dashboard for live algospeak/toxicity detection.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import time
8
+ from collections import defaultdict
9
+ from datetime import datetime, timedelta
10
+ from html import escape
11
+
12
+ from dotenv import load_dotenv
13
+ import plotly.graph_objects as go
14
+ import streamlit as st
15
+
16
+ from app.database import get_recent_posts, save_post
17
+ from app.graph import build_cooccurrence_graph, graph_to_pyvis
18
+ from app.ingestion import ALGOSPEAK_QUERIES, fetch_posts
19
+ from app.model import ToxicityClassifier
20
+
21
+ load_dotenv()
22
+
23
+ # Singleton classifier — loads once, survives all reruns.
24
+ # DistilBERT takes ~2s to load; without this every button click reloads it.
25
+ classifier = ToxicityClassifier()
26
+
27
+
28
+ def _parse_dt(ts_str) -> datetime | None:
29
+ """
30
+ Safely parse a timestamp string to datetime.
31
+ WHY: SQLite stores timestamps as plain text with no timezone info.
32
+ Without explicit normalization, timezone-naive comparisons silently
33
+ give wrong results — a common subtle data bug in production.
34
+ """
35
+ try:
36
+ s = str(ts_str).replace("Z", "").replace(" ", "T")
37
+ return datetime.fromisoformat(s)
38
+ except Exception:
39
+ return None
40
+
41
+
42
+ def main() -> None:
43
+ st.set_page_config(
44
+ page_title="AlgoScope — Live Algospeak Detection",
45
+ layout="wide",
46
+ initial_sidebar_state="expanded", # WHY: prevents sidebar from starting closed
47
+ )
48
+
49
+ st.markdown(
50
+ """
51
+ <style>
52
+ [data-testid="stAppViewContainer"] { background: #0a0d14; color: #e8eaf0; }
53
+ [data-testid="stSidebar"] { background: #0d1120; }
54
+ /* Hide toolbar/menu but keep header for sidebar toggle */
55
+ [data-testid="stToolbar"] { display: none !important; }
56
+ [data-testid="stDecoration"] { display: none !important; }
57
+ #MainMenu { display: none !important; }
58
+ footer { display: none !important; }
59
+ /* Hide Streamlit header entirely — we use our own JS toggle button */
60
+ [data-testid="stHeader"] { display: none !important; }
61
+ [data-testid="stMain"] { overflow-y: auto !important; }
62
+ /* Kill every source of top-gap Streamlit adds */
63
+ [data-testid="block-container"],
64
+ [data-testid="stMainBlockContainer"],
65
+ .main .block-container,
66
+ .block-container { padding-top: 0.3rem !important; max-width: 100% !important; }
67
+ [data-testid="stSidebarContent"] { padding-top: 0 !important; }
68
+ body { color: #e8eaf0; }
69
+ .stButton>button {
70
+ background: linear-gradient(135deg, #ff4b4b, #ff8c42);
71
+ color: white; border: none; border-radius: 8px; font-weight: 600;
72
+ }
73
+ .stTabs [data-baseweb="tab-list"] { background: transparent; border-bottom: 1px solid #1e2540; }
74
+ .stTabs [data-baseweb="tab"] { color: #5a6080; background: transparent; }
75
+ .stTabs [aria-selected="true"] { color: #ff6b3d !important; border-bottom: 2px solid #ff6b3d !important; }
76
+ .algoscope-header {
77
+ display: flex; justify-content: space-between; align-items: center;
78
+ padding: 0.5rem 0 0.75rem 0;
79
+ border-bottom: 1px solid #1e2540; margin-bottom: 1rem;
80
+ }
81
+ .algoscope-header-left { display: flex; align-items: center; gap: 0.8rem; }
82
+ .algoscope-logo {
83
+ width: 32px; height: 32px; border-radius: 8px;
84
+ background: linear-gradient(135deg, #ff4b4b, #ff8c42);
85
+ display: flex; align-items: center; justify-content: center;
86
+ font-weight: 800; color: #fff; font-size: 1.1rem;
87
+ box-shadow: 0 0 14px rgba(255,76,76,0.45);
88
+ }
89
+ .algoscope-title-text { font-size: 1.8rem; font-weight: 800; color: #fff; line-height: 1.1; }
90
+ .algoscope-subtitle { font-size: 0.85rem; color: #9aa0c0; }
91
+ .algoscope-header-right { text-align: right; }
92
+ .algoscope-live {
93
+ display: inline-flex; align-items: center; gap: 0.35rem;
94
+ padding: 0.2rem 0.6rem; border-radius: 999px;
95
+ background: rgba(46,204,113,0.08); border: 1px solid rgba(46,204,113,0.2);
96
+ margin-bottom: 0.25rem;
97
+ }
98
+ .algoscope-live-dot {
99
+ width: 7px; height: 7px; border-radius: 50%; background: #2ecc71;
100
+ box-shadow: 0 0 6px rgba(46,204,113,0.9);
101
+ animation: blink 1.5s infinite;
102
+ }
103
+ @keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.3} }
104
+ .algoscope-live-text { color: #2ecc71; font-weight: 700; letter-spacing: 1px; font-size: 0.7rem; }
105
+ .algoscope-meta { font-size: 0.75rem; color: #6f7695; }
106
+ .algoscope-meta a { color: #a6b0ff; text-decoration: none; }
107
+ .sidebar-section-label {
108
+ font-size: 0.6rem; text-transform: uppercase; letter-spacing: 1.2px;
109
+ color: #3a4060; margin-top: 0.5rem; margin-bottom: 0.3rem;
110
+ }
111
+ .term-row {
112
+ display: flex; align-items: center; justify-content: space-between;
113
+ background: #141826; border: 1px solid #1e2540; border-radius: 8px;
114
+ padding: 6px 10px; margin-bottom: 5px;
115
+ }
116
+ .term-row-left { display: flex; align-items: center; gap: 8px; font-size: 0.8rem; color: #c8cce0; }
117
+ .term-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
118
+ .term-pct { font-size: 0.7rem; color: #3a4060; }
119
+ .card {
120
+ background: #0d1120; border: 1px solid #1e2540;
121
+ border-radius: 10px; padding: 0.75rem 0.9rem;
122
+ }
123
+ .metric-label {
124
+ font-size: 0.6rem; text-transform: uppercase; letter-spacing: 1px;
125
+ color: #3a4060; margin-bottom: 0.35rem;
126
+ }
127
+ .metric-value { font-size: 1.4rem; font-weight: 700; line-height: 1.1; }
128
+ .metric-sub { font-size: 0.72rem; color: #8a90ad; margin-top: 0.2rem; }
129
+ .post-list { max-height: 320px; overflow-y: auto; }
130
+ .post-card {
131
+ display: flex; align-items: center; gap: 0.6rem;
132
+ padding: 0.55rem 0.4rem; border-bottom: 1px solid #141826;
133
+ }
134
+ .score-badge {
135
+ min-width: 48px; text-align: center; font-size: 0.72rem;
136
+ font-weight: 700; border-radius: 6px; padding: 0.2rem 0.3rem; flex-shrink: 0;
137
+ }
138
+ .score-low { background: rgba(46,204,113,0.15); color: #2ecc71; }
139
+ .score-mid { background: rgba(255,159,67,0.15); color: #ff9f43; }
140
+ .score-high { background: rgba(255,75,75,0.18); color: #ff4b4b; }
141
+ .post-text { flex: 1; font-size: 0.78rem; color: #c8cce0; }
142
+ .term-pill {
143
+ font-size: 0.68rem; padding: 0.12rem 0.4rem; border-radius: 999px;
144
+ background: rgba(155,127,212,0.14); color: #c3a6ff;
145
+ border: 1px solid rgba(155,127,212,0.5); white-space: nowrap;
146
+ }
147
+ .custom-term-pill {
148
+ display: inline-flex; align-items: center; gap: 0.2rem;
149
+ padding: 0.1rem 0.4rem; border-radius: 999px;
150
+ background: rgba(155,127,212,0.1); color: #c3a6ff;
151
+ border: 1px solid rgba(155,127,212,0.5);
152
+ font-size: 0.68rem; margin: 2px 3px 0 0;
153
+ }
154
+ </style>
155
+ """,
156
+ unsafe_allow_html=True,
157
+ )
158
+
159
+ # ── Header ─────────────────────────────────────────────────────────────────
160
+ # WHY st.components.v1.html: st.markdown strips onclick for security.
161
+ # components.html renders inside an iframe, so JS runs freely and can
162
+ # reach window.parent.document to click Streamlit's native sidebar button.
163
+ import streamlit.components.v1 as components
164
+ components.html("""
165
+ <style>
166
+ body { margin: 0; padding: 0; background: transparent; }
167
+ #toggle-btn {
168
+ position: fixed;
169
+ top: 8px;
170
+ left: 8px;
171
+ z-index: 999999;
172
+ width: 34px;
173
+ height: 34px;
174
+ background: #1a1f35;
175
+ border: 1px solid #2e3560;
176
+ border-radius: 7px;
177
+ color: #e8eaf0;
178
+ font-size: 18px;
179
+ cursor: pointer;
180
+ display: flex;
181
+ align-items: center;
182
+ justify-content: center;
183
+ }
184
+ #toggle-btn:hover { background: #252b4a; border-color: #ff6b3d; color: #ff6b3d; }
185
+ </style>
186
+ <button id="toggle-btn" onclick="
187
+ var d = window.parent.document;
188
+ var btn = d.querySelector('[data-testid=stSidebarCollapseButton] button')
189
+ || d.querySelector('[data-testid=stSidebarNavCollapseButton] button')
190
+ || d.querySelector('button[kind=header]');
191
+ if (btn) btn.click();
192
+ ">&#9776;</button>
193
+ """, height=50, scrolling=False)
194
+
195
+ st.markdown(
196
+ """
197
+ <div class="algoscope-header">
198
+ <div class="algoscope-header-left">
199
+ <div class="algoscope-logo">A</div>
200
+ <div>
201
+ <div class="algoscope-title-text">AlgoScope</div>
202
+ <div class="algoscope-subtitle">Real-time algospeak &amp; toxicity intelligence on Bluesky</div>
203
+ </div>
204
+ </div>
205
+ <div class="algoscope-header-right">
206
+ <div class="algoscope-live">
207
+ <span class="algoscope-live-dot"></span>
208
+ <span class="algoscope-live-text">LIVE</span>
209
+ </div>
210
+ <div class="algoscope-meta">
211
+ by Odeliya Charitonova <br/>
212
+ <a href="https://github.com/odeliyach/Algoscope" target="_blank">
213
+ github.com/odeliyach/Algoscope
214
+ </a>
215
+ </div>
216
+ </div>
217
+ </div>
218
+ """,
219
+ unsafe_allow_html=True,
220
+ )
221
+
222
+ # ── Session state init ───────────────��─────────────────────────────────────
223
+ # WHY session_state: Streamlit reruns the entire script on every interaction.
224
+ # Anything that must survive between clicks lives here.
225
+ defaults = {
226
+ "analyzed_results": [],
227
+ "just_fetched": False,
228
+ "graph_html": None,
229
+ "custom_terms": [],
230
+ "selected_queries": list(ALGOSPEAK_QUERIES[:3]),
231
+ "fetch_stats": None,
232
+ "auto_refresh_ran": False,
233
+ "auto_trigger_fetch": False,
234
+ "fetch_message": None,
235
+ "last_min_cooc": 3,
236
+ "sidebar_open": True,
237
+ }
238
+ for k, v in defaults.items():
239
+ if k not in st.session_state:
240
+ st.session_state[k] = v
241
+
242
+ # ── Sidebar ────────────────────────────────────────────────────────────────
243
+ with st.sidebar:
244
+ # ── Tracked terms display (styled pills like the mockup) ───────────────
245
+ st.markdown('<div class="sidebar-section-label">Tracked terms</div>', unsafe_allow_html=True)
246
+
247
+ # Build toxic-ratio per term from DB history for the sidebar display
248
+ history_for_terms = get_recent_posts(limit=1000)
249
+ term_toxic_ratio: dict[str, float] = {}
250
+ for term in ALGOSPEAK_QUERIES:
251
+ term_posts = [r for r in history_for_terms if term in (r.get("text") or "").lower()]
252
+ if term_posts:
253
+ toxic_n = sum(1 for r in term_posts if (r.get("label") or "").lower() == "toxic")
254
+ term_toxic_ratio[term] = toxic_n / len(term_posts)
255
+ else:
256
+ term_toxic_ratio[term] = 0.0
257
+
258
+ selected_queries = st.session_state.selected_queries
259
+ base_options = list(ALGOSPEAK_QUERIES)
260
+ extra_options = [t for t in st.session_state.custom_terms if t not in base_options]
261
+ all_options = base_options + extra_options
262
+
263
+ # Show styled term rows for selected terms
264
+ term_rows_html = []
265
+ for term in selected_queries:
266
+ ratio = term_toxic_ratio.get(term, 0.0)
267
+ pct = int(ratio * 100)
268
+ if ratio > 0.7:
269
+ dot_color = "#ff4b4b"
270
+ elif ratio >= 0.4:
271
+ dot_color = "#ff8c42"
272
+ else:
273
+ dot_color = "#2ecc71"
274
+ term_rows_html.append(f"""
275
+ <div class="term-row">
276
+ <div class="term-row-left">
277
+ <div class="term-dot" style="background:{dot_color};"></div>
278
+ <span>{escape(term)}</span>
279
+ </div>
280
+ <span class="term-pct">{pct}%</span>
281
+ </div>""")
282
+ if term_rows_html:
283
+ st.markdown("".join(term_rows_html), unsafe_allow_html=True)
284
+
285
+ st.markdown('<div class="sidebar-section-label">Algospeak terms</div>', unsafe_allow_html=True)
286
+ # WHY multiselect over checkboxes: supports the full vocabulary list
287
+ # and lets analysts add/remove terms without page reload.
288
+ selected_queries = st.multiselect(
289
+ "Select terms",
290
+ options=all_options,
291
+ default=st.session_state.selected_queries,
292
+ label_visibility="collapsed",
293
+ )
294
+ st.session_state.selected_queries = selected_queries
295
+
296
+ # Custom term input — full width input + button below
297
+ new_term = st.text_input("Add custom term", key="custom_term_input", label_visibility="collapsed",
298
+ placeholder="Add custom term...")
299
+ add_clicked = st.button("+ Add term", key="add_custom_term", use_container_width=True)
300
+ if add_clicked and new_term.strip():
301
+ term = new_term.strip().lower()
302
+ if term not in st.session_state.custom_terms and term not in base_options:
303
+ st.session_state.custom_terms.append(term)
304
+ if term not in st.session_state.selected_queries:
305
+ st.session_state.selected_queries.append(term)
306
+ selected_queries = st.session_state.selected_queries
307
+
308
+ if st.session_state.custom_terms:
309
+ pills = "".join(
310
+ f"<span class='custom-term-pill'>{escape(t)}</span>"
311
+ for t in st.session_state.custom_terms
312
+ )
313
+ st.markdown(pills, unsafe_allow_html=True)
314
+
315
+ # ── Threshold ──────────────────────────────────────────────────────────
316
+ st.markdown('<div class="sidebar-section-label">Threshold</div>', unsafe_allow_html=True)
317
+ # WHY threshold slider: separates ML policy from model. Operators tune
318
+ # sensitivity without retraining — standard MLOps pattern.
319
+ threshold = st.slider("Toxicity threshold", 0.0, 1.0, 0.7, 0.05, label_visibility="collapsed")
320
+
321
+ # ── Sampling ───────────────────────────────────────────────────────────
322
+ st.markdown('<div class="sidebar-section-label">Sampling</div>', unsafe_allow_html=True)
323
+ limit = st.slider("Number of posts", 5, 100, 25, label_visibility="collapsed")
324
+
325
+ # ── Fetch ──────────────────────────────────────────────────────────────
326
+ st.markdown('<div class="sidebar-section-label">Fetch</div>', unsafe_allow_html=True)
327
+ auto_refresh = st.checkbox("Auto-refresh (60s)", value=False)
328
+ fetch_button = st.button("Fetch & Analyze", type="primary", use_container_width=True)
329
+
330
+ # Auto-refresh countdown
331
+ if auto_refresh and not st.session_state.auto_refresh_ran and not fetch_button:
332
+ ph = st.empty()
333
+ for remaining in range(60, 0, -1):
334
+ ph.caption(f"Next fetch in: {remaining}s")
335
+ time.sleep(1)
336
+ st.session_state.auto_trigger_fetch = True
337
+ st.session_state.auto_refresh_ran = True
338
+ st.rerun() # WHY st.rerun: experimental_rerun removed in Streamlit 1.34
339
+ if not auto_refresh:
340
+ st.session_state.auto_refresh_ran = False
341
+
342
+ # ── Fetch stats ────────────────────────────────────────────────────────
343
+ if st.session_state.fetch_stats:
344
+ s = st.session_state.fetch_stats
345
+ st.caption(f"Fetched {s['n']} posts in {s['fetch']:.1f}s · Inference: {s['infer']:.1f}s")
346
+
347
+ min_cooccurrence = st.session_state.last_min_cooc
348
+
349
+ # ── Trigger handling ───────────────────────────────────────────────────────
350
+ if st.session_state.auto_trigger_fetch:
351
+ fetch_button = True
352
+ st.session_state.auto_trigger_fetch = False
353
+
354
+ # ── Fetch & Analyze ────────────────────────────────────────────────────────
355
+ if fetch_button:
356
+ st.session_state.just_fetched = True
357
+ st.session_state.analyzed_results = []
358
+
359
+ with st.spinner("Fetching posts from Bluesky..."):
360
+ t0 = time.time()
361
+ effective_query = selected_queries[0] if selected_queries else "toxic"
362
+ posts = fetch_posts(query=effective_query, limit=limit, queries=selected_queries or None)
363
+ fetch_time = time.time() - t0
364
+
365
+ if posts:
366
+ t1 = time.time()
367
+ # WHY predict_batch: one forward pass for all posts — ~50x faster
368
+ # than looping predict() on CPU (measured: 0.36s vs ~18s for 50 posts).
369
+ predictions = classifier.predict_batch(posts)
370
+ infer_time = time.time() - t1
371
+
372
+ # WHY store created_at and query_term in session_state:
373
+ # trend alerts and export both need these fields. Without created_at,
374
+ # all datetime comparisons return None and alerts never fire.
375
+ batch_ts = datetime.utcnow().isoformat()
376
+ for text, pred in zip(posts, predictions):
377
+ score = float(pred.get("score", 0.0) or 0.0)
378
+ label = "toxic" if score >= threshold else "non-toxic"
379
+ # Identify which query term matched this post
380
+ matched_term = next(
381
+ (t for t in (selected_queries or []) if t and t in text.lower()),
382
+ selected_queries[0] if selected_queries else ""
383
+ )
384
+ save_post(text=text, label=label, score=score, platform="bluesky")
385
+ st.session_state.analyzed_results.append({
386
+ "text": text,
387
+ "label": label,
388
+ "score": score,
389
+ "query_term": matched_term,
390
+ "created_at": batch_ts,
391
+ })
392
+
393
+ st.session_state.fetch_stats = {"n": len(posts), "fetch": fetch_time, "infer": infer_time}
394
+ st.session_state.fetch_message = ("success", f"Done! Analyzed {len(posts)} posts.")
395
+ else:
396
+ st.session_state.fetch_stats = {"n": 0, "fetch": fetch_time, "infer": 0.0}
397
+ st.session_state.fetch_message = ("warning", "No posts fetched. Try again or check your connection.")
398
+ else:
399
+ st.session_state.just_fetched = False
400
+
401
+
402
+ # ── Load data ──────────────────────────────────────────────────────────────
403
+ # WHY limit=10000: using 1000 capped the "Posts analyzed" metric
404
+ # at 1000 even when the DB had more. High limit shows the real total.
405
+ history = get_recent_posts(limit=10000)
406
+ batch_rows = st.session_state.analyzed_results
407
+ vocab = set(st.session_state.selected_queries or []) | set(st.session_state.custom_terms or [])
408
+
409
+ # ── Tabs ───────────────────────────────────────────────────────────────────
410
+ overview_tab, graph_tab, compare_tab, export_tab = st.tabs([" Overview ", " Co-occurrence Graph ", " Term Comparison ", " Export "])
411
+
412
+ with overview_tab:
413
+
414
+ # Show fetch message inside the tab (not above tabs)
415
+ if st.session_state.get("fetch_message"):
416
+ msg_type, msg_text = st.session_state.fetch_message
417
+ if msg_type == "success":
418
+ st.success(msg_text)
419
+ else:
420
+ st.warning(msg_text)
421
+ st.session_state.fetch_message = None
422
+
423
+ # ── Trend alerts ───────────────────────────────────────────────────────
424
+ # WHY: simple spike detection — if a term's toxicity rate in the last
425
+ # hour exceeds 80%, show a red banner. In production you'd use z-scores
426
+ # or CUSUM control charts; this is the lightweight portfolio version.
427
+ now_utc = datetime.utcnow()
428
+ one_hour_ago = now_utc - timedelta(hours=1)
429
+ alerts = []
430
+ all_terms_alert = list(set(st.session_state.selected_queries or []) | set(st.session_state.custom_terms or []))
431
+ for term in all_terms_alert:
432
+ recent_term = [r for r in history
433
+ if term in (r.get("text") or "").lower()
434
+ and _parse_dt(r.get("created_at"))
435
+ and _parse_dt(r.get("created_at")) >= one_hour_ago]
436
+ if len(recent_term) >= 3:
437
+ t_rate = sum(1 for r in recent_term if (r.get("label") or "").lower() == "toxic") / len(recent_term)
438
+ if t_rate >= 0.80:
439
+ alerts.append((term, t_rate, len(recent_term)))
440
+ for term, t_rate, count in alerts:
441
+ st.markdown(f"""<div style="background:rgba(255,75,75,0.10);border:1px solid
442
+ rgba(255,75,75,0.4);border-radius:10px;padding:0.6rem 1rem;
443
+ margin-bottom:0.5rem;display:flex;align-items:center;gap:0.7rem">
444
+ <span style="font-size:1.1rem">🚨</span>
445
+ <span style="color:#ff4b4b;font-weight:700">Spike detected:</span>
446
+ <span style="color:#e8eaf0"><b>{term}</b> &mdash; {t_rate*100:.0f}%
447
+ toxic in last hour ({count} posts)</span></div>""",
448
+ unsafe_allow_html=True)
449
+
450
+ # ── Metrics ────────────────────────────────────────────────────────────
451
+ total_posts = len(history)
452
+ toxic_posts = sum(1 for r in history if (r.get("label") or "").lower() == "toxic")
453
+ toxic_rate = (toxic_posts / total_posts * 100.0) if total_posts else 0.0
454
+ avg_score = (
455
+ sum(float(r.get("score", 0) or 0) for r in batch_rows) / len(batch_rows)
456
+ if batch_rows else 0.0
457
+ )
458
+
459
+ term_counts: dict[str, int] = defaultdict(int)
460
+ for row in batch_rows:
461
+ txt = (row.get("text") or "").lower()
462
+ for t in vocab:
463
+ if t and t in txt:
464
+ term_counts[t] += 1
465
+ top_term = max(term_counts, key=term_counts.get) if term_counts else "—"
466
+
467
+ now = datetime.utcnow()
468
+ yesterday = now - timedelta(hours=24)
469
+ two_days_ago = now - timedelta(hours=48)
470
+
471
+ posts_today = sum(
472
+ 1 for r in history
473
+ if _parse_dt(r.get("created_at")) and _parse_dt(r["created_at"]) > yesterday
474
+ )
475
+ posts_yesterday_n = sum(
476
+ 1 for r in history
477
+ if _parse_dt(r.get("created_at"))
478
+ and two_days_ago < _parse_dt(r["created_at"]) <= yesterday
479
+ )
480
+ delta_sign = "+" if posts_today >= posts_yesterday_n else ""
481
+ delta_text = f"{delta_sign}{posts_today - posts_yesterday_n} vs yesterday"
482
+
483
+ # WHY compare batch vs prior history instead of today vs yesterday:
484
+ # All posts in the DB were fetched today, so a day-based comparison
485
+ # always yields delta=0. Comparing the current batch toxic rate against
486
+ # everything fetched before it gives a real, meaningful signal —
487
+ # "is this fetch more toxic than usual?"
488
+ batch_toxic = sum(1 for r in batch_rows if (r.get("label") or "").lower() == "toxic")
489
+ batch_rate = (batch_toxic / len(batch_rows) * 100.0) if batch_rows else None
490
+
491
+ prior_rows = [r for r in history if r not in batch_rows]
492
+ prior_toxic = sum(1 for r in prior_rows if (r.get("label") or "").lower() == "toxic")
493
+ prior_rate = (prior_toxic / len(prior_rows) * 100.0) if prior_rows else None
494
+
495
+ if batch_rate is not None and prior_rate is not None:
496
+ rate_delta = batch_rate - prior_rate
497
+ delta_color = "#ff4b4b" if rate_delta > 0 else "#2ecc71"
498
+ delta_arrow = "↑" if rate_delta > 0 else "↓"
499
+ rate_delta_str = f"{delta_arrow} {abs(rate_delta):.1f}% vs prior"
500
+ elif batch_rate is not None:
501
+ rate_delta_str = f"batch: {batch_rate:.1f}%"
502
+ delta_color = "#ff4b4b" if batch_rate >= 70 else "#ff8c42" if batch_rate >= 40 else "#2ecc71"
503
+ else:
504
+ rate_delta_str = "fetch to see delta"
505
+ delta_color = "#5a6080"
506
+
507
+ c1, c2, c3, c4 = st.columns(4)
508
+ with c1:
509
+ st.markdown(f"""<div class="card">
510
+ <div class="metric-label">Posts analyzed</div>
511
+ <div class="metric-value" style="color:#fff">{total_posts}</div>
512
+ <div class="metric-sub" style="color:#ff6b3d">+{posts_today} today &middot; {delta_text}</div>
513
+ </div>""", unsafe_allow_html=True)
514
+ with c2:
515
+ st.markdown(f"""<div class="card">
516
+ <div class="metric-label">Toxic rate</div>
517
+ <div class="metric-value" style="color:#ff4b4b">{toxic_rate:.1f}%</div>
518
+ <div class="metric-sub" style="color:{delta_color}">{rate_delta_str}</div>
519
+ </div>""", unsafe_allow_html=True)
520
+ with c3:
521
+ st.markdown(f"""<div class="card">
522
+ <div class="metric-label">Avg score (last batch)</div>
523
+ <div class="metric-value" style="color:#ff8c42">{avg_score:.3f}</div>
524
+ <div class="metric-sub">Mean toxicity from last fetch</div>
525
+ </div>""", unsafe_allow_html=True)
526
+ with c4:
527
+ st.markdown(f"""<div class="card">
528
+ <div class="metric-label">Top term</div>
529
+ <div class="metric-value" style="color:#9b7fd4">{escape(str(top_term))}</div>
530
+ <div class="metric-sub">Most frequent in last batch</div>
531
+ </div>""", unsafe_allow_html=True)
532
+
533
+ st.markdown("<div style='height:0.8rem'></div>", unsafe_allow_html=True)
534
+
535
+ # ── Toxicity over time + Score distribution ────────────────────────────
536
+ ts_col, hist_col = st.columns([3, 1])
537
+
538
+ with ts_col:
539
+ st.markdown("#### Toxicity over time")
540
+ buckets: dict = defaultdict(lambda: {"sum": 0.0, "count": 0})
541
+ for row in history:
542
+ dt = _parse_dt(row.get("created_at"))
543
+ if not dt:
544
+ continue
545
+ hour = dt.replace(minute=0, second=0, microsecond=0)
546
+ score = float(row.get("score", 0) or 0)
547
+ buckets[hour]["sum"] += score
548
+ buckets[hour]["count"] += 1
549
+
550
+ if buckets:
551
+ # WHY sort by datetime key not string label: hours like "01:00"
552
+ # sort alphabetically before "03:00" so without proper datetime
553
+ # sorting the line jumps backwards across midnight, creating a
554
+ # visual zigzag. Sorting by the actual datetime object fixes this.
555
+ # WHY sort by datetime key: ensures chronological order across
556
+ # midnight. Using datetime objects (not strings) as the x-axis
557
+ # means Plotly treats it as a real time axis — no more zigzag
558
+ # when data spans midnight (e.g. 22:00 → 01:00 → 14:00).
559
+ # WHY fixed 24 buckets: previous approach only showed hours
560
+ # with data, causing uneven spacing and duplicate labels when
561
+ # data spanned midnight. A fixed 0-23 hour axis is always
562
+ # uniform — empty hours show as gaps in the line (connectgaps=False).
563
+ hour_avgs = {}
564
+ for dt_key, val in buckets.items():
565
+ h = dt_key.hour
566
+ # If same hour appears on multiple days, keep the most recent
567
+ if h not in hour_avgs or dt_key > max(
568
+ k for k in buckets if k.hour == h
569
+ ):
570
+ hour_avgs[h] = val["sum"] / val["count"]
571
+
572
+ x_labels = [f"{h:02d}:00" for h in range(24)]
573
+ y_vals = [hour_avgs.get(h, None) for h in range(24)]
574
+ point_colors = [
575
+ "#2ecc71" if v is not None and v < 0.4
576
+ else "#ff8c42" if v is not None and v < 0.7
577
+ else "#ff4b4b" if v is not None
578
+ else "rgba(0,0,0,0)"
579
+ for v in y_vals
580
+ ]
581
+
582
+ fig = go.Figure()
583
+ fig.add_trace(go.Scatter(
584
+ x=x_labels,
585
+ y=y_vals,
586
+ mode="lines+markers",
587
+ connectgaps=True,
588
+ line=dict(color="#ff4b4b", width=2, shape="spline"),
589
+ fill="tozeroy",
590
+ fillcolor="rgba(255,75,75,0.07)",
591
+ marker=dict(
592
+ color=point_colors,
593
+ size=8,
594
+ line=dict(color="#0a0d14", width=1.5),
595
+ ),
596
+ hovertemplate="%{x}<br>Avg score: %{y:.3f}<extra></extra>",
597
+ showlegend=False,
598
+ ))
599
+ fig.update_layout(
600
+ showlegend=False,
601
+ margin=dict(l=10, r=10, t=10, b=10),
602
+ paper_bgcolor="#0a0d14",
603
+ plot_bgcolor="#0a0d14",
604
+ height=220,
605
+ )
606
+ fig.update_xaxes(
607
+ showgrid=False, color="#5a6080", tickfont_size=10,
608
+ tickmode="array",
609
+ tickvals=[f"{h:02d}:00" for h in range(0, 24, 3)],
610
+ ticktext=[f"{h:02d}:00" for h in range(0, 24, 3)],
611
+ )
612
+ fig.update_yaxes(range=[0, 1], showgrid=True,
613
+ gridcolor="#1e2540", color="#5a6080", tickfont_size=10)
614
+ st.plotly_chart(fig, use_container_width=True)
615
+ else:
616
+ st.info("No time series data yet. Fetch posts first.")
617
+
618
+ with hist_col:
619
+ st.markdown("#### Score distribution")
620
+ scores = [float(r.get("score", 0) or 0) for r in batch_rows]
621
+ if scores:
622
+ bin_labels = ["0-0.2", "0.2-0.4", "0.4-0.6", "0.6-0.8", "0.8-1.0"]
623
+ bin_styles = [
624
+ ("rgba(46,204,113,0.15)", "#2ecc71"),
625
+ ("rgba(163,230,53,0.15)", "#a3e635"),
626
+ ("rgba(255,235,59,0.15)", "#ffeb3b"),
627
+ ("rgba(255,140,66,0.15)", "#ff8c42"),
628
+ ("rgba(255,75,75,0.18)", "#ff4b4b"),
629
+ ]
630
+ bins = [0.0, 0.2, 0.4, 0.6, 0.8, 1.01]
631
+ counts = [0] * 5
632
+ for s in scores:
633
+ for i in range(5):
634
+ if bins[i] <= s < bins[i + 1]:
635
+ counts[i] += 1
636
+ break
637
+ fig2 = go.Figure()
638
+ for label, count, (fill, line) in zip(bin_labels, counts, bin_styles):
639
+ fig2.add_trace(go.Bar(
640
+ x=[label], y=[count],
641
+ marker_color=fill,
642
+ marker_line_color=line,
643
+ marker_line_width=1.5,
644
+ hovertemplate=f"{label}: {count} posts<extra></extra>",
645
+ showlegend=False,
646
+ ))
647
+ fig2.update_layout(
648
+ showlegend=False,
649
+ margin=dict(l=5, r=5, t=10, b=10),
650
+ paper_bgcolor="#0a0d14",
651
+ plot_bgcolor="#0a0d14",
652
+ height=220, bargap=0.2,
653
+ )
654
+ fig2.update_xaxes(showgrid=False, color="#5a6080", tickfont_size=9)
655
+ fig2.update_yaxes(showgrid=True, gridcolor="#1e2540", color="#5a6080", tickfont_size=9)
656
+ st.plotly_chart(fig2, use_container_width=True)
657
+ else:
658
+ st.info("Fetch posts to see score distribution.")
659
+
660
+ # ── Activity heatmap ───────────────────────────────────────────────────
661
+ st.markdown("#### Activity heatmap")
662
+ # WHY heatmap: reveals temporal patterns in toxic speech — e.g. spikes
663
+ # on weekends or late night — that raw numbers can't show.
664
+ # GitHub-style contribution grid is immediately readable to developers.
665
+ hm: dict = defaultdict(lambda: {"sum": 0.0, "count": 0})
666
+ for row in history:
667
+ dt = _parse_dt(row.get("created_at"))
668
+ if not dt:
669
+ continue
670
+ hm[(dt.weekday(), dt.hour)]["sum"] += float(row.get("score", 0) or 0)
671
+ hm[(dt.weekday(), dt.hour)]["count"] += 1
672
+
673
+ if hm:
674
+ max_avg = max(
675
+ (v["sum"] / v["count"] for v in hm.values() if v["count"]), default=1.0
676
+ ) or 1.0
677
+
678
+ days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
679
+ rows_html = ["<div style='display:flex;flex-direction:column;gap:3px;overflow-x:auto;'>"]
680
+
681
+ # Hour labels row
682
+ hour_labels = "<div style='display:flex;align-items:center;gap:2px;margin-bottom:2px;'>"
683
+ hour_labels += "<div style='width:36px'></div>"
684
+ for h in range(24):
685
+ label = str(h) if h % 6 == 0 else ""
686
+ hour_labels += f"<div style='width:18px;font-size:0.55rem;color:#3a4060;text-align:center'>{label}</div>"
687
+ hour_labels += "</div>"
688
+ rows_html.append(hour_labels)
689
+
690
+ for d_idx, day in enumerate(days):
691
+ row_html = "<div style='display:flex;align-items:center;gap:2px;'>"
692
+ row_html += f"<div style='width:36px;font-size:0.68rem;color:#5a6080;text-align:right;padding-right:6px'>{day}</div>"
693
+ for h in range(24):
694
+ cell = hm.get((d_idx, h))
695
+ if cell and cell["count"]:
696
+ intensity = (cell["sum"] / cell["count"]) / max_avg
697
+ r_val = int(30 + (255 - 30) * intensity)
698
+ g_val = int(37 + (75 - 37) * intensity)
699
+ b_val = int(64 + (75 - 64) * intensity)
700
+ bg = f"rgb({r_val},{g_val},{b_val})"
701
+ title = f"{day} {h:02d}:00 — avg {cell['sum']/cell['count']:.2f}"
702
+ else:
703
+ bg = "#1a1d2e"
704
+ title = f"{day} {h:02d}:00 — no data"
705
+ row_html += f"<div style='width:18px;height:18px;background:{bg};border-radius:3px;' title='{title}'></div>"
706
+ row_html += "</div>"
707
+ rows_html.append(row_html)
708
+
709
+ rows_html.append("</div>")
710
+ rows_html.append("""
711
+ <div style='margin-top:6px;display:flex;align-items:center;gap:6px;font-size:0.68rem;color:#5a6080;'>
712
+ <span>Low</span>
713
+ <div style='width:100px;height:7px;border-radius:4px;
714
+ background:linear-gradient(90deg,#1a1d2e,#ff4b4b);'></div>
715
+ <span>High toxicity</span>
716
+ </div>""")
717
+
718
+ st.markdown("".join(rows_html), unsafe_allow_html=True)
719
+ else:
720
+ st.info("Not enough data for heatmap yet.")
721
+
722
+ st.markdown("<div style='height:0.5rem'></div>", unsafe_allow_html=True)
723
+
724
+ # ── Recent posts ───────────────────────────────────────────────────────
725
+ st.markdown("#### Recent posts (last batch)")
726
+ if batch_rows:
727
+ cards_html = ["<div class='card post-list'>"]
728
+ for row in batch_rows[:20]:
729
+ text = (row.get("text") or "").strip()
730
+ score = float(row.get("score", 0) or 0)
731
+ sc = "score-high" if score >= 0.7 else ("score-mid" if score >= 0.4 else "score-low")
732
+ # WHY prefer stored query_term over vocab search:
733
+ # query_term is set at fetch time so it's always accurate.
734
+ # vocab search is a fallback for older rows that predate this field.
735
+ matched = row.get("query_term") or next((t for t in vocab if t and t in text.lower()), "—")
736
+ cards_html.append(f"""<div class="post-card">
737
+ <div class="score-badge {sc}">{score:.3f}</div>
738
+ <div class="post-text">{escape(text[:110])}</div>
739
+ <div class="term-pill">{escape(matched)}</div>
740
+ </div>""")
741
+ cards_html.append("</div>")
742
+ st.markdown("".join(cards_html), unsafe_allow_html=True)
743
+ else:
744
+ st.info("Click 'Fetch & Analyze' to see recent posts.")
745
+
746
+ with graph_tab:
747
+ # Graph controls live inside the tab.
748
+ # WHY: we use a session_state trigger flag so the button click stores
749
+ # intent in session_state. On the NEXT rerun (which re-renders the tab
750
+ # we are already on), the flag is read and graph is built. This avoids
751
+ # the tab-reset issue caused by st.button triggering a full rerun.
752
+ info_col, ctrl_col = st.columns([3, 1])
753
+ with info_col:
754
+ st.markdown("""<div class="card" style="margin-bottom:1rem">
755
+ <div class="metric-label">How to read this graph</div>
756
+ <div style="font-size:0.8rem;color:#8a90ad;margin-top:0.3rem">
757
+ Words that frequently appear together in algospeak posts are connected.
758
+ Node size = frequency &nbsp;|&nbsp;
759
+ <span style="color:#ff4b4b">red &gt;70% toxic</span> &nbsp;
760
+ <span style="color:#ff9f43">orange 40-70% mixed</span> &nbsp;
761
+ <span style="color:#2ecc71">green &lt;40% benign</span>
762
+ </div>
763
+ </div>""", unsafe_allow_html=True)
764
+ with ctrl_col:
765
+ min_cooccurrence = st.slider("Min co-occurrences", 2, 10,
766
+ st.session_state.last_min_cooc, key="graph_cooc_slider")
767
+ _ = st.checkbox("Toxic posts only", value=False, key="toxic_only_graph")
768
+ if st.button("Build Graph", use_container_width=True, key="build_graph_btn"):
769
+ st.session_state.build_graph_trigger = True
770
+ st.session_state.build_graph_cooc = int(min_cooccurrence)
771
+
772
+ if st.session_state.get("build_graph_trigger"):
773
+ st.session_state.build_graph_trigger = False
774
+ cooc = st.session_state.get("build_graph_cooc", 3)
775
+ with st.spinner("Building co-occurrence graph..."):
776
+ graph = build_cooccurrence_graph(min_cooccurrence=cooc)
777
+ st.session_state.last_min_cooc = cooc
778
+ if graph.number_of_nodes() == 0:
779
+ st.session_state.graph_html = None
780
+ st.warning("Not enough data. Fetch more posts first.")
781
+ else:
782
+ st.session_state.graph_html = graph_to_pyvis(
783
+ graph, toxic_only=st.session_state.get("toxic_only_graph", False)
784
+ )
785
+
786
+ if st.session_state.graph_html:
787
+ st.components.v1.html(st.session_state.graph_html, height=620, scrolling=True)
788
+ else:
789
+ st.info("Adjust settings above and click 'Build Graph' to visualize word co-occurrences.")
790
+
791
+ # ── Term comparison tab ────────────────────────────────────────────────────
792
+ with compare_tab:
793
+ st.markdown("#### Compare two terms side by side")
794
+ st.markdown("<div style='color:#5a6080;font-size:0.82rem;margin-bottom:0.8rem'>Select two algospeak terms to compare their toxicity profiles from all stored posts.</div>", unsafe_allow_html=True)
795
+
796
+ all_opts = list(ALGOSPEAK_QUERIES) + [t for t in st.session_state.custom_terms if t not in ALGOSPEAK_QUERIES]
797
+ col_a, col_b = st.columns(2)
798
+ with col_a:
799
+ term_a = st.selectbox("Term A", all_opts, index=0, key="compare_a")
800
+ with col_b:
801
+ term_b = st.selectbox("Term B", all_opts, index=min(1, len(all_opts)-1), key="compare_b")
802
+
803
+ if term_a == term_b:
804
+ st.warning("Select two different terms to compare.")
805
+ else:
806
+ # WHY: filter history per term, then compute the same metrics used in
807
+ # Overview so the numbers are directly comparable side by side.
808
+ def term_stats(term):
809
+ posts = [r for r in history if term in (r.get("text") or "").lower()]
810
+ if not posts:
811
+ return None
812
+ scores = [float(r.get("score", 0) or 0) for r in posts]
813
+ toxic_n = sum(1 for r in posts if (r.get("label") or "").lower() == "toxic")
814
+ return {
815
+ "count": len(posts),
816
+ "toxic_rate": toxic_n / len(posts) * 100,
817
+ "avg_score": sum(scores) / len(scores),
818
+ "max_score": max(scores),
819
+ "posts": posts,
820
+ }
821
+
822
+ stats_a = term_stats(term_a)
823
+ stats_b = term_stats(term_b)
824
+
825
+ if not stats_a or not stats_b:
826
+ st.info("Not enough data for one or both terms. Fetch more posts first.")
827
+ else:
828
+ # Metric cards
829
+ ca, cb = st.columns(2)
830
+ for col, term, stats in [(ca, term_a, stats_a), (cb, term_b, stats_b)]:
831
+ color = "#ff4b4b" if stats["toxic_rate"] >= 70 else ("#ff8c42" if stats["toxic_rate"] >= 40 else "#2ecc71")
832
+ with col:
833
+ st.markdown(f"""<div class="card" style="margin-bottom:0.8rem">
834
+ <div style="font-size:1rem;font-weight:700;color:#e8eaf0;margin-bottom:0.5rem">"{term}"</div>
835
+ <div style="display:flex;gap:1.5rem;flex-wrap:wrap">
836
+ <div><div class="metric-label">Posts</div>
837
+ <div class="metric-value" style="color:#a6b0ff">{stats["count"]}</div></div>
838
+ <div><div class="metric-label">Toxic rate</div>
839
+ <div class="metric-value" style="color:{color}">{stats["toxic_rate"]:.1f}%</div></div>
840
+ <div><div class="metric-label">Avg score</div>
841
+ <div class="metric-value" style="color:{color}">{stats["avg_score"]:.3f}</div></div>
842
+ <div><div class="metric-label">Max score</div>
843
+ <div class="metric-value" style="color:#ff4b4b">{stats["max_score"]:.3f}</div></div>
844
+ </div></div>""", unsafe_allow_html=True)
845
+
846
+ # Side by side score distribution
847
+ st.markdown("#### Score distribution comparison")
848
+ bins = [0.0, 0.2, 0.4, 0.6, 0.8, 1.01]
849
+ bin_labels = ["0-0.2", "0.2-0.4", "0.4-0.6", "0.6-0.8", "0.8-1.0"]
850
+
851
+ def bin_scores(posts):
852
+ counts = [0] * 5
853
+ for r in posts:
854
+ s = float(r.get("score", 0) or 0)
855
+ for i in range(5):
856
+ if bins[i] <= s < bins[i+1]:
857
+ counts[i] += 1
858
+ break
859
+ return counts
860
+
861
+ counts_a = bin_scores(stats_a["posts"])
862
+ counts_b = bin_scores(stats_b["posts"])
863
+
864
+ fig_cmp = go.Figure()
865
+ fig_cmp.add_trace(go.Bar(name=term_a, x=bin_labels, y=counts_a,
866
+ marker_color="rgba(166,176,255,0.2)", marker_line_color="#a6b0ff", marker_line_width=1.5))
867
+ fig_cmp.add_trace(go.Bar(name=term_b, x=bin_labels, y=counts_b,
868
+ marker_color="rgba(255,140,66,0.2)", marker_line_color="#ff8c42", marker_line_width=1.5))
869
+ fig_cmp.update_layout(
870
+ barmode="group", paper_bgcolor="#0a0d14", plot_bgcolor="#0a0d14",
871
+ legend=dict(font=dict(color="#e8eaf0")),
872
+ margin=dict(l=10, r=10, t=10, b=10), height=260,
873
+ )
874
+ fig_cmp.update_xaxes(showgrid=False, color="#5a6080")
875
+ fig_cmp.update_yaxes(showgrid=True, gridcolor="#1e2540", color="#5a6080")
876
+ st.plotly_chart(fig_cmp, use_container_width=True)
877
+
878
+ # ── Export tab ─────────────────────────────────────────────────────────────
879
+ with export_tab:
880
+ st.markdown("#### Export data")
881
+ st.markdown("<div style='color:#5a6080;font-size:0.82rem;margin-bottom:0.8rem'>Download posts from the current session or the full database history.</div>", unsafe_allow_html=True)
882
+
883
+ export_source = st.radio("Data source", ["Last batch (current fetch)", "Full history (all stored posts)"],
884
+ horizontal=True, key="export_source")
885
+ export_rows = batch_rows if export_source.startswith("Last") else history
886
+
887
+ if not export_rows:
888
+ st.info("No data to export yet. Fetch posts first.")
889
+ else:
890
+ import csv
891
+ import io
892
+ import json as _json
893
+
894
+ # WHY io.StringIO: lets us build the CSV entirely in memory without
895
+ # writing to disk — important for a stateless server environment like
896
+ # HuggingFace Spaces where you don't control the filesystem.
897
+ csv_buf = io.StringIO()
898
+ writer = csv.DictWriter(csv_buf, fieldnames=["text", "label", "score", "query_term", "created_at"])
899
+ writer.writeheader()
900
+ for r in export_rows:
901
+ writer.writerow({
902
+ "text": (r.get("text") or "").replace("\n", " "),
903
+ "label": r.get("label", ""),
904
+ "score": f'{float(r.get("score", 0) or 0):.4f}',
905
+ "query_term": r.get("query_term", ""),
906
+ "created_at": r.get("created_at", ""),
907
+ })
908
+
909
+ json_buf = _json.dumps(export_rows, ensure_ascii=False, indent=2, default=str)
910
+
911
+ st.markdown(f"<div style='color:#5a6080;font-size:0.8rem;margin-bottom:0.6rem'>{len(export_rows)} posts ready to export</div>", unsafe_allow_html=True)
912
+
913
+ dl_csv, dl_json = st.columns(2)
914
+ with dl_csv:
915
+ st.download_button(
916
+ label="Download CSV",
917
+ data=csv_buf.getvalue(),
918
+ file_name="algoscope_export.csv",
919
+ mime="text/csv",
920
+ use_container_width=True,
921
+ )
922
+ with dl_json:
923
+ st.download_button(
924
+ label="Download JSON",
925
+ data=json_buf,
926
+ file_name="algoscope_export.json",
927
+ mime="application/json",
928
+ use_container_width=True,
929
+ )
930
+
931
+ # Preview table
932
+ st.markdown("#### Preview (first 10 rows)")
933
+ preview_html = ["<div class='card'><table style='width:100%;border-collapse:collapse;font-size:0.75rem'>",
934
+ "<tr style='color:#5a6080;border-bottom:1px solid #1e2540'>",
935
+ "<th style='padding:6px;text-align:left'>Score</th>",
936
+ "<th style='padding:6px;text-align:left'>Label</th>",
937
+ "<th style='padding:6px;text-align:left'>Term</th>",
938
+ "<th style='padding:6px;text-align:left'>Text</th></tr>"]
939
+ for r in export_rows[:10]:
940
+ score = float(r.get("score", 0) or 0)
941
+ sc = "score-high" if score >= 0.7 else ("score-mid" if score >= 0.4 else "score-low")
942
+ preview_html.append(f"""<tr style='border-bottom:1px solid #141826'>
943
+ <td style='padding:5px'><span class='score-badge {sc}'>{score:.3f}</span></td>
944
+ <td style='padding:5px;color:#c8cce0'>{escape(r.get("label",""))}</td>
945
+ <td style='padding:5px'><span class='term-pill'>{escape(r.get("query_term",""))}</span></td>
946
+ <td style='padding:5px;color:#9aa0c0'>{escape((r.get("text","") or "")[:80])}</td>
947
+ </tr>""")
948
+ preview_html.append("</table></div>")
949
+ st.markdown("".join(preview_html), unsafe_allow_html=True)
950
+
951
+
952
+ if __name__ == "__main__":
953
+ main()
frontend/.env.development ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # WHY empty: in production, the frontend is served by the same FastAPI server
2
+ # on port 7860. Empty VITE_API_URL means fetch() uses relative URLs (/posts,
3
+ # /fetch-and-analyze etc.) which automatically go to the correct host.
4
+ VITE_API_URL=
frontend/.env.production ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # WHY empty: in production, React is served by the same FastAPI process.
2
+ # All fetch() calls use a relative URL (e.g. /posts), which resolves to
3
+ # the same origin — no CORS, no separate server address needed.
4
+ VITE_API_URL=
frontend/dist/assets/index-CZMDWpNf.js ADDED
The diff for this file is too large to render. See raw diff
 
frontend/dist/assets/index-DgSDpXn3.css ADDED
@@ -0,0 +1 @@
 
 
1
+ @media source(none){@layer theme,base,components,utilities;@layer theme{@theme default{ --font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif; --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; --color-red-50: oklch(97.1% .013 17.38); --color-red-100: oklch(93.6% .032 17.717); --color-red-200: oklch(88.5% .062 18.334); --color-red-300: oklch(80.8% .114 19.571); --color-red-400: oklch(70.4% .191 22.216); --color-red-500: oklch(63.7% .237 25.331); --color-red-600: oklch(57.7% .245 27.325); --color-red-700: oklch(50.5% .213 27.518); --color-red-800: oklch(44.4% .177 26.899); --color-red-900: oklch(39.6% .141 25.723); --color-red-950: oklch(25.8% .092 26.042); --color-orange-50: oklch(98% .016 73.684); --color-orange-100: oklch(95.4% .038 75.164); --color-orange-200: oklch(90.1% .076 70.697); --color-orange-300: oklch(83.7% .128 66.29); --color-orange-400: oklch(75% .183 55.934); --color-orange-500: oklch(70.5% .213 47.604); --color-orange-600: oklch(64.6% .222 41.116); --color-orange-700: oklch(55.3% .195 38.402); --color-orange-800: oklch(47% .157 37.304); --color-orange-900: oklch(40.8% .123 38.172); --color-orange-950: oklch(26.6% .079 36.259); --color-amber-50: oklch(98.7% .022 95.277); --color-amber-100: oklch(96.2% .059 95.617); --color-amber-200: oklch(92.4% .12 95.746); --color-amber-300: oklch(87.9% .169 91.605); --color-amber-400: oklch(82.8% .189 84.429); --color-amber-500: oklch(76.9% .188 70.08); --color-amber-600: oklch(66.6% .179 58.318); --color-amber-700: oklch(55.5% .163 48.998); --color-amber-800: oklch(47.3% .137 46.201); --color-amber-900: oklch(41.4% .112 45.904); --color-amber-950: oklch(27.9% .077 45.635); --color-yellow-50: oklch(98.7% .026 102.212); --color-yellow-100: oklch(97.3% .071 103.193); --color-yellow-200: oklch(94.5% .129 101.54); --color-yellow-300: oklch(90.5% .182 98.111); --color-yellow-400: oklch(85.2% .199 91.936); --color-yellow-500: oklch(79.5% .184 86.047); --color-yellow-600: oklch(68.1% .162 75.834); --color-yellow-700: oklch(55.4% .135 66.442); --color-yellow-800: oklch(47.6% .114 61.907); --color-yellow-900: oklch(42.1% .095 57.708); --color-yellow-950: oklch(28.6% .066 53.813); --color-lime-50: oklch(98.6% .031 120.757); --color-lime-100: oklch(96.7% .067 122.328); --color-lime-200: oklch(93.8% .127 124.321); --color-lime-300: oklch(89.7% .196 126.665); --color-lime-400: oklch(84.1% .238 128.85); --color-lime-500: oklch(76.8% .233 130.85); --color-lime-600: oklch(64.8% .2 131.684); --color-lime-700: oklch(53.2% .157 131.589); --color-lime-800: oklch(45.3% .124 130.933); --color-lime-900: oklch(40.5% .101 131.063); --color-lime-950: oklch(27.4% .072 132.109); --color-green-50: oklch(98.2% .018 155.826); --color-green-100: oklch(96.2% .044 156.743); --color-green-200: oklch(92.5% .084 155.995); --color-green-300: oklch(87.1% .15 154.449); --color-green-400: oklch(79.2% .209 151.711); --color-green-500: oklch(72.3% .219 149.579); --color-green-600: oklch(62.7% .194 149.214); --color-green-700: oklch(52.7% .154 150.069); --color-green-800: oklch(44.8% .119 151.328); --color-green-900: oklch(39.3% .095 152.535); --color-green-950: oklch(26.6% .065 152.934); --color-emerald-50: oklch(97.9% .021 166.113); --color-emerald-100: oklch(95% .052 163.051); --color-emerald-200: oklch(90.5% .093 164.15); --color-emerald-300: oklch(84.5% .143 164.978); --color-emerald-400: oklch(76.5% .177 163.223); --color-emerald-500: oklch(69.6% .17 162.48); --color-emerald-600: oklch(59.6% .145 163.225); --color-emerald-700: oklch(50.8% .118 165.612); --color-emerald-800: oklch(43.2% .095 166.913); --color-emerald-900: oklch(37.8% .077 168.94); --color-emerald-950: oklch(26.2% .051 172.552); --color-teal-50: oklch(98.4% .014 180.72); --color-teal-100: oklch(95.3% .051 180.801); --color-teal-200: oklch(91% .096 180.426); --color-teal-300: oklch(85.5% .138 181.071); --color-teal-400: oklch(77.7% .152 181.912); --color-teal-500: oklch(70.4% .14 182.503); --color-teal-600: oklch(60% .118 184.704); --color-teal-700: oklch(51.1% .096 186.391); --color-teal-800: oklch(43.7% .078 188.216); --color-teal-900: oklch(38.6% .063 188.416); --color-teal-950: oklch(27.7% .046 192.524); --color-cyan-50: oklch(98.4% .019 200.873); --color-cyan-100: oklch(95.6% .045 203.388); --color-cyan-200: oklch(91.7% .08 205.041); --color-cyan-300: oklch(86.5% .127 207.078); --color-cyan-400: oklch(78.9% .154 211.53); --color-cyan-500: oklch(71.5% .143 215.221); --color-cyan-600: oklch(60.9% .126 221.723); --color-cyan-700: oklch(52% .105 223.128); --color-cyan-800: oklch(45% .085 224.283); --color-cyan-900: oklch(39.8% .07 227.392); --color-cyan-950: oklch(30.2% .056 229.695); --color-sky-50: oklch(97.7% .013 236.62); --color-sky-100: oklch(95.1% .026 236.824); --color-sky-200: oklch(90.1% .058 230.902); --color-sky-300: oklch(82.8% .111 230.318); --color-sky-400: oklch(74.6% .16 232.661); --color-sky-500: oklch(68.5% .169 237.323); --color-sky-600: oklch(58.8% .158 241.966); --color-sky-700: oklch(50% .134 242.749); --color-sky-800: oklch(44.3% .11 240.79); --color-sky-900: oklch(39.1% .09 240.876); --color-sky-950: oklch(29.3% .066 243.157); --color-blue-50: oklch(97% .014 254.604); --color-blue-100: oklch(93.2% .032 255.585); --color-blue-200: oklch(88.2% .059 254.128); --color-blue-300: oklch(80.9% .105 251.813); --color-blue-400: oklch(70.7% .165 254.624); --color-blue-500: oklch(62.3% .214 259.815); --color-blue-600: oklch(54.6% .245 262.881); --color-blue-700: oklch(48.8% .243 264.376); --color-blue-800: oklch(42.4% .199 265.638); --color-blue-900: oklch(37.9% .146 265.522); --color-blue-950: oklch(28.2% .091 267.935); --color-indigo-50: oklch(96.2% .018 272.314); --color-indigo-100: oklch(93% .034 272.788); --color-indigo-200: oklch(87% .065 274.039); --color-indigo-300: oklch(78.5% .115 274.713); --color-indigo-400: oklch(67.3% .182 276.935); --color-indigo-500: oklch(58.5% .233 277.117); --color-indigo-600: oklch(51.1% .262 276.966); --color-indigo-700: oklch(45.7% .24 277.023); --color-indigo-800: oklch(39.8% .195 277.366); --color-indigo-900: oklch(35.9% .144 278.697); --color-indigo-950: oklch(25.7% .09 281.288); --color-violet-50: oklch(96.9% .016 293.756); --color-violet-100: oklch(94.3% .029 294.588); --color-violet-200: oklch(89.4% .057 293.283); --color-violet-300: oklch(81.1% .111 293.571); --color-violet-400: oklch(70.2% .183 293.541); --color-violet-500: oklch(60.6% .25 292.717); --color-violet-600: oklch(54.1% .281 293.009); --color-violet-700: oklch(49.1% .27 292.581); --color-violet-800: oklch(43.2% .232 292.759); --color-violet-900: oklch(38% .189 293.745); --color-violet-950: oklch(28.3% .141 291.089); --color-purple-50: oklch(97.7% .014 308.299); --color-purple-100: oklch(94.6% .033 307.174); --color-purple-200: oklch(90.2% .063 306.703); --color-purple-300: oklch(82.7% .119 306.383); --color-purple-400: oklch(71.4% .203 305.504); --color-purple-500: oklch(62.7% .265 303.9); --color-purple-600: oklch(55.8% .288 302.321); --color-purple-700: oklch(49.6% .265 301.924); --color-purple-800: oklch(43.8% .218 303.724); --color-purple-900: oklch(38.1% .176 304.987); --color-purple-950: oklch(29.1% .149 302.717); --color-fuchsia-50: oklch(97.7% .017 320.058); --color-fuchsia-100: oklch(95.2% .037 318.852); --color-fuchsia-200: oklch(90.3% .076 319.62); --color-fuchsia-300: oklch(83.3% .145 321.434); --color-fuchsia-400: oklch(74% .238 322.16); --color-fuchsia-500: oklch(66.7% .295 322.15); --color-fuchsia-600: oklch(59.1% .293 322.896); --color-fuchsia-700: oklch(51.8% .253 323.949); --color-fuchsia-800: oklch(45.2% .211 324.591); --color-fuchsia-900: oklch(40.1% .17 325.612); --color-fuchsia-950: oklch(29.3% .136 325.661); --color-pink-50: oklch(97.1% .014 343.198); --color-pink-100: oklch(94.8% .028 342.258); --color-pink-200: oklch(89.9% .061 343.231); --color-pink-300: oklch(82.3% .12 346.018); --color-pink-400: oklch(71.8% .202 349.761); --color-pink-500: oklch(65.6% .241 354.308); --color-pink-600: oklch(59.2% .249 .584); --color-pink-700: oklch(52.5% .223 3.958); --color-pink-800: oklch(45.9% .187 3.815); --color-pink-900: oklch(40.8% .153 2.432); --color-pink-950: oklch(28.4% .109 3.907); --color-rose-50: oklch(96.9% .015 12.422); --color-rose-100: oklch(94.1% .03 12.58); --color-rose-200: oklch(89.2% .058 10.001); --color-rose-300: oklch(81% .117 11.638); --color-rose-400: oklch(71.2% .194 13.428); --color-rose-500: oklch(64.5% .246 16.439); --color-rose-600: oklch(58.6% .253 17.585); --color-rose-700: oklch(51.4% .222 16.935); --color-rose-800: oklch(45.5% .188 13.697); --color-rose-900: oklch(41% .159 10.272); --color-rose-950: oklch(27.1% .105 12.094); --color-slate-50: oklch(98.4% .003 247.858); --color-slate-100: oklch(96.8% .007 247.896); --color-slate-200: oklch(92.9% .013 255.508); --color-slate-300: oklch(86.9% .022 252.894); --color-slate-400: oklch(70.4% .04 256.788); --color-slate-500: oklch(55.4% .046 257.417); --color-slate-600: oklch(44.6% .043 257.281); --color-slate-700: oklch(37.2% .044 257.287); --color-slate-800: oklch(27.9% .041 260.031); --color-slate-900: oklch(20.8% .042 265.755); --color-slate-950: oklch(12.9% .042 264.695); --color-gray-50: oklch(98.5% .002 247.839); --color-gray-100: oklch(96.7% .003 264.542); --color-gray-200: oklch(92.8% .006 264.531); --color-gray-300: oklch(87.2% .01 258.338); --color-gray-400: oklch(70.7% .022 261.325); --color-gray-500: oklch(55.1% .027 264.364); --color-gray-600: oklch(44.6% .03 256.802); --color-gray-700: oklch(37.3% .034 259.733); --color-gray-800: oklch(27.8% .033 256.848); --color-gray-900: oklch(21% .034 264.665); --color-gray-950: oklch(13% .028 261.692); --color-zinc-50: oklch(98.5% 0 0); --color-zinc-100: oklch(96.7% .001 286.375); --color-zinc-200: oklch(92% .004 286.32); --color-zinc-300: oklch(87.1% .006 286.286); --color-zinc-400: oklch(70.5% .015 286.067); --color-zinc-500: oklch(55.2% .016 285.938); --color-zinc-600: oklch(44.2% .017 285.786); --color-zinc-700: oklch(37% .013 285.805); --color-zinc-800: oklch(27.4% .006 286.033); --color-zinc-900: oklch(21% .006 285.885); --color-zinc-950: oklch(14.1% .005 285.823); --color-neutral-50: oklch(98.5% 0 0); --color-neutral-100: oklch(97% 0 0); --color-neutral-200: oklch(92.2% 0 0); --color-neutral-300: oklch(87% 0 0); --color-neutral-400: oklch(70.8% 0 0); --color-neutral-500: oklch(55.6% 0 0); --color-neutral-600: oklch(43.9% 0 0); --color-neutral-700: oklch(37.1% 0 0); --color-neutral-800: oklch(26.9% 0 0); --color-neutral-900: oklch(20.5% 0 0); --color-neutral-950: oklch(14.5% 0 0); --color-stone-50: oklch(98.5% .001 106.423); --color-stone-100: oklch(97% .001 106.424); --color-stone-200: oklch(92.3% .003 48.717); --color-stone-300: oklch(86.9% .005 56.366); --color-stone-400: oklch(70.9% .01 56.259); --color-stone-500: oklch(55.3% .013 58.071); --color-stone-600: oklch(44.4% .011 73.639); --color-stone-700: oklch(37.4% .01 67.558); --color-stone-800: oklch(26.8% .007 34.298); --color-stone-900: oklch(21.6% .006 56.043); --color-stone-950: oklch(14.7% .004 49.25); --color-black: #000; --color-white: #fff; --spacing: .25rem; --breakpoint-sm: 40rem; --breakpoint-md: 48rem; --breakpoint-lg: 64rem; --breakpoint-xl: 80rem; --breakpoint-2xl: 96rem; --container-3xs: 16rem; --container-2xs: 18rem; --container-xs: 20rem; --container-sm: 24rem; --container-md: 28rem; --container-lg: 32rem; --container-xl: 36rem; --container-2xl: 42rem; --container-3xl: 48rem; --container-4xl: 56rem; --container-5xl: 64rem; --container-6xl: 72rem; --container-7xl: 80rem; --text-xs: .75rem; --text-xs--line-height: calc(1 / .75); --text-sm: .875rem; --text-sm--line-height: calc(1.25 / .875); --text-base: 1rem; --text-base--line-height: 1.5 ; --text-lg: 1.125rem; --text-lg--line-height: calc(1.75 / 1.125); --text-xl: 1.25rem; --text-xl--line-height: calc(1.75 / 1.25); --text-2xl: 1.5rem; --text-2xl--line-height: calc(2 / 1.5); --text-3xl: 1.875rem; --text-3xl--line-height: 1.2 ; --text-4xl: 2.25rem; --text-4xl--line-height: calc(2.5 / 2.25); --text-5xl: 3rem; --text-5xl--line-height: 1; --text-6xl: 3.75rem; --text-6xl--line-height: 1; --text-7xl: 4.5rem; --text-7xl--line-height: 1; --text-8xl: 6rem; --text-8xl--line-height: 1; --text-9xl: 8rem; --text-9xl--line-height: 1; --font-weight-thin: 100; --font-weight-extralight: 200; --font-weight-light: 300; --font-weight-normal: 400; --font-weight-medium: 500; --font-weight-semibold: 600; --font-weight-bold: 700; --font-weight-extrabold: 800; --font-weight-black: 900; --tracking-tighter: -.05em; --tracking-tight: -.025em; --tracking-normal: 0em; --tracking-wide: .025em; --tracking-wider: .05em; --tracking-widest: .1em; --leading-tight: 1.25; --leading-snug: 1.375; --leading-normal: 1.5; --leading-relaxed: 1.625; --leading-loose: 2; --radius-xs: .125rem; --radius-sm: .25rem; --radius-md: .375rem; --radius-lg: .5rem; --radius-xl: .75rem; --radius-2xl: 1rem; --radius-3xl: 1.5rem; --radius-4xl: 2rem; --shadow-2xs: 0 1px rgb(0 0 0 / .05); --shadow-xs: 0 1px 2px 0 rgb(0 0 0 / .05); --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1); --shadow-md: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1); --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1); --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / .1), 0 8px 10px -6px rgb(0 0 0 / .1); --shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / .25); --inset-shadow-2xs: inset 0 1px rgb(0 0 0 / .05); --inset-shadow-xs: inset 0 1px 1px rgb(0 0 0 / .05); --inset-shadow-sm: inset 0 2px 4px rgb(0 0 0 / .05); --drop-shadow-xs: 0 1px 1px rgb(0 0 0 / .05); --drop-shadow-sm: 0 1px 2px rgb(0 0 0 / .15); --drop-shadow-md: 0 3px 3px rgb(0 0 0 / .12); --drop-shadow-lg: 0 4px 4px rgb(0 0 0 / .15); --drop-shadow-xl: 0 9px 7px rgb(0 0 0 / .1); --drop-shadow-2xl: 0 25px 25px rgb(0 0 0 / .15); --text-shadow-2xs: 0px 1px 0px rgb(0 0 0 / .15); --text-shadow-xs: 0px 1px 1px rgb(0 0 0 / .2); --text-shadow-sm: 0px 1px 0px rgb(0 0 0 / .075), 0px 1px 1px rgb(0 0 0 / .075), 0px 2px 2px rgb(0 0 0 / .075); --text-shadow-md: 0px 1px 1px rgb(0 0 0 / .1), 0px 1px 2px rgb(0 0 0 / .1), 0px 2px 4px rgb(0 0 0 / .1); --text-shadow-lg: 0px 1px 2px rgb(0 0 0 / .1), 0px 3px 2px rgb(0 0 0 / .1), 0px 4px 8px rgb(0 0 0 / .1); --ease-in: cubic-bezier(.4, 0, 1, 1); --ease-out: cubic-bezier(0, 0, .2, 1); --ease-in-out: cubic-bezier(.4, 0, .2, 1); --animate-spin: spin 1s linear infinite; --animate-ping: ping 1s cubic-bezier(0, 0, .2, 1) infinite; --animate-pulse: pulse 2s cubic-bezier(.4, 0, .6, 1) infinite; --animate-bounce: bounce 1s infinite; @keyframes spin { to { transform: rotate(360deg); } } @keyframes ping { 75%, 100% { transform: scale(2); opacity: 0; } } @keyframes pulse { 50% { opacity: .5; } } @keyframes bounce { 0%, 100% { transform: translateY(-25%); animation-timing-function: cubic-bezier(.8, 0, 1, 1); } 50% { transform: none; animation-timing-function: cubic-bezier(0, 0, .2, 1); } } --blur-xs: 4px; --blur-sm: 8px; --blur-md: 12px; --blur-lg: 16px; --blur-xl: 24px; --blur-2xl: 40px; --blur-3xl: 64px; --perspective-dramatic: 100px; --perspective-near: 300px; --perspective-normal: 500px; --perspective-midrange: 800px; --perspective-distant: 1200px; --aspect-video: 16 / 9; --default-transition-duration: .15s; --default-transition-timing-function: cubic-bezier(.4, 0, .2, 1); --default-font-family: --theme(--font-sans, initial); --default-font-feature-settings: --theme( --font-sans--font-feature-settings, initial ); --default-font-variation-settings: --theme( --font-sans--font-variation-settings, initial ); --default-mono-font-family: --theme(--font-mono, initial); --default-mono-font-feature-settings: --theme( --font-mono--font-feature-settings, initial ); --default-mono-font-variation-settings: --theme( --font-mono--font-variation-settings, initial ); }@theme default inline reference{ --blur: 8px; --shadow: 0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1); --shadow-inner: inset 0 2px 4px 0 rgb(0 0 0 / .05); --drop-shadow: 0 1px 2px rgb(0 0 0 / .1), 0 1px 1px rgb(0 0 0 / .06); --radius: .25rem; --max-width-prose: 65ch; }}@layer base{*,:after,:before,::backdrop,::file-selector-button{box-sizing:border-box;margin:0;padding:0;border:0 solid}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;font-family:--theme(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:--theme(--default-font-feature-settings,normal);font-variation-settings:--theme(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:--theme(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:--theme(--default-mono-font-feature-settings,normal);font-variation-settings:--theme(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea,::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;border-radius:0;background-color:transparent;opacity:1}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit,::-webkit-datetime-edit-year-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]),::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer utilities{@tailwind utilities;}}@property --tw-animation-delay{syntax:"*";inherits:false;initial-value:0s}@property --tw-animation-direction{syntax:"*";inherits:false;initial-value:normal}@property --tw-animation-duration{syntax:"*";inherits:false}@property --tw-animation-fill-mode{syntax:"*";inherits:false;initial-value:none}@property --tw-animation-iteration-count{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-translate-y{syntax:"*";inherits:false;initial-value:0}@theme inline{--animation-delay-0: 0s; --animation-delay-75: 75ms; --animation-delay-100: .1s; --animation-delay-150: .15s; --animation-delay-200: .2s; --animation-delay-300: .3s; --animation-delay-500: .5s; --animation-delay-700: .7s; --animation-delay-1000: 1s; --animation-repeat-0: 0; --animation-repeat-1: 1; --animation-repeat-infinite: infinite; --animation-direction-normal: normal; --animation-direction-reverse: reverse; --animation-direction-alternate: alternate; --animation-direction-alternate-reverse: alternate-reverse; --animation-fill-mode-none: none; --animation-fill-mode-forwards: forwards; --animation-fill-mode-backwards: backwards; --animation-fill-mode-both: both; --percentage-0: 0; --percentage-5: .05; --percentage-10: .1; --percentage-15: .15; --percentage-20: .2; --percentage-25: .25; --percentage-30: .3; --percentage-35: .35; --percentage-40: .4; --percentage-45: .45; --percentage-50: .5; --percentage-55: .55; --percentage-60: .6; --percentage-65: .65; --percentage-70: .7; --percentage-75: .75; --percentage-80: .8; --percentage-85: .85; --percentage-90: .9; --percentage-95: .95; --percentage-100: 1; --percentage-translate-full: 1; --animate-in: enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none); --animate-out: exit var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none); @keyframes enter { from { opacity: var(--tw-enter-opacity,1); transform: translate3d(var(--tw-enter-translate-x,0),var(--tw-enter-translate-y,0),0)scale3d(var(--tw-enter-scale,1),var(--tw-enter-scale,1),var(--tw-enter-scale,1))rotate(var(--tw-enter-rotate,0)); filter: blur(var(--tw-enter-blur,0)); }}@keyframes exit { to { opacity: var(--tw-exit-opacity,1); transform: translate3d(var(--tw-exit-translate-x,0),var(--tw-exit-translate-y,0),0)scale3d(var(--tw-exit-scale,1),var(--tw-exit-scale,1),var(--tw-exit-scale,1))rotate(var(--tw-exit-rotate,0)); filter: blur(var(--tw-exit-blur,0)); }}--animate-accordion-down: accordion-down var(--tw-animation-duration,var(--tw-duration,.2s))var(--tw-ease,ease-out)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none); --animate-accordion-up: accordion-up var(--tw-animation-duration,var(--tw-duration,.2s))var(--tw-ease,ease-out)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none); --animate-collapsible-down: collapsible-down var(--tw-animation-duration,var(--tw-duration,.2s))var(--tw-ease,ease-out)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none); --animate-collapsible-up: collapsible-up var(--tw-animation-duration,var(--tw-duration,.2s))var(--tw-ease,ease-out)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none); @keyframes accordion-down { from { height: 0; }to { height: var(--radix-accordion-content-height,var(--bits-accordion-content-height,var(--reka-accordion-content-height,var(--kb-accordion-content-height,var(--ngp-accordion-content-height,auto))))); }}@keyframes accordion-up { from { height: var(--radix-accordion-content-height,var(--bits-accordion-content-height,var(--reka-accordion-content-height,var(--kb-accordion-content-height,var(--ngp-accordion-content-height,auto))))); }to { height: 0; }}@keyframes collapsible-down { from { height: 0; }to { height: var(--radix-collapsible-content-height,var(--bits-collapsible-content-height,var(--reka-collapsible-content-height,var(--kb-collapsible-content-height,auto)))); }}@keyframes collapsible-up { from { height: var(--radix-collapsible-content-height,var(--bits-collapsible-content-height,var(--reka-collapsible-content-height,var(--kb-collapsible-content-height,auto)))); }to { height: 0; }}--animate-caret-blink: caret-blink 1.25s ease-out infinite; @keyframes caret-blink { 0%,70%,100% { opacity: 1; }20%,50% { opacity: 0; }}}@utility animation-duration-*{--tw-animation-duration: calc(--value(number)*1ms) ; --tw-animation-duration: --value(--animation-duration-*,[duration],"initial",[*]); animation-duration: calc(--value(number)*1ms) ; animation-duration: --value(--animation-duration-*,[duration],"initial",[*]);}@utility delay-*{animation-delay: calc(--value(number)*1ms) ; animation-delay: --value(--animation-delay-*,[duration],"initial",[*]); --tw-animation-delay: calc(--value(number)*1ms) ; --tw-animation-delay: --value(--animation-delay-*,[duration],"initial",[*]);}@utility repeat-*{animation-iteration-count: --value(--animation-repeat-*,number,"initial",[*]); --tw-animation-iteration-count: --value(--animation-repeat-*,number,"initial",[*]);}@utility direction-*{animation-direction: --value(--animation-direction-*,"initial",[*]); --tw-animation-direction: --value(--animation-direction-*,"initial",[*]);}@utility fill-mode-*{animation-fill-mode: --value(--animation-fill-mode-*,"initial",[*]); --tw-animation-fill-mode: --value(--animation-fill-mode-*,"initial",[*]);}@utility running{animation-play-state: running;}@utility paused{animation-play-state: paused;}@utility play-state-*{animation-play-state: --value("initial",[*]);}@utility blur-in{--tw-enter-blur: 20px;}@utility blur-in-*{--tw-enter-blur: calc(--value(number)*1px) ; --tw-enter-blur: --value(--blur-*,[*]);}@utility blur-out{--tw-exit-blur: 20px;}@utility blur-out-*{--tw-exit-blur: calc(--value(number)*1px) ; --tw-exit-blur: --value(--blur-*,[*]);}@utility fade-in{--tw-enter-opacity: 0;}@utility fade-in-*{--tw-enter-opacity: calc(--value(number)*.01) ; --tw-enter-opacity: --value(--percentage-*,[*]);}@utility fade-out{--tw-exit-opacity: 0;}@utility fade-out-*{--tw-exit-opacity: calc(--value(number)*.01) ; --tw-exit-opacity: --value(--percentage-*,[*]);}@utility zoom-in{--tw-enter-scale: 0;}@utility zoom-in-*{--tw-enter-scale: calc(--value(number)*1%) ; --tw-enter-scale: --value(ratio) ; --tw-enter-scale: --value(--percentage-*,[*]);}@utility -zoom-in-*{--tw-enter-scale: calc(--value(number)*-1%) ; --tw-enter-scale: calc(--value(ratio)*-1) ; --tw-enter-scale: --value(--percentage-*,[*]);}@utility zoom-out{--tw-exit-scale: 0;}@utility zoom-out-*{--tw-exit-scale: calc(--value(number)*1%) ; --tw-exit-scale: --value(ratio) ; --tw-exit-scale: --value(--percentage-*,[*]);}@utility -zoom-out-*{--tw-exit-scale: calc(--value(number)*-1%) ; --tw-exit-scale: calc(--value(ratio)*-1) ; --tw-exit-scale: --value(--percentage-*,[*]);}@utility spin-in{--tw-enter-rotate: 30deg;}@utility spin-in-*{--tw-enter-rotate: calc(--value(number)*1deg) ; --tw-enter-rotate: calc(--value(ratio)*360deg) ; --tw-enter-rotate: --value(--rotate-*,[*]);}@utility -spin-in{--tw-enter-rotate: -30deg;}@utility -spin-in-*{--tw-enter-rotate: calc(--value(number)*-1deg) ; --tw-enter-rotate: calc(--value(ratio)*-360deg) ; --tw-enter-rotate: --value(--rotate-*,[*]);}@utility spin-out{--tw-exit-rotate: 30deg;}@utility spin-out-*{--tw-exit-rotate: calc(--value(number)*1deg) ; --tw-exit-rotate: calc(--value(ratio)*360deg) ; --tw-exit-rotate: --value(--rotate-*,[*]);}@utility -spin-out{--tw-exit-rotate: -30deg;}@utility -spin-out-*{--tw-exit-rotate: calc(--value(number)*-1deg) ; --tw-exit-rotate: calc(--value(ratio)*-360deg) ; --tw-exit-rotate: --value(--rotate-*,[*]);}@utility slide-in-from-top{--tw-enter-translate-y: -100%;}@utility slide-in-from-top-*{--tw-enter-translate-y: --spacing(--value(integer)*-1); --tw-enter-translate-y: calc(--value(--percentage-*,--percentage-translate-*)*-100%) ; --tw-enter-translate-y: calc(--value(ratio)*-100%) ; --tw-enter-translate-y: calc(--value(--translate-*,[percentage],[length])*-1) ;}@utility slide-in-from-bottom{--tw-enter-translate-y: 100%;}@utility slide-in-from-bottom-*{--tw-enter-translate-y: --spacing(--value(integer)); --tw-enter-translate-y: calc(--value(--percentage-*,--percentage-translate-*)*100%) ; --tw-enter-translate-y: calc(--value(ratio)*100%) ; --tw-enter-translate-y: --value(--translate-*,[percentage],[length]);}@utility slide-in-from-left{--tw-enter-translate-x: -100%;}@utility slide-in-from-left-*{--tw-enter-translate-x: --spacing(--value(integer)*-1); --tw-enter-translate-x: calc(--value(--percentage-*,--percentage-translate-*)*-100%) ; --tw-enter-translate-x: calc(--value(ratio)*-100%) ; --tw-enter-translate-x: calc(--value(--translate-*,[percentage],[length])*-1) ;}@utility slide-in-from-right{--tw-enter-translate-x: 100%;}@utility slide-in-from-right-*{--tw-enter-translate-x: --spacing(--value(integer)); --tw-enter-translate-x: calc(--value(--percentage-*,--percentage-translate-*)*100%) ; --tw-enter-translate-x: calc(--value(ratio)*100%) ; --tw-enter-translate-x: --value(--translate-*,[percentage],[length]);}@utility slide-in-from-start{&:dir(ltr){ --tw-enter-translate-x: -100%; }&:dir(rtl){ --tw-enter-translate-x: 100%; }}@utility slide-in-from-start-*{&:where(:dir(ltr),[dir="ltr"],[dir="ltr"]*){ --tw-enter-translate-x: --spacing(--value(integer)*-1); --tw-enter-translate-x: calc(--value(--percentage-*,--percentage-translate-*)*-100%) ; --tw-enter-translate-x: calc(--value(ratio)*-100%) ; --tw-enter-translate-x: calc(--value(--translate-*,[percentage],[length])*-1) ; }&:where(:dir(rtl),[dir="rtl"],[dir="rtl"]*){ --tw-enter-translate-x: --spacing(--value(integer)); --tw-enter-translate-x: calc(--value(--percentage-*,--percentage-translate-*)*100%) ; --tw-enter-translate-x: calc(--value(ratio)*100%) ; --tw-enter-translate-x: --value(--translate-*,[percentage],[length]); }}@utility slide-in-from-end{&:dir(ltr){ --tw-enter-translate-x: 100%; }&:dir(rtl){ --tw-enter-translate-x: -100%; }}@utility slide-in-from-end-*{&:where(:dir(ltr),[dir="ltr"],[dir="ltr"]*){ --tw-enter-translate-x: --spacing(--value(integer)); --tw-enter-translate-x: calc(--value(--percentage-*,--percentage-translate-*)*100%) ; --tw-enter-translate-x: calc(--value(ratio)*100%) ; --tw-enter-translate-x: --value(--translate-*,[percentage],[length]); }&:where(:dir(rtl),[dir="rtl"],[dir="rtl"]*){ --tw-enter-translate-x: --spacing(--value(integer)*-1); --tw-enter-translate-x: calc(--value(--percentage-*,--percentage-translate-*)*-100%) ; --tw-enter-translate-x: calc(--value(ratio)*-100%) ; --tw-enter-translate-x: calc(--value(--translate-*,[percentage],[length])*-1) ; }}@utility slide-out-to-top{--tw-exit-translate-y: -100%;}@utility slide-out-to-top-*{--tw-exit-translate-y: --spacing(--value(integer)*-1); --tw-exit-translate-y: calc(--value(--percentage-*,--percentage-translate-*)*-100%) ; --tw-exit-translate-y: calc(--value(ratio)*-100%) ; --tw-exit-translate-y: calc(--value(--translate-*,[percentage],[length])*-1) ;}@utility slide-out-to-bottom{--tw-exit-translate-y: 100%;}@utility slide-out-to-bottom-*{--tw-exit-translate-y: --spacing(--value(integer)); --tw-exit-translate-y: calc(--value(--percentage-*,--percentage-translate-*)*100%) ; --tw-exit-translate-y: calc(--value(ratio)*100%) ; --tw-exit-translate-y: --value(--translate-*,[percentage],[length]);}@utility slide-out-to-left{--tw-exit-translate-x: -100%;}@utility slide-out-to-left-*{--tw-exit-translate-x: --spacing(--value(integer)*-1); --tw-exit-translate-x: calc(--value(--percentage-*,--percentage-translate-*)*-100%) ; --tw-exit-translate-x: calc(--value(ratio)*-100%) ; --tw-exit-translate-x: calc(--value(--translate-*,[percentage],[length])*-1) ;}@utility slide-out-to-right{--tw-exit-translate-x: 100%;}@utility slide-out-to-right-*{--tw-exit-translate-x: --spacing(--value(integer)); --tw-exit-translate-x: calc(--value(--percentage-*,--percentage-translate-*)*100%) ; --tw-exit-translate-x: calc(--value(ratio)*100%) ; --tw-exit-translate-x: --value(--translate-*,[percentage],[length]);}@utility slide-out-to-start{&:dir(ltr){ --tw-exit-translate-x: -100%; }&:dir(rtl){ --tw-exit-translate-x: 100%; }}@utility slide-out-to-start-*{&:where(:dir(ltr),[dir="ltr"],[dir="ltr"]*){ --tw-exit-translate-x: --spacing(--value(integer)*-1); --tw-exit-translate-x: calc(--value(--percentage-*,--percentage-translate-*)*-100%) ; --tw-exit-translate-x: calc(--value(ratio)*-100%) ; --tw-exit-translate-x: calc(--value(--translate-*,[percentage],[length])*-1) ; }&:where(:dir(rtl),[dir="rtl"],[dir="rtl"]*){ --tw-exit-translate-x: --spacing(--value(integer)); --tw-exit-translate-x: calc(--value(--percentage-*,--percentage-translate-*)*100%) ; --tw-exit-translate-x: calc(--value(ratio)*100%) ; --tw-exit-translate-x: --value(--translate-*,[percentage],[length]); }}@utility slide-out-to-end{&:dir(ltr){ --tw-exit-translate-x: 100%; }&:dir(rtl){ --tw-exit-translate-x: -100%; }}@utility slide-out-to-end-*{&:where(:dir(ltr),[dir="ltr"],[dir="ltr"]*){ --tw-exit-translate-x: --spacing(--value(integer)); --tw-exit-translate-x: calc(--value(--percentage-*,--percentage-translate-*)*100%) ; --tw-exit-translate-x: calc(--value(ratio)*100%) ; --tw-exit-translate-x: --value(--translate-*,[percentage],[length]); }&:where(:dir(rtl),[dir="rtl"],[dir="rtl"]*){ --tw-exit-translate-x: --spacing(--value(integer)*-1); --tw-exit-translate-x: calc(--value(--percentage-*,--percentage-translate-*)*-100%) ; --tw-exit-translate-x: calc(--value(ratio)*-100%) ; --tw-exit-translate-x: calc(--value(--translate-*,[percentage],[length])*-1) ; }}@source "../**/*.{js,ts,jsx,tsx}";@custom-variant dark (&:is(.dark *));:root{--font-size: 16px;--background: #ffffff;--foreground: oklch(.145 0 0);--card: #ffffff;--card-foreground: oklch(.145 0 0);--popover: oklch(1 0 0);--popover-foreground: oklch(.145 0 0);--primary: #030213;--primary-foreground: oklch(1 0 0);--secondary: oklch(.95 .0058 264.53);--secondary-foreground: #030213;--muted: #ececf0;--muted-foreground: #717182;--accent: #e9ebef;--accent-foreground: #030213;--destructive: #d4183d;--destructive-foreground: #ffffff;--border: rgba(0, 0, 0, .1);--input: transparent;--input-background: #f3f3f5;--switch-background: #cbced4;--font-weight-medium: 500;--font-weight-normal: 400;--ring: oklch(.708 0 0);--chart-1: oklch(.646 .222 41.116);--chart-2: oklch(.6 .118 184.704);--chart-3: oklch(.398 .07 227.392);--chart-4: oklch(.828 .189 84.429);--chart-5: oklch(.769 .188 70.08);--radius: .625rem;--sidebar: oklch(.985 0 0);--sidebar-foreground: oklch(.145 0 0);--sidebar-primary: #030213;--sidebar-primary-foreground: oklch(.985 0 0);--sidebar-accent: oklch(.97 0 0);--sidebar-accent-foreground: oklch(.205 0 0);--sidebar-border: oklch(.922 0 0);--sidebar-ring: oklch(.708 0 0)}.dark{--background: oklch(.145 0 0);--foreground: oklch(.985 0 0);--card: oklch(.145 0 0);--card-foreground: oklch(.985 0 0);--popover: oklch(.145 0 0);--popover-foreground: oklch(.985 0 0);--primary: oklch(.985 0 0);--primary-foreground: oklch(.205 0 0);--secondary: oklch(.269 0 0);--secondary-foreground: oklch(.985 0 0);--muted: oklch(.269 0 0);--muted-foreground: oklch(.708 0 0);--accent: oklch(.269 0 0);--accent-foreground: oklch(.985 0 0);--destructive: oklch(.396 .141 25.723);--destructive-foreground: oklch(.637 .237 25.331);--border: oklch(.269 0 0);--input: oklch(.269 0 0);--ring: oklch(.439 0 0);--font-weight-medium: 500;--font-weight-normal: 400;--chart-1: oklch(.488 .243 264.376);--chart-2: oklch(.696 .17 162.48);--chart-3: oklch(.769 .188 70.08);--chart-4: oklch(.627 .265 303.9);--chart-5: oklch(.645 .246 16.439);--sidebar: oklch(.205 0 0);--sidebar-foreground: oklch(.985 0 0);--sidebar-primary: oklch(.488 .243 264.376);--sidebar-primary-foreground: oklch(.985 0 0);--sidebar-accent: oklch(.269 0 0);--sidebar-accent-foreground: oklch(.985 0 0);--sidebar-border: oklch(.269 0 0);--sidebar-ring: oklch(.439 0 0)}@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-input-background: var(--input-background); --color-switch-background: var(--switch-background); --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); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); --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); }@layer base{*{@apply border-border outline-ring/50;}body{@apply bg-background text-foreground;}html{font-size:var(--font-size)}h1{font-size:var(--text-2xl);font-weight:var(--font-weight-medium);line-height:1.5}h2{font-size:var(--text-xl);font-weight:var(--font-weight-medium);line-height:1.5}h3{font-size:var(--text-lg);font-weight:var(--font-weight-medium);line-height:1.5}h4,label,button{font-size:var(--text-base);font-weight:var(--font-weight-medium);line-height:1.5}input{font-size:var(--text-base);font-weight:var(--font-weight-normal);line-height:1.5}}
frontend/dist/index.html ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ <!DOCTYPE html>
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="UTF-8" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>שיפור עיצוב עם אנימציה</title>
8
+ <script type="module" crossorigin src="/assets/index-CZMDWpNf.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-DgSDpXn3.css">
10
+ </head>
11
+
12
+ <body>
13
+ <div id="root"></div>
14
+ </body>
15
+ </html>
16
+
frontend/index.html ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ <!DOCTYPE html>
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="UTF-8" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>שיפור עיצוב עם אנימציה</title>
8
+ </head>
9
+
10
+ <body>
11
+ <div id="root"></div>
12
+ <script type="module" src="/src/main.tsx"></script>
13
+ </body>
14
+ </html>
15
+
frontend/package.json ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "@figma/my-make-file",
3
+ "private": true,
4
+ "version": "0.0.1",
5
+ "type": "module",
6
+ "scripts": {
7
+ "build": "vite build",
8
+ "dev": "vite"
9
+ },
10
+ "dependencies": {
11
+ "react": "18.3.1",
12
+ "react-dom": "18.3.1",
13
+ "@emotion/react": "11.14.0",
14
+ "@emotion/styled": "11.14.1",
15
+ "@mui/icons-material": "7.3.5",
16
+ "@mui/material": "7.3.5",
17
+ "@popperjs/core": "2.11.8",
18
+ "@radix-ui/react-accordion": "1.2.3",
19
+ "@radix-ui/react-alert-dialog": "1.1.6",
20
+ "@radix-ui/react-aspect-ratio": "1.1.2",
21
+ "@radix-ui/react-avatar": "1.1.3",
22
+ "@radix-ui/react-checkbox": "1.1.4",
23
+ "@radix-ui/react-collapsible": "1.1.3",
24
+ "@radix-ui/react-context-menu": "2.2.6",
25
+ "@radix-ui/react-dialog": "1.1.6",
26
+ "@radix-ui/react-dropdown-menu": "2.1.6",
27
+ "@radix-ui/react-hover-card": "1.1.6",
28
+ "@radix-ui/react-label": "2.1.2",
29
+ "@radix-ui/react-menubar": "1.1.6",
30
+ "@radix-ui/react-navigation-menu": "1.2.5",
31
+ "@radix-ui/react-popover": "1.1.6",
32
+ "@radix-ui/react-progress": "1.1.2",
33
+ "@radix-ui/react-radio-group": "1.2.3",
34
+ "@radix-ui/react-scroll-area": "1.2.3",
35
+ "@radix-ui/react-select": "2.1.6",
36
+ "@radix-ui/react-separator": "1.1.2",
37
+ "@radix-ui/react-slider": "1.2.3",
38
+ "@radix-ui/react-slot": "1.1.2",
39
+ "@radix-ui/react-switch": "1.1.3",
40
+ "@radix-ui/react-tabs": "1.1.3",
41
+ "@radix-ui/react-toggle-group": "1.1.2",
42
+ "@radix-ui/react-toggle": "1.1.2",
43
+ "@radix-ui/react-tooltip": "1.1.8",
44
+ "canvas-confetti": "1.9.4",
45
+ "class-variance-authority": "0.7.1",
46
+ "clsx": "2.1.1",
47
+ "cmdk": "1.1.1",
48
+ "date-fns": "3.6.0",
49
+ "embla-carousel-react": "8.6.0",
50
+ "input-otp": "1.4.2",
51
+ "lucide-react": "0.487.0",
52
+ "motion": "12.23.24",
53
+ "next-themes": "0.4.6",
54
+ "react-day-picker": "8.10.1",
55
+ "react-dnd": "16.0.1",
56
+ "react-dnd-html5-backend": "16.0.1",
57
+ "react-hook-form": "7.55.0",
58
+ "react-popper": "2.3.0",
59
+ "react-resizable-panels": "2.1.7",
60
+ "react-responsive-masonry": "2.7.1",
61
+ "react-router": "7.13.0",
62
+ "react-slick": "0.31.0",
63
+ "recharts": "2.15.2",
64
+ "sonner": "2.0.3",
65
+ "tailwind-merge": "3.2.0",
66
+ "tw-animate-css": "1.3.8",
67
+ "vaul": "1.1.2"
68
+ },
69
+ "devDependencies": {
70
+ "@tailwindcss/vite": "4.1.12",
71
+ "@vitejs/plugin-react": "4.7.0",
72
+ "tailwindcss": "4.1.12",
73
+ "vite": "6.3.5"
74
+ },
75
+ "pnpm": {
76
+ "overrides": {
77
+ "vite": "6.3.5"
78
+ },
79
+ "onlyBuiltDependencies": ["@tailwindcss/oxide", "esbuild"]
80
+ }
81
+ }
frontend/pnpm-lock.yaml ADDED
The diff for this file is too large to render. See raw diff
 
frontend/postcss.config.mjs ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * PostCSS Configuration
3
+ *
4
+ * Tailwind CSS v4 (via @tailwindcss/vite) automatically sets up all required
5
+ * PostCSS plugins — you do NOT need to include `tailwindcss` or `autoprefixer` here.
6
+ *
7
+ * This file only exists for adding additional PostCSS plugins, if needed.
8
+ * For example:
9
+ *
10
+ * import postcssNested from 'postcss-nested'
11
+ * export default { plugins: [postcssNested()] }
12
+ *
13
+ * Otherwise, you can leave this file empty.
14
+ */
15
+ export default {}
frontend/src/app/App.tsx ADDED
@@ -0,0 +1,333 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useCallback, useEffect } from "react";
2
+ // AlgoScope dashboard — v2 (custom charts, no Recharts)
3
+ import { motion, AnimatePresence } from "motion/react";
4
+ import { Header } from "./components/Header";
5
+ import { Sidebar } from "./components/Sidebar";
6
+ import { OverviewTab } from "./components/OverviewTab";
7
+ import { CoOccurrenceGraph } from "./components/CoOccurrenceGraph";
8
+ import { TermComparisonTab } from "./components/TermComparisonTab";
9
+ import { ExportTab } from "./components/ExportTab";
10
+ import { SplashScreen } from "./components/SplashScreen";
11
+ import {
12
+ apiFetchPosts,
13
+ apiFetchAndAnalyze,
14
+ apiFetchTotal,
15
+ generateMockPosts,
16
+ ALGOSPEAK_TERMS,
17
+ Post,
18
+ } from "./components/mockData";
19
+
20
+ // WHY empty initial state instead of mock data:
21
+ // We fetch real posts from the backend in the useEffect below. Starting with
22
+ // an empty array avoids a flash of mock data before real data arrives.
23
+ const EMPTY_POSTS: Post[] = [];
24
+
25
+ const TABS = [
26
+ { id: "overview", label: "Overview" },
27
+ { id: "graph", label: "Co-occurrence Graph" },
28
+ { id: "compare", label: "Term Comparison" },
29
+ { id: "export", label: "Export" },
30
+ ];
31
+
32
+ export default function App() {
33
+ const [showSplash, setShowSplash] = useState(true);
34
+ const [sidebarOpen, setSidebarOpen] = useState(true);
35
+ const [activeTab, setActiveTab] = useState("overview");
36
+
37
+ // Sidebar state
38
+ const [selectedTerms, setSelectedTerms] = useState(ALGOSPEAK_TERMS.slice(0, 4));
39
+ const [threshold, setThreshold] = useState(0.70);
40
+ const [sampling, setSampling] = useState(25);
41
+ const [autoRefresh, setAutoRefresh] = useState(false);
42
+
43
+ // Data
44
+ const [allPosts, setAllPosts] = useState<Post[]>(EMPTY_POSTS);
45
+ const [batchPosts, setBatchPosts] = useState<Post[]>(EMPTY_POSTS);
46
+ const [fetching, setFetching] = useState(false);
47
+ const [justFetched, setJustFetched] = useState(false);
48
+ const [fetchError, setFetchError] = useState<string | null>(null);
49
+ // WHY separate counter instead of allPosts.length:
50
+ // allPosts is capped at 500 and deduplicates by id, so it can go DOWN
51
+ // when new posts replace old ones. totalAnalyzed is a monotonically
52
+ // increasing sum — the true count of posts ever processed this session.
53
+ const [totalAnalyzed, setTotalAnalyzed] = useState(0);
54
+
55
+ // Graph controls
56
+ const [minCooccurrence, setMinCooccurrence] = useState(3);
57
+ const [toxicOnly, setToxicOnly] = useState(false);
58
+
59
+ // ── Load initial posts from backend on mount ────────────────────────────────
60
+ // WHY useEffect + apiFetchPosts:
61
+ // On mount we call GET /posts to populate the dashboard with whatever the
62
+ // server already has (either seeded posts from cold start, or posts from
63
+ // previous sessions). This replaces the old `generateMockPosts()` call.
64
+ //
65
+ // WHY fallback to mock data on error:
66
+ // If the backend is unavailable (local dev without FastAPI running), we fall
67
+ // back to mock data so the UI is still usable. This is dev-only behaviour;
68
+ // in production the frontend and backend are served from the same process.
69
+ useEffect(() => {
70
+ let cancelled = false;
71
+ async function loadInitial() {
72
+ try {
73
+ const posts = await apiFetchPosts(200);
74
+ if (cancelled) return;
75
+ if (posts.length > 0) {
76
+ setAllPosts(posts);
77
+ setBatchPosts(posts.slice(0, 25));
78
+ // WHY set totalAnalyzed from DB count on mount:
79
+ // The counter starts at 0 but the DB already has posts from previous
80
+ // sessions. Without this, the display jumps DOWN from 200 (DB load)
81
+ // to 25 (first fetch) because totalAnalyzed || totalPosts picks
82
+ // totalPosts on load, then switches to the smaller totalAnalyzed.
83
+ setTotalAnalyzed(posts.length);
84
+ } else {
85
+ // Backend is healthy but DB is empty — show mock data as a placeholder
86
+ const mock = generateMockPosts(ALGOSPEAK_TERMS.slice(0, 4), 30);
87
+ setAllPosts(mock);
88
+ setBatchPosts(mock.slice(0, 25));
89
+ }
90
+ } catch {
91
+ if (cancelled) return;
92
+ // Backend unreachable — fall back to mock data for dev/offline use
93
+ const mock = generateMockPosts(ALGOSPEAK_TERMS.slice(0, 4), 30);
94
+ setAllPosts(mock);
95
+ setBatchPosts(mock.slice(0, 25));
96
+ }
97
+ }
98
+ loadInitial();
99
+ return () => { cancelled = true; };
100
+ }, []);
101
+
102
+ // ── Fetch & Analyze handler ─────────────────────────────────────────────────
103
+ // WHY apiFetchAndAnalyze instead of generateMockPosts:
104
+ // This calls POST /fetch-and-analyze which fetches real Bluesky posts,
105
+ // runs batch DistilBERT inference, saves them to SQLite, and returns results.
106
+ const handleFetch = useCallback(async () => {
107
+ setFetching(true);
108
+ setJustFetched(false);
109
+ setFetchError(null);
110
+ try {
111
+ const { posts: newBatch, message } = await apiFetchAndAnalyze(
112
+ selectedTerms.length ? selectedTerms : ALGOSPEAK_TERMS,
113
+ sampling,
114
+ threshold,
115
+ );
116
+
117
+ if (newBatch.length === 0) {
118
+ setFetchError(message ?? "No posts fetched from Bluesky. Check credentials and try again.");
119
+ setJustFetched(true);
120
+ return;
121
+ }
122
+
123
+ setBatchPosts(newBatch);
124
+ setAllPosts(prev => {
125
+ const idSet = new Set(newBatch.map(p => String(p.id)));
126
+ const filtered = prev.filter(p => !idSet.has(String(p.id)));
127
+ return [...newBatch, ...filtered].slice(0, 500);
128
+ });
129
+ // WHY fetch total from DB instead of computing locally:
130
+ // Any local computation (trulyNew, c => c + n) suffers from stale
131
+ // closures inside useCallback — allPosts captured at creation time is
132
+ // never the latest value, so the counter stops accumulating after the
133
+ // first fetch. The DB is the only source of truth for how many posts
134
+ // have ever been analyzed. One extra GET /posts?limit=1 call (tiny)
135
+ // gives us the exact real count with zero closure risk.
136
+ const dbTotal = await apiFetchTotal();
137
+ if (dbTotal >= 0) setTotalAnalyzed(dbTotal);
138
+ setJustFetched(true);
139
+ } catch (err) {
140
+ const msg = err instanceof Error ? err.message : "Fetch failed";
141
+ setFetchError(msg);
142
+ // Fallback: generate mock data so the UI doesn't go blank
143
+ const mock = generateMockPosts(selectedTerms.length ? selectedTerms : ALGOSPEAK_TERMS, sampling);
144
+ setBatchPosts(mock);
145
+ setAllPosts(prev => [...mock, ...prev].slice(0, 500));
146
+ setJustFetched(true);
147
+ } finally {
148
+ setFetching(false);
149
+ }
150
+ }, [selectedTerms, sampling, threshold]);
151
+
152
+ return (
153
+ <>
154
+ {showSplash && <SplashScreen onDone={() => setShowSplash(false)} />}
155
+ <div
156
+ style={{
157
+ width: "100vw",
158
+ minHeight: "100vh",
159
+ background: "#0a0d14",
160
+ display: "flex",
161
+ flexDirection: "column",
162
+ overflowY: "auto",
163
+ overflowX: "hidden",
164
+ fontFamily: "system-ui, -apple-system, sans-serif",
165
+ color: "#e8eaf0",
166
+ }}
167
+ >
168
+ {/* Header */}
169
+ <Header onToggleSidebar={() => setSidebarOpen(v => !v)} sidebarOpen={sidebarOpen} />
170
+
171
+ {/* Body */}
172
+ <div style={{ flex: 1, display: "flex", overflow: "visible", minHeight: 0 }}>
173
+ {/* Sidebar */}
174
+ <Sidebar
175
+ open={sidebarOpen}
176
+ selectedTerms={selectedTerms}
177
+ setSelectedTerms={setSelectedTerms}
178
+ threshold={threshold}
179
+ setThreshold={setThreshold}
180
+ sampling={sampling}
181
+ setSampling={setSampling}
182
+ autoRefresh={autoRefresh}
183
+ setAutoRefresh={setAutoRefresh}
184
+ onFetch={handleFetch}
185
+ posts={allPosts}
186
+ fetching={fetching}
187
+ />
188
+
189
+ {/* Main content */}
190
+ <div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "visible", minHeight: 0 }}>
191
+ {/* Tabs nav */}
192
+ <div
193
+ style={{
194
+ display: "flex",
195
+ borderBottom: "1px solid #1e2540",
196
+ background: "#0a0d14",
197
+ paddingInline: "1.4rem",
198
+ gap: "0.25rem",
199
+ flexShrink: 0,
200
+ }}
201
+ >
202
+ {TABS.map(tab => {
203
+ const active = tab.id === activeTab;
204
+ return (
205
+ <button
206
+ key={tab.id}
207
+ onClick={() => setActiveTab(tab.id)}
208
+ style={{
209
+ background: "transparent",
210
+ border: "none",
211
+ borderBottom: active ? "2px solid #ff6b3d" : "2px solid transparent",
212
+ color: active ? "#ff6b3d" : "#5a6080",
213
+ fontSize: "0.82rem",
214
+ padding: "0.65rem 0.9rem",
215
+ cursor: "pointer",
216
+ fontWeight: active ? 600 : 400,
217
+ transition: "color 0.2s, border-color 0.2s",
218
+ whiteSpace: "nowrap",
219
+ }}
220
+ onMouseEnter={e => {
221
+ if (!active) (e.currentTarget as HTMLButtonElement).style.color = "#9aa0c0";
222
+ }}
223
+ onMouseLeave={e => {
224
+ if (!active) (e.currentTarget as HTMLButtonElement).style.color = "#5a6080";
225
+ }}
226
+ >
227
+ {tab.label}
228
+ </button>
229
+ );
230
+ })}
231
+
232
+ {/* Status toast — shows fetch success or error */}
233
+ <AnimatePresence>
234
+ {justFetched && !fetchError && (
235
+ <motion.div
236
+ initial={{ opacity: 0, x: 20 }}
237
+ animate={{ opacity: 1, x: 0 }}
238
+ exit={{ opacity: 0, x: 20 }}
239
+ transition={{ duration: 0.3 }}
240
+ style={{
241
+ marginLeft: "auto",
242
+ alignSelf: "center",
243
+ fontSize: "0.72rem",
244
+ padding: "4px 10px",
245
+ borderRadius: 999,
246
+ background: "rgba(46,204,113,0.1)",
247
+ border: "1px solid rgba(46,204,113,0.25)",
248
+ color: "#2ecc71",
249
+ }}
250
+ >
251
+ ✓ Done! Analyzed {batchPosts.length} posts
252
+ </motion.div>
253
+ )}
254
+ {justFetched && fetchError && (
255
+ <motion.div
256
+ initial={{ opacity: 0, x: 20 }}
257
+ animate={{ opacity: 1, x: 0 }}
258
+ exit={{ opacity: 0, x: 20 }}
259
+ transition={{ duration: 0.3 }}
260
+ style={{
261
+ marginLeft: "auto",
262
+ alignSelf: "center",
263
+ fontSize: "0.72rem",
264
+ padding: "4px 10px",
265
+ borderRadius: 999,
266
+ background: "rgba(255,75,75,0.1)",
267
+ border: "1px solid rgba(255,75,75,0.25)",
268
+ color: "#ff6b3d",
269
+ }}
270
+ >
271
+ ⚠ API unavailable — showing mock data
272
+ </motion.div>
273
+ )}
274
+ </AnimatePresence>
275
+ </div>
276
+
277
+ {/* Tab content */}
278
+ <div style={{ flex: 1, overflow: "visible", minHeight: 0 }}>
279
+ <AnimatePresence mode="wait">
280
+ <motion.div
281
+ key={activeTab}
282
+ initial={{ opacity: 0, y: 8 }}
283
+ animate={{ opacity: 1, y: 0 }}
284
+ exit={{ opacity: 0, y: -8 }}
285
+ transition={{ duration: 0.25 }}
286
+ style={{ minHeight: "100%" }}
287
+ >
288
+ {activeTab === "overview" && (
289
+ <OverviewTab
290
+ posts={allPosts}
291
+ batchPosts={batchPosts}
292
+ selectedTerms={selectedTerms}
293
+ justFetched={justFetched}
294
+ totalAnalyzed={totalAnalyzed}
295
+ />
296
+ )}
297
+ {activeTab === "graph" && (
298
+ <CoOccurrenceGraph
299
+ minCooccurrence={minCooccurrence}
300
+ setMinCooccurrence={setMinCooccurrence}
301
+ toxicOnly={toxicOnly}
302
+ setToxicOnly={setToxicOnly}
303
+ />
304
+ )}
305
+ {activeTab === "compare" && (
306
+ <TermComparisonTab posts={allPosts} />
307
+ )}
308
+ {activeTab === "export" && (
309
+ <ExportTab posts={allPosts} />
310
+ )}
311
+ </motion.div>
312
+ </AnimatePresence>
313
+ </div>
314
+ </div>
315
+ </div>
316
+
317
+ <style>{`
318
+ * { box-sizing: border-box; }
319
+ ::-webkit-scrollbar { width: 5px; height: 5px; }
320
+ ::-webkit-scrollbar-track { background: #0d1120; }
321
+ ::-webkit-scrollbar-thumb { background: #1e2540; border-radius: 3px; }
322
+ ::-webkit-scrollbar-thumb:hover { background: #2e3560; }
323
+ input[type=range] { appearance: none; height: 4px; border-radius: 2px; background: #1e2540; outline: none; }
324
+ input[type=range]::-webkit-slider-thumb {
325
+ appearance: none; width: 14px; height: 14px; border-radius: 50%;
326
+ background: #ff4b4b; cursor: pointer; border: 2px solid #0a0d14;
327
+ }
328
+ select option { background: #141826; color: #e8eaf0; }
329
+ `}</style>
330
+ </div>
331
+ </>
332
+ );
333
+ }
frontend/src/app/components/CoOccurrenceGraph.tsx ADDED
@@ -0,0 +1,609 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef, useState, useCallback, useMemo, Component } from "react";
2
+ import { motion } from "motion/react";
3
+ import { nodeColor, apiGetGraphData, GraphNode, GraphEdge } from "./mockData";
4
+
5
+ // ── Types ──────────────────────────────────────────────────────────────────────
6
+ interface SimNode {
7
+ id: string;
8
+ x: number;
9
+ y: number;
10
+ vx: number;
11
+ vy: number;
12
+ frequency: number;
13
+ toxicRatio: number;
14
+ }
15
+
16
+ interface Props {
17
+ minCooccurrence: number;
18
+ setMinCooccurrence: (v: number) => void;
19
+ toxicOnly: boolean;
20
+ setToxicOnly: (v: boolean) => void;
21
+ }
22
+
23
+ // ── Constants ──────────────────────────────────────────────────────────────────
24
+ const W = 820;
25
+ const H = 540;
26
+ // WHY increased REPULSION + reduced SPRING for real data:
27
+ // Mock data had ~6 nodes with low edge weights. Real data has 20-30 nodes
28
+ // where a hub like "unalive" has 17 edges — the combined spring pull
29
+ // overwhelmed repulsion and collapsed the graph into one blob.
30
+ // Higher REPULSION (6000) pushes nodes apart more aggressively,
31
+ // lower SPRING (0.018) reduces the per-edge pull so a hub with 17 edges
32
+ // doesn't dominate. EDGE_LEN increased so nodes have more breathing room.
33
+ const REPULSION = 6000;
34
+ const SPRING = 0.018;
35
+ const EDGE_LEN = 180;
36
+ const GRAVITY = 0.008;
37
+ const DAMPING = 0.82;
38
+ const CENTER_X = W / 2;
39
+ const CENTER_Y = H / 2;
40
+
41
+ // ── Error Boundary ─────────────────────────────────────────────────────────────
42
+ interface EBState { hasError: boolean; error?: string }
43
+ class GraphErrorBoundary extends Component<{ children: React.ReactNode }, EBState> {
44
+ constructor(props: { children: React.ReactNode }) {
45
+ super(props);
46
+ this.state = { hasError: false };
47
+ }
48
+ static getDerivedStateFromError(err: Error): EBState {
49
+ return { hasError: true, error: err.message };
50
+ }
51
+ render() {
52
+ if (this.state.hasError) {
53
+ return (
54
+ <div style={{
55
+ margin: "1.5rem",
56
+ background: "rgba(255,75,75,0.07)",
57
+ border: "1px solid rgba(255,75,75,0.2)",
58
+ borderRadius: 10,
59
+ padding: "1.5rem",
60
+ textAlign: "center",
61
+ }}>
62
+ <div style={{ color: "#ff4b4b", fontSize: "0.9rem", marginBottom: 8 }}>
63
+ ⚠ Graph failed to render
64
+ </div>
65
+ <div style={{ color: "#5a6080", fontSize: "0.75rem" }}>
66
+ {this.state.error || "Unknown error"}
67
+ </div>
68
+ <button
69
+ onClick={() => this.setState({ hasError: false })}
70
+ style={{
71
+ marginTop: 12,
72
+ background: "rgba(255,75,75,0.12)",
73
+ border: "1px solid rgba(255,75,75,0.3)",
74
+ borderRadius: 7,
75
+ color: "#ff6b3d",
76
+ padding: "5px 14px",
77
+ cursor: "pointer",
78
+ fontSize: "0.78rem",
79
+ }}
80
+ >
81
+ Retry
82
+ </button>
83
+ </div>
84
+ );
85
+ }
86
+ return this.props.children;
87
+ }
88
+ }
89
+
90
+ // ── Physics hook ───────────────────────────────────────────────────────────────
91
+ function safeNum(v: number, fallback = 0): number {
92
+ return isFinite(v) && !isNaN(v) ? v : fallback;
93
+ }
94
+
95
+ function useForceSimulation(
96
+ nodeConfigs: GraphNode[],
97
+ edges: GraphEdge[],
98
+ nodeKey: string,
99
+ ) {
100
+ const nodesRef = useRef<SimNode[]>([]);
101
+ const [positions, setPositions] = useState<SimNode[]>([]);
102
+ const rafRef = useRef<number>(0);
103
+ const edgesRef = useRef(edges);
104
+ edgesRef.current = edges;
105
+ const activeRef = useRef(false);
106
+
107
+ const run = useCallback(() => {
108
+ if (!activeRef.current) return;
109
+ const ns = nodesRef.current;
110
+ if (!ns.length) return;
111
+ const es = edgesRef.current;
112
+
113
+ try {
114
+ // Repulsion
115
+ for (let i = 0; i < ns.length; i++) {
116
+ for (let j = i + 1; j < ns.length; j++) {
117
+ const dx = (ns[i].x - ns[j].x) || 0.5;
118
+ const dy = (ns[i].y - ns[j].y) || 0.5;
119
+ const dist2 = Math.max(0.01, dx * dx + dy * dy);
120
+ const dist = Math.sqrt(dist2);
121
+ const force = REPULSION / dist2;
122
+ const fx = safeNum((dx / dist) * force);
123
+ const fy = safeNum((dy / dist) * force);
124
+ ns[i].vx += fx;
125
+ ns[i].vy += fy;
126
+ ns[j].vx -= fx;
127
+ ns[j].vy -= fy;
128
+ }
129
+ }
130
+
131
+ // Spring edges
132
+ for (const e of es) {
133
+ const s = ns.find(n => n.id === e.source);
134
+ const t = ns.find(n => n.id === e.target);
135
+ if (!s || !t) continue;
136
+ const dx = t.x - s.x;
137
+ const dy = t.y - s.y;
138
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1;
139
+ // WHY Math.min cap: with real data edge weights can be 50–500+
140
+ // (co-occurrence count across hundreds of posts). Without capping,
141
+ // naturalLen collapses to ~3px, pulling all nodes into a single blob.
142
+ // Capping at 8 keeps naturalLen in the range 115–140px regardless of
143
+ // how large the real-data weights get.
144
+ const naturalLen = EDGE_LEN / (1 + Math.min(e.weight, 8) * 0.04);
145
+ const force = (dist - naturalLen) * SPRING;
146
+ const fx = safeNum((dx / dist) * force);
147
+ const fy = safeNum((dy / dist) * force);
148
+ s.vx += fx;
149
+ s.vy += fy;
150
+ t.vx -= fx;
151
+ t.vy -= fy;
152
+ }
153
+
154
+ // Gravity + integrate
155
+ for (const n of ns) {
156
+ n.vx = safeNum(n.vx + (CENTER_X - n.x) * GRAVITY);
157
+ n.vy = safeNum(n.vy + (CENTER_Y - n.y) * GRAVITY);
158
+ n.vx *= DAMPING;
159
+ n.vy *= DAMPING;
160
+ n.x = safeNum(n.x + n.vx, CENTER_X);
161
+ n.y = safeNum(n.y + n.vy, CENTER_Y);
162
+ // WHY log scale: linear sizing (freq * 0.25) lets high-frequency common
163
+ // words (e.g. "yeah", "his") grow to 10x the size of algospeak terms,
164
+ // dominating the canvas. Math.log compresses the range so all nodes
165
+ // stay visually comparable. Min 8px, max ~32px regardless of frequency.
166
+ const r = 5 + Math.min(Math.log1p(n.frequency ?? 1) * 1.8, 12);
167
+ n.x = Math.max(r + 40, Math.min(W - r - 40, n.x));
168
+ n.y = Math.max(r + 20, Math.min(H - r - 20, n.y));
169
+ }
170
+
171
+ if (activeRef.current) {
172
+ setPositions(ns.map(n => ({ ...n })));
173
+ const maxV = ns.reduce((mx, n) => Math.max(mx, Math.abs(n.vx) + Math.abs(n.vy)), 0);
174
+ if (maxV > 0.15) {
175
+ rafRef.current = requestAnimationFrame(run);
176
+ }
177
+ }
178
+ } catch {
179
+ activeRef.current = false;
180
+ }
181
+ }, []);
182
+
183
+ useEffect(() => {
184
+ cancelAnimationFrame(rafRef.current);
185
+ activeRef.current = true;
186
+
187
+ nodesRef.current = nodeConfigs.map((n, i) => {
188
+ // WHY circle spread: random init clusters nodes near center, requiring
189
+ // hundreds of ticks to separate. Evenly spreading in a circle means
190
+ // repulsion forces are balanced from tick 1 — graph settles readable.
191
+ const angle = (i / Math.max(nodeConfigs.length, 1)) * 2 * Math.PI;
192
+ const spread = Math.min(W, H) * 0.32;
193
+ return {
194
+ id: n.id,
195
+ frequency: n.frequency,
196
+ toxicRatio: n.toxicRatio,
197
+ x: CENTER_X + Math.cos(angle) * spread,
198
+ y: CENTER_Y + Math.sin(angle) * spread,
199
+ vx: (Math.random() - 0.5) * 1.5,
200
+ vy: (Math.random() - 0.5) * 1.5,
201
+ };
202
+ });
203
+ setPositions(nodesRef.current.map(n => ({ ...n })));
204
+ rafRef.current = requestAnimationFrame(run);
205
+
206
+ return () => {
207
+ activeRef.current = false;
208
+ cancelAnimationFrame(rafRef.current);
209
+ };
210
+ }, [nodeKey, run]);
211
+
212
+ return positions;
213
+ }
214
+
215
+ // ── Edge colour ───────────────────────────────────────────────────────────────
216
+ function edgeColor(sourceRatio: number, targetRatio: number, weight: number): string {
217
+ const avg = ((sourceRatio ?? 0) + (targetRatio ?? 0)) / 2;
218
+ const alpha = Math.min(0.9, 0.3 + weight * 0.05);
219
+ if (avg >= 0.7) return `rgba(255,75,75,${alpha})`;
220
+ if (avg >= 0.4) return `rgba(255,140,66,${alpha})`;
221
+ return `rgba(46,204,113,${alpha})`;
222
+ }
223
+
224
+ // ── Inner graph component ─────────────────────────────────────────────────────
225
+ function GraphCanvas({
226
+ minCooccurrence, toxicOnly, setMinCooccurrence, setToxicOnly,
227
+ }: {
228
+ minCooccurrence: number;
229
+ toxicOnly: boolean;
230
+ setMinCooccurrence: (v: number) => void;
231
+ setToxicOnly: (v: boolean) => void;
232
+ }) {
233
+ const [built, setBuilt] = useState(false);
234
+ const [hoveredNode, setHoveredNode] = useState<string | null>(null);
235
+ const [loading, setLoading] = useState(false);
236
+ const [error, setError] = useState<string | null>(null);
237
+
238
+ // WHY state for nodes/edges instead of hardcoded constants:
239
+ // Previously these were GRAPH_NODES / GRAPH_EDGES imported from mockData.
240
+ // Now they come from GET /graph-data when the user clicks "Build Graph".
241
+ // The physics simulation code is exactly unchanged — it just receives real data.
242
+ const [nodes, setNodes] = useState<GraphNode[]>([]);
243
+ const [edges, setEdges] = useState<GraphEdge[]>([]);
244
+
245
+ // ── Fetch graph data from backend ───────────────────────────────────────────
246
+ const handleBuild = useCallback(async () => {
247
+ setLoading(true);
248
+ setError(null);
249
+ try {
250
+ const data = await apiGetGraphData(minCooccurrence, toxicOnly);
251
+ setNodes(data.nodes);
252
+ setEdges(data.edges);
253
+ setBuilt(true);
254
+ } catch (err) {
255
+ setError(err instanceof Error ? err.message : "Failed to load graph data");
256
+ } finally {
257
+ setLoading(false);
258
+ }
259
+ }, [minCooccurrence, toxicOnly]);
260
+
261
+ const visibleNodes = useMemo(() => {
262
+ return nodes.filter(n => {
263
+ if (toxicOnly && n.toxicRatio < 0.7) return false;
264
+ const edgeCount = edges.filter(
265
+ e => (e.source === n.id || e.target === n.id) && e.weight >= minCooccurrence
266
+ ).length;
267
+ return edgeCount > 0 || minCooccurrence <= 2;
268
+ });
269
+ }, [nodes, edges, toxicOnly, minCooccurrence]);
270
+
271
+ const visibleEdges = useMemo(() => {
272
+ const nodeIds = new Set(visibleNodes.map(n => n.id));
273
+ return edges.filter(
274
+ e => e.weight >= minCooccurrence && nodeIds.has(e.source) && nodeIds.has(e.target)
275
+ );
276
+ }, [visibleNodes, edges, minCooccurrence]);
277
+
278
+ const nodeKey = useMemo(
279
+ () => visibleNodes.map(n => n.id).sort().join(","),
280
+ [visibleNodes]
281
+ );
282
+
283
+ const positions = useForceSimulation(visibleNodes, visibleEdges, nodeKey);
284
+
285
+ const posMap = useMemo(() => {
286
+ const m: Record<string, { x: number; y: number }> = {};
287
+ for (const p of positions) m[p.id] = { x: p.x, y: p.y };
288
+ return m;
289
+ }, [positions]);
290
+
291
+ const nodeMap = useMemo(() => {
292
+ const m: Record<string, GraphNode> = {};
293
+ for (const n of visibleNodes) m[n.id] = n;
294
+ return m;
295
+ }, [visibleNodes]);
296
+
297
+ return (
298
+ <div style={{ padding: "1.2rem 1.4rem" }}>
299
+ {/* Top controls */}
300
+ <div style={{ display: "flex", gap: "1rem", marginBottom: "1rem" }}>
301
+ {/* Info card */}
302
+ <motion.div
303
+ initial={{ opacity: 0, y: 16 }}
304
+ animate={{ opacity: 1, y: 0 }}
305
+ style={{
306
+ flex: 3,
307
+ background: "#0d1120",
308
+ border: "1px solid #1e2540",
309
+ borderRadius: 10,
310
+ padding: "0.75rem 1rem",
311
+ }}
312
+ >
313
+ <div style={{ fontSize: "0.58rem", textTransform: "uppercase", letterSpacing: "1px", color: "#3a4060", marginBottom: 6 }}>
314
+ How to read this graph
315
+ </div>
316
+ <div style={{ fontSize: "0.78rem", color: "#8a90ad" }}>
317
+ Words that frequently appear together in algospeak posts are connected. Node size = frequency &nbsp;|&nbsp;
318
+ <span style={{ color: "#ff4b4b" }}>red &gt;70% toxic</span>
319
+ {" "}<span style={{ color: "#ff9f43" }}>orange 40-70% mixed</span>
320
+ {" "}<span style={{ color: "#2ecc71" }}>green &lt;40% benign</span>
321
+ </div>
322
+ </motion.div>
323
+
324
+ {/* Controls */}
325
+ <motion.div
326
+ initial={{ opacity: 0, y: 16 }}
327
+ animate={{ opacity: 1, y: 0 }}
328
+ transition={{ delay: 0.05 }}
329
+ style={{
330
+ flex: 1,
331
+ background: "#0d1120",
332
+ border: "1px solid #1e2540",
333
+ borderRadius: 10,
334
+ padding: "0.75rem 1rem",
335
+ display: "flex",
336
+ flexDirection: "column",
337
+ gap: 8,
338
+ }}
339
+ >
340
+ <div>
341
+ <div style={{ fontSize: "0.62rem", color: "#5a6080", marginBottom: 3 }}>
342
+ Min co-occurrences
343
+ </div>
344
+ <div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 2 }}>
345
+ <span style={{ fontSize: "0.72rem", color: "#ff6b3d", fontWeight: 700 }}>{minCooccurrence}</span>
346
+ </div>
347
+ <input
348
+ type="range"
349
+ min={2} max={10} step={1}
350
+ value={minCooccurrence}
351
+ onChange={e => {
352
+ setMinCooccurrence(parseInt(e.target.value));
353
+ // Reset graph so user re-clicks Build Graph with new params
354
+ setBuilt(false);
355
+ setNodes([]);
356
+ setEdges([]);
357
+ }}
358
+ style={{ width: "100%", accentColor: "#ff4b4b" }}
359
+ />
360
+ </div>
361
+ <label style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer" }}>
362
+ <input
363
+ type="checkbox"
364
+ checked={toxicOnly}
365
+ onChange={e => {
366
+ setToxicOnly(e.target.checked);
367
+ setBuilt(false);
368
+ setNodes([]);
369
+ setEdges([]);
370
+ }}
371
+ style={{ accentColor: "#ff4b4b" }}
372
+ />
373
+ <span style={{ fontSize: "0.75rem", color: "#8a90ad" }}>Toxic posts only</span>
374
+ </label>
375
+ <motion.button
376
+ onClick={handleBuild}
377
+ disabled={loading}
378
+ whileHover={{ scale: loading ? 1 : 1.02 }}
379
+ whileTap={{ scale: loading ? 1 : 0.97 }}
380
+ style={{
381
+ background: built
382
+ ? "linear-gradient(135deg, #2ecc71, #27ae60)"
383
+ : "linear-gradient(135deg, #ff4b4b, #ff8c42)",
384
+ color: "#fff",
385
+ border: "none",
386
+ borderRadius: 8,
387
+ padding: "7px 0",
388
+ fontWeight: 700,
389
+ fontSize: "0.82rem",
390
+ cursor: loading ? "wait" : "pointer",
391
+ opacity: loading ? 0.7 : 1,
392
+ boxShadow: built
393
+ ? "0 0 14px rgba(46,204,113,0.25)"
394
+ : "0 0 14px rgba(255,75,75,0.25)",
395
+ }}
396
+ >
397
+ {loading ? "Loading…" : built ? "✓ Graph Active" : "Build Graph"}
398
+ </motion.button>
399
+ </motion.div>
400
+ </div>
401
+
402
+ {/* Error state */}
403
+ {error && (
404
+ <div style={{
405
+ background: "rgba(255,75,75,0.07)",
406
+ border: "1px solid rgba(255,75,75,0.2)",
407
+ borderRadius: 10,
408
+ padding: "1rem",
409
+ color: "#ff6b3d",
410
+ fontSize: "0.8rem",
411
+ marginBottom: "1rem",
412
+ }}>
413
+ ⚠ {error}
414
+ </div>
415
+ )}
416
+
417
+ {/* Graph canvas */}
418
+ {!built ? (
419
+ <motion.div
420
+ initial={{ opacity: 0 }}
421
+ animate={{ opacity: 1 }}
422
+ style={{
423
+ background: "#0d1120",
424
+ border: "1px solid #1e2540",
425
+ borderRadius: 10,
426
+ padding: "3rem",
427
+ textAlign: "center",
428
+ color: "#5a6080",
429
+ fontSize: "0.85rem",
430
+ }}
431
+ >
432
+ Adjust settings above and click &quot;Build Graph&quot; to visualize word co-occurrences.
433
+ </motion.div>
434
+ ) : (
435
+ <motion.div
436
+ initial={{ opacity: 0 }}
437
+ animate={{ opacity: 1 }}
438
+ transition={{ duration: 0.5 }}
439
+ style={{
440
+ background: "#080c18",
441
+ border: "1px solid #1e2540",
442
+ borderRadius: 10,
443
+ overflow: "hidden",
444
+ position: "relative",
445
+ maxHeight: "62vh",
446
+ }}
447
+ >
448
+ <svg
449
+ width="100%"
450
+ viewBox={`0 0 ${W} ${H}`}
451
+ preserveAspectRatio="xMidYMid meet"
452
+ style={{ display: "block", width: "100%", height: "auto", maxHeight: "62vh" }}
453
+ >
454
+ {/* Background grid */}
455
+ <defs>
456
+ <pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
457
+ <path d="M 40 0 L 0 0 0 40" fill="none" stroke="#1a1f35" strokeWidth="0.5" />
458
+ </pattern>
459
+ </defs>
460
+ <rect width={W} height={H} fill="url(#grid)" opacity={0.5} />
461
+
462
+ {/* Edges */}
463
+ {visibleEdges.map(e => {
464
+ const s = posMap[e.source];
465
+ const t = posMap[e.target];
466
+ if (!s || !t) return null;
467
+ const sNode = nodeMap[e.source];
468
+ const tNode = nodeMap[e.target];
469
+ if (!sNode || !tNode) return null;
470
+ const isHighlighted = hoveredNode === e.source || hoveredNode === e.target;
471
+ return (
472
+ <line
473
+ key={`${e.source}--${e.target}`}
474
+ x1={s.x} y1={s.y} x2={t.x} y2={t.y}
475
+ stroke={edgeColor(sNode.toxicRatio, tNode.toxicRatio, e.weight)}
476
+ strokeWidth={0.5 + Math.min(Math.log1p(e.weight) * 0.5, 2.5)}
477
+ opacity={hoveredNode ? (isHighlighted ? 0.9 : 0.1) : 0.7}
478
+ style={{ transition: "opacity 0.2s" }}
479
+ />
480
+ );
481
+ })}
482
+
483
+ {/* Nodes */}
484
+ {positions.map(node => {
485
+ const freq = node.frequency ?? 1;
486
+ const tRatio = node.toxicRatio ?? 0.5;
487
+ // WHY log1p: same formula as the physics loop so the rendered
488
+ // circle matches the collision radius used for simulation.
489
+ const size = 5 + Math.min(Math.log1p(freq) * 1.8, 12);
490
+ const color = nodeColor(tRatio);
491
+ const isHovered = hoveredNode === node.id;
492
+ const isDimmed = !!(hoveredNode && !isHovered);
493
+ const r = isHovered ? size * 1.25 : size;
494
+
495
+ if (!isFinite(node.x) || !isFinite(node.y)) return null;
496
+
497
+ return (
498
+ <g
499
+ key={node.id}
500
+ onMouseEnter={() => setHoveredNode(node.id)}
501
+ onMouseLeave={() => setHoveredNode(null)}
502
+ style={{ cursor: "pointer" }}
503
+ >
504
+ <circle
505
+ cx={node.x} cy={node.y}
506
+ r={r + 6}
507
+ fill={color}
508
+ opacity={isHovered ? 0.2 : 0}
509
+ style={{ transition: "opacity 0.2s" }}
510
+ />
511
+ <circle
512
+ cx={node.x} cy={node.y}
513
+ r={r}
514
+ fill={color}
515
+ fillOpacity={isDimmed ? 0.2 : 0.85}
516
+ stroke={color}
517
+ strokeWidth={isHovered ? 2.5 : 1.5}
518
+ strokeOpacity={isDimmed ? 0.2 : 0.6}
519
+ style={{ transition: "opacity 0.2s" }}
520
+ />
521
+ <text
522
+ x={node.x}
523
+ y={node.y + r + 12}
524
+ textAnchor="middle"
525
+ fontSize={isHovered ? 11 : 9.5}
526
+ fill={isDimmed ? "#3a4060" : "#c8cce0"}
527
+ fontFamily="system-ui, sans-serif"
528
+ style={{ userSelect: "none", pointerEvents: "none", transition: "opacity 0.2s" }}
529
+ opacity={isDimmed ? 0.2 : 1}
530
+ >
531
+ {node.id}
532
+ </text>
533
+ </g>
534
+ );
535
+ })}
536
+ </svg>
537
+
538
+ {/* Hover tooltip */}
539
+ {hoveredNode && (() => {
540
+ const n = visibleNodes.find(x => x.id === hoveredNode);
541
+ if (!n) return null;
542
+ const color = nodeColor(n.toxicRatio);
543
+ const connections = visibleEdges.filter(
544
+ e => e.source === n.id || e.target === n.id
545
+ ).length;
546
+ return (
547
+ <motion.div
548
+ key={hoveredNode}
549
+ initial={{ opacity: 0, scale: 0.9 }}
550
+ animate={{ opacity: 1, scale: 1 }}
551
+ style={{
552
+ position: "absolute",
553
+ top: 12, right: 12,
554
+ background: "#0d1120",
555
+ border: `1px solid ${color}44`,
556
+ borderRadius: 9,
557
+ padding: "0.6rem 0.9rem",
558
+ minWidth: 150,
559
+ boxShadow: `0 0 20px ${color}22`,
560
+ }}
561
+ >
562
+ <div style={{ fontWeight: 700, color: "#e8eaf0", fontSize: "0.88rem", marginBottom: 6 }}>
563
+ {n.id}
564
+ </div>
565
+ <div style={{ fontSize: "0.72rem", color: "#8a90ad", marginBottom: 2 }}>
566
+ Frequency: <span style={{ color: "#e8eaf0" }}>{n.frequency}</span>
567
+ </div>
568
+ <div style={{ fontSize: "0.72rem", color: "#8a90ad", marginBottom: 2 }}>
569
+ Toxic ratio:{" "}
570
+ <span style={{ color, fontWeight: 700 }}>
571
+ {(n.toxicRatio * 100).toFixed(0)}%
572
+ </span>
573
+ </div>
574
+ <div style={{ fontSize: "0.72rem", color: "#8a90ad" }}>
575
+ Connections: <span style={{ color: "#e8eaf0" }}>{connections}</span>
576
+ </div>
577
+ <div style={{ marginTop: 6, background: "#1a1f35", borderRadius: 4, height: 4, overflow: "hidden" }}>
578
+ <div
579
+ style={{
580
+ width: `${n.toxicRatio * 100}%`,
581
+ height: "100%",
582
+ background: color,
583
+ borderRadius: 4,
584
+ transition: "width 0.3s",
585
+ }}
586
+ />
587
+ </div>
588
+ </motion.div>
589
+ );
590
+ })()}
591
+ </motion.div>
592
+ )}
593
+ </div>
594
+ );
595
+ }
596
+
597
+ // ── Public component ───────────────────────────────────────────────────────────
598
+ export function CoOccurrenceGraph({ minCooccurrence, setMinCooccurrence, toxicOnly, setToxicOnly }: Props) {
599
+ return (
600
+ <GraphErrorBoundary>
601
+ <GraphCanvas
602
+ minCooccurrence={minCooccurrence}
603
+ setMinCooccurrence={setMinCooccurrence}
604
+ toxicOnly={toxicOnly}
605
+ setToxicOnly={setToxicOnly}
606
+ />
607
+ </GraphErrorBoundary>
608
+ );
609
+ }
frontend/src/app/components/ExportTab.tsx ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { motion } from "motion/react";
3
+ import { Download, FileText, FileJson, CheckCircle } from "lucide-react";
4
+ import { Post } from "./mockData";
5
+
6
+ interface Props {
7
+ posts: Post[];
8
+ }
9
+
10
+ function downloadJSON(data: unknown, filename: string) {
11
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
12
+ const url = URL.createObjectURL(blob);
13
+ const a = document.createElement("a");
14
+ a.href = url;
15
+ a.download = filename;
16
+ a.click();
17
+ URL.revokeObjectURL(url);
18
+ }
19
+
20
+ function downloadCSV(posts: Post[], filename: string) {
21
+ const header = "id,text,score,label,query_term,created_at";
22
+ const rows = posts.map(p =>
23
+ [p.id, `"${p.text.replace(/"/g, '""')}"`, p.score, p.label, p.query_term, p.created_at].join(",")
24
+ );
25
+ const blob = new Blob([[header, ...rows].join("\n")], { type: "text/csv" });
26
+ const url = URL.createObjectURL(blob);
27
+ const a = document.createElement("a");
28
+ a.href = url;
29
+ a.download = filename;
30
+ a.click();
31
+ URL.revokeObjectURL(url);
32
+ }
33
+
34
+ function ExportCard({
35
+ icon,
36
+ title,
37
+ description,
38
+ buttonLabel,
39
+ color,
40
+ onClick,
41
+ delay,
42
+ }: {
43
+ icon: React.ReactNode;
44
+ title: string;
45
+ description: string;
46
+ buttonLabel: string;
47
+ color: string;
48
+ onClick: () => void;
49
+ delay: number;
50
+ }) {
51
+ const [clicked, setClicked] = useState(false);
52
+
53
+ const handleClick = () => {
54
+ onClick();
55
+ setClicked(true);
56
+ setTimeout(() => setClicked(false), 2000);
57
+ };
58
+
59
+ return (
60
+ <motion.div
61
+ initial={{ opacity: 0, y: 18 }}
62
+ animate={{ opacity: 1, y: 0 }}
63
+ transition={{ delay, duration: 0.45 }}
64
+ style={{
65
+ background: "#0d1120",
66
+ border: "1px solid #1e2540",
67
+ borderRadius: 10,
68
+ padding: "1.1rem 1.2rem",
69
+ display: "flex",
70
+ alignItems: "center",
71
+ gap: "1rem",
72
+ }}
73
+ >
74
+ <div
75
+ style={{
76
+ width: 42,
77
+ height: 42,
78
+ borderRadius: 10,
79
+ background: `${color}18`,
80
+ border: `1px solid ${color}44`,
81
+ display: "flex",
82
+ alignItems: "center",
83
+ justifyContent: "center",
84
+ flexShrink: 0,
85
+ color,
86
+ }}
87
+ >
88
+ {icon}
89
+ </div>
90
+ <div style={{ flex: 1, minWidth: 0 }}>
91
+ <div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#e8eaf0", marginBottom: 3 }}>{title}</div>
92
+ <div style={{ fontSize: "0.73rem", color: "#5a6080" }}>{description}</div>
93
+ </div>
94
+ <motion.button
95
+ onClick={handleClick}
96
+ whileHover={{ scale: 1.04 }}
97
+ whileTap={{ scale: 0.96 }}
98
+ style={{
99
+ background: clicked ? "rgba(46,204,113,0.12)" : `${color}18`,
100
+ border: `1px solid ${clicked ? "#2ecc71" : color}55`,
101
+ borderRadius: 8,
102
+ color: clicked ? "#2ecc71" : color,
103
+ padding: "6px 14px",
104
+ fontSize: "0.78rem",
105
+ fontWeight: 600,
106
+ cursor: "pointer",
107
+ display: "flex",
108
+ alignItems: "center",
109
+ gap: 5,
110
+ whiteSpace: "nowrap",
111
+ transition: "all 0.2s",
112
+ }}
113
+ >
114
+ {clicked ? <CheckCircle size={14} /> : <Download size={14} />}
115
+ {clicked ? "Downloaded!" : buttonLabel}
116
+ </motion.button>
117
+ </motion.div>
118
+ );
119
+ }
120
+
121
+ export function ExportTab({ posts }: Props) {
122
+ const toxicPosts = posts.filter(p => p.label === "toxic");
123
+ const nonToxicPosts = posts.filter(p => p.label === "non-toxic");
124
+
125
+ const summaryData = {
126
+ exported_at: new Date().toISOString(),
127
+ total_posts: posts.length,
128
+ toxic_count: toxicPosts.length,
129
+ non_toxic_count: nonToxicPosts.length,
130
+ toxic_rate: posts.length ? ((toxicPosts.length / posts.length) * 100).toFixed(2) + "%" : "0%",
131
+ avg_score: posts.length
132
+ ? (posts.reduce((s, p) => s + p.score, 0) / posts.length).toFixed(4)
133
+ : "0",
134
+ posts,
135
+ };
136
+
137
+ return (
138
+ <div style={{ padding: "1.2rem 1.4rem", display: "flex", flexDirection: "column", gap: "0.9rem" }}>
139
+ <motion.div
140
+ initial={{ opacity: 0, y: 10 }}
141
+ animate={{ opacity: 1, y: 0 }}
142
+ style={{ color: "#5a6080", fontSize: "0.82rem" }}
143
+ >
144
+ Export your analyzed data for further research or archiving.
145
+ </motion.div>
146
+
147
+ {/* Stats summary */}
148
+ <motion.div
149
+ initial={{ opacity: 0, y: 14 }}
150
+ animate={{ opacity: 1, y: 0 }}
151
+ transition={{ delay: 0.05 }}
152
+ style={{
153
+ background: "#0d1120",
154
+ border: "1px solid #1e2540",
155
+ borderRadius: 10,
156
+ padding: "0.9rem 1rem",
157
+ display: "flex",
158
+ gap: "2rem",
159
+ flexWrap: "wrap",
160
+ }}
161
+ >
162
+ {[
163
+ { label: "Total posts", value: String(posts.length), color: "#fff" },
164
+ { label: "Toxic", value: String(toxicPosts.length), color: "#ff4b4b" },
165
+ { label: "Non-toxic", value: String(nonToxicPosts.length), color: "#2ecc71" },
166
+ {
167
+ label: "Toxic rate",
168
+ value: posts.length ? `${((toxicPosts.length / posts.length) * 100).toFixed(1)}%` : "0%",
169
+ color: "#ff8c42",
170
+ },
171
+ ].map(({ label, value, color }) => (
172
+ <div key={label}>
173
+ <div style={{ fontSize: "0.58rem", textTransform: "uppercase", letterSpacing: "1px", color: "#3a4060", marginBottom: 4 }}>
174
+ {label}
175
+ </div>
176
+ <div style={{ fontSize: "1.3rem", fontWeight: 700, color }}>{value}</div>
177
+ </div>
178
+ ))}
179
+ </motion.div>
180
+
181
+ {/* Export options */}
182
+ <ExportCard
183
+ icon={<FileJson size={20} />}
184
+ title="Full dataset (JSON)"
185
+ description={`Export all ${posts.length} analyzed posts with scores, labels, and metadata`}
186
+ buttonLabel="Export JSON"
187
+ color="#a6b0ff"
188
+ onClick={() => downloadJSON(summaryData, "algoscope-export.json")}
189
+ delay={0.1}
190
+ />
191
+
192
+ <ExportCard
193
+ icon={<FileText size={20} />}
194
+ title="All posts (CSV)"
195
+ description={`${posts.length} rows · id, text, score, label, query_term, created_at`}
196
+ buttonLabel="Export CSV"
197
+ color="#ff8c42"
198
+ onClick={() => downloadCSV(posts, "algoscope-posts.csv")}
199
+ delay={0.18}
200
+ />
201
+
202
+ <ExportCard
203
+ icon={<FileText size={20} />}
204
+ title="Toxic posts only (CSV)"
205
+ description={`${toxicPosts.length} rows · filtered to toxic label (score ≥ threshold)`}
206
+ buttonLabel="Export CSV"
207
+ color="#ff4b4b"
208
+ onClick={() => downloadCSV(toxicPosts, "algoscope-toxic.csv")}
209
+ delay={0.26}
210
+ />
211
+
212
+ <ExportCard
213
+ icon={<FileJson size={20} />}
214
+ title="Summary statistics (JSON)"
215
+ description="Aggregate metrics: counts, rates, avg scores per term"
216
+ buttonLabel="Export JSON"
217
+ color="#2ecc71"
218
+ onClick={() => {
219
+ const termStats: Record<string, unknown> = {};
220
+ const terms = [...new Set(posts.map(p => p.query_term))];
221
+ for (const t of terms) {
222
+ const tp = posts.filter(p => p.query_term === t);
223
+ const toxicN = tp.filter(p => p.label === "toxic").length;
224
+ termStats[t] = {
225
+ count: tp.length,
226
+ toxic_count: toxicN,
227
+ toxic_rate: `${((toxicN / tp.length) * 100).toFixed(1)}%`,
228
+ avg_score: (tp.reduce((s, p) => s + p.score, 0) / tp.length).toFixed(4),
229
+ };
230
+ }
231
+ downloadJSON({
232
+ exported_at: new Date().toISOString(),
233
+ total: posts.length,
234
+ toxic_rate: `${((toxicPosts.length / posts.length) * 100).toFixed(1)}%`,
235
+ per_term: termStats,
236
+ }, "algoscope-summary.json");
237
+ }}
238
+ delay={0.34}
239
+ />
240
+ </div>
241
+ );
242
+ }
frontend/src/app/components/Header.tsx ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { motion } from "motion/react";
2
+
3
+ interface HeaderProps {
4
+ onToggleSidebar: () => void;
5
+ sidebarOpen: boolean;
6
+ }
7
+
8
+ export function Header({ onToggleSidebar, sidebarOpen }: HeaderProps) {
9
+ return (
10
+ <motion.div
11
+ initial={{ opacity: 0, y: -12 }}
12
+ animate={{ opacity: 1, y: 0 }}
13
+ transition={{ duration: 0.4 }}
14
+ style={{
15
+ display: "flex",
16
+ justifyContent: "space-between",
17
+ alignItems: "center",
18
+ padding: "0.6rem 1.4rem 0.7rem",
19
+ borderBottom: "1px solid #1e2540",
20
+ background: "#0a0d14",
21
+ position: "sticky",
22
+ top: 0,
23
+ zIndex: 10,
24
+ }}
25
+ >
26
+ <div style={{ display: "flex", alignItems: "center", gap: "0.9rem" }}>
27
+ {/* Sidebar toggle */}
28
+ <button
29
+ onClick={onToggleSidebar}
30
+ style={{
31
+ width: 32,
32
+ height: 32,
33
+ background: "#141826",
34
+ border: "1px solid #1e2540",
35
+ borderRadius: 7,
36
+ color: "#e8eaf0",
37
+ fontSize: 16,
38
+ cursor: "pointer",
39
+ display: "flex",
40
+ alignItems: "center",
41
+ justifyContent: "center",
42
+ flexShrink: 0,
43
+ transition: "border-color 0.2s, color 0.2s",
44
+ }}
45
+ onMouseEnter={e => {
46
+ (e.currentTarget as HTMLButtonElement).style.borderColor = "#ff6b3d";
47
+ (e.currentTarget as HTMLButtonElement).style.color = "#ff6b3d";
48
+ }}
49
+ onMouseLeave={e => {
50
+ (e.currentTarget as HTMLButtonElement).style.borderColor = "#1e2540";
51
+ (e.currentTarget as HTMLButtonElement).style.color = "#e8eaf0";
52
+ }}
53
+ >
54
+
55
+ </button>
56
+
57
+ {/* Logo */}
58
+ <motion.div
59
+ whileHover={{ scale: 1.05 }}
60
+ style={{
61
+ width: 36,
62
+ height: 36,
63
+ borderRadius: 9,
64
+ background: "linear-gradient(135deg, #ff4b4b, #ff8c42)",
65
+ display: "flex",
66
+ alignItems: "center",
67
+ justifyContent: "center",
68
+ fontWeight: 800,
69
+ color: "#fff",
70
+ fontSize: "1.15rem",
71
+ boxShadow: "0 0 18px rgba(255,76,76,0.45)",
72
+ flexShrink: 0,
73
+ }}
74
+ >
75
+ A
76
+ </motion.div>
77
+
78
+ {/* Title */}
79
+ <div>
80
+ <div style={{ fontSize: "1.55rem", fontWeight: 800, color: "#fff", lineHeight: 1.1 }}>
81
+ AlgoScope
82
+ </div>
83
+ <div style={{ fontSize: "0.78rem", color: "#9aa0c0" }}>
84
+ Real-time algospeak &amp; toxicity intelligence on Bluesky
85
+ </div>
86
+ </div>
87
+ </div>
88
+
89
+ {/* Right side */}
90
+ <div style={{ textAlign: "right" }}>
91
+ <div
92
+ style={{
93
+ display: "inline-flex",
94
+ alignItems: "center",
95
+ gap: "0.35rem",
96
+ padding: "0.2rem 0.65rem",
97
+ borderRadius: 999,
98
+ background: "rgba(46,204,113,0.08)",
99
+ border: "1px solid rgba(46,204,113,0.22)",
100
+ marginBottom: "0.25rem",
101
+ }}
102
+ >
103
+ <span
104
+ style={{
105
+ width: 7,
106
+ height: 7,
107
+ borderRadius: "50%",
108
+ background: "#2ecc71",
109
+ boxShadow: "0 0 6px rgba(46,204,113,0.9)",
110
+ display: "inline-block",
111
+ animation: "pulse-dot 1.5s infinite",
112
+ }}
113
+ />
114
+ <span style={{ color: "#2ecc71", fontWeight: 700, letterSpacing: 1, fontSize: "0.68rem" }}>
115
+ LIVE
116
+ </span>
117
+ </div>
118
+ <div style={{ fontSize: "0.73rem", color: "#6f7695" }}>
119
+ by Odeliya Charitonova
120
+ <br />
121
+ <a
122
+ href="https://github.com/odeliyach/Algoscope"
123
+ target="_blank"
124
+ rel="noopener noreferrer"
125
+ style={{ color: "#a6b0ff", textDecoration: "none" }}
126
+ >
127
+ github.com/odeliyach/Algoscope
128
+ </a>
129
+ </div>
130
+ </div>
131
+
132
+ <style>{`
133
+ @keyframes pulse-dot {
134
+ 0%, 100% { opacity: 1; }
135
+ 50% { opacity: 0.25; }
136
+ }
137
+ `}</style>
138
+ </motion.div>
139
+ );
140
+ }
frontend/src/app/components/OverviewTab.tsx ADDED
@@ -0,0 +1,687 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useMemo, useEffect, useRef, useState } from "react";
2
+ import { motion, AnimatePresence } from "motion/react";
3
+ import { Post } from "./mockData";
4
+
5
+ interface OverviewTabProps {
6
+ posts: Post[];
7
+ batchPosts: Post[];
8
+ selectedTerms: string[];
9
+ justFetched: boolean;
10
+ totalAnalyzed: number;
11
+ }
12
+
13
+ // ── Animated counter hook ──────────────────────────────────────────────────────
14
+ function useAnimatedNumber(target: number, duration = 900): number {
15
+ const [current, setCurrent] = useState(0);
16
+ const startRef = useRef<number | null>(null);
17
+ const fromRef = useRef(0);
18
+ const rafRef = useRef<number>(0);
19
+
20
+ useEffect(() => {
21
+ const from = fromRef.current;
22
+ startRef.current = null;
23
+ const tick = (ts: number) => {
24
+ if (startRef.current === null) startRef.current = ts;
25
+ const progress = Math.min((ts - startRef.current) / duration, 1);
26
+ const ease = 1 - Math.pow(1 - progress, 3);
27
+ setCurrent(Math.round(from + (target - from) * ease));
28
+ if (progress < 1) rafRef.current = requestAnimationFrame(tick);
29
+ else fromRef.current = target;
30
+ };
31
+ rafRef.current = requestAnimationFrame(tick);
32
+ return () => cancelAnimationFrame(rafRef.current);
33
+ }, [target, duration]);
34
+
35
+ return current;
36
+ }
37
+
38
+ function useAnimatedFloat(target: number, duration = 900, decimals = 1): string {
39
+ const [current, setCurrent] = useState(0);
40
+ const startRef = useRef<number | null>(null);
41
+ const fromRef = useRef(0);
42
+ const rafRef = useRef<number>(0);
43
+
44
+ useEffect(() => {
45
+ const from = fromRef.current;
46
+ startRef.current = null;
47
+ const tick = (ts: number) => {
48
+ if (startRef.current === null) startRef.current = ts;
49
+ const progress = Math.min((ts - startRef.current) / duration, 1);
50
+ const ease = 1 - Math.pow(1 - progress, 3);
51
+ setCurrent(from + (target - from) * ease);
52
+ if (progress < 1) rafRef.current = requestAnimationFrame(tick);
53
+ else fromRef.current = target;
54
+ };
55
+ rafRef.current = requestAnimationFrame(tick);
56
+ return () => cancelAnimationFrame(rafRef.current);
57
+ }, [target, duration]);
58
+
59
+ return current.toFixed(decimals);
60
+ }
61
+
62
+ // ── Metric card ────────────────────────────────────────────────────────────────
63
+ function MetricCard({
64
+ label, displayValue, sub, subIcon, subColor, subBg, valueColor, delay, isAlert,
65
+ }: {
66
+ label: string;
67
+ displayValue?: string;
68
+ sub: string;
69
+ subIcon?: string;
70
+ subColor?: string;
71
+ subBg?: string;
72
+ valueColor: string;
73
+ delay: number;
74
+ isAlert?: boolean;
75
+ }) {
76
+ return (
77
+ <motion.div
78
+ initial={{ opacity: 0, y: 24 }}
79
+ animate={{ opacity: 1, y: 0 }}
80
+ transition={{ delay, duration: 0.45, ease: "easeOut" }}
81
+ style={{
82
+ background: isAlert ? "rgba(255,75,75,0.06)" : "#0d1120",
83
+ border: isAlert ? "1px solid rgba(255,75,75,0.3)" : "1px solid #1e2540",
84
+ borderRadius: 10,
85
+ padding: "0.8rem 1rem",
86
+ flex: 1,
87
+ minWidth: 0,
88
+ position: "relative",
89
+ overflow: "hidden",
90
+ }}
91
+ >
92
+ {isAlert && (
93
+ <motion.div
94
+ animate={{ opacity: [0.4, 1, 0.4] }}
95
+ transition={{ duration: 1.5, repeat: Infinity }}
96
+ style={{
97
+ position: "absolute", top: 0, left: 0,
98
+ width: "100%", height: 2,
99
+ background: "linear-gradient(90deg, transparent, #ff4b4b, transparent)",
100
+ }}
101
+ />
102
+ )}
103
+ <div style={{ fontSize: "0.58rem", textTransform: "uppercase", letterSpacing: "1px", color: "#3a4060", marginBottom: "0.35rem" }}>
104
+ {label}
105
+ </div>
106
+ <div style={{ fontSize: "1.45rem", fontWeight: 700, color: valueColor, lineHeight: 1.1, fontVariantNumeric: "tabular-nums" }}>
107
+ {displayValue}
108
+ </div>
109
+ {/* Styled sub badge */}
110
+ <motion.div
111
+ initial={{ opacity: 0, x: -6 }}
112
+ animate={{ opacity: 1, x: 0 }}
113
+ transition={{ delay: delay + 0.25, duration: 0.35 }}
114
+ style={{
115
+ marginTop: "0.4rem",
116
+ display: "inline-flex",
117
+ alignItems: "center",
118
+ gap: 4,
119
+ padding: "2px 7px 2px 5px",
120
+ borderRadius: 999,
121
+ background: subBg ?? "rgba(90,96,128,0.12)",
122
+ border: `1px solid ${subColor ? subColor + "30" : "#2a3050"}`,
123
+ maxWidth: "100%",
124
+ }}
125
+ >
126
+ {subIcon && (
127
+ <span style={{ fontSize: "0.65rem", flexShrink: 0 }}>{subIcon}</span>
128
+ )}
129
+ <span style={{
130
+ fontSize: "0.65rem",
131
+ color: subColor ?? "#6a7090",
132
+ whiteSpace: "nowrap",
133
+ overflow: "hidden",
134
+ textOverflow: "ellipsis",
135
+ fontWeight: subColor ? 600 : 400,
136
+ }}>
137
+ {sub}
138
+ </span>
139
+ </motion.div>
140
+ </motion.div>
141
+ );
142
+ }
143
+
144
+ // ── Spike alert banner ─────────────────────────────────────────────────────────
145
+ function SpikeAlert({ batchToxicRate, batchCount }: { batchToxicRate: number; batchCount: number }) {
146
+ const [dismissed, setDismissed] = useState(false);
147
+ useEffect(() => { setDismissed(false); }, [Math.floor(batchToxicRate)]);
148
+ if (batchToxicRate < 38 || batchCount === 0 || dismissed) return null;
149
+
150
+ const severity = batchToxicRate >= 70 ? "critical" : batchToxicRate >= 55 ? "high" : "elevated";
151
+ const severityColor = severity === "critical" ? "#ff4b4b" : severity === "high" ? "#ff6b3d" : "#ff9f43";
152
+ const severityBg = severity === "critical" ? "rgba(255,75,75,0.08)" : severity === "high" ? "rgba(255,107,61,0.08)" : "rgba(255,159,67,0.08)";
153
+ const severityBorder = severity === "critical" ? "rgba(255,75,75,0.35)" : severity === "high" ? "rgba(255,107,61,0.3)" : "rgba(255,159,67,0.28)";
154
+
155
+ return (
156
+ <AnimatePresence>
157
+ <motion.div
158
+ initial={{ opacity: 0, y: -12, height: 0 }}
159
+ animate={{ opacity: 1, y: 0, height: "auto" }}
160
+ exit={{ opacity: 0, y: -12, height: 0 }}
161
+ transition={{ duration: 0.4 }}
162
+ style={{
163
+ background: severityBg,
164
+ border: `1px solid ${severityBorder}`,
165
+ borderRadius: 9, padding: "0.7rem 1rem",
166
+ display: "flex", alignItems: "center", gap: "0.75rem",
167
+ overflow: "hidden",
168
+ position: "relative",
169
+ }}
170
+ >
171
+ {/* Animated scan line */}
172
+ <motion.div
173
+ animate={{ x: ["-100%", "400%"] }}
174
+ transition={{ duration: 2.5, repeat: Infinity, ease: "linear", repeatDelay: 1 }}
175
+ style={{
176
+ position: "absolute", top: 0, left: 0,
177
+ width: "30%", height: "100%",
178
+ background: `linear-gradient(90deg, transparent, ${severityColor}10, transparent)`,
179
+ pointerEvents: "none",
180
+ }}
181
+ />
182
+ {/* Pulsing dot */}
183
+ <motion.div
184
+ animate={{ scale: [1, 1.5, 1], opacity: [1, 0.4, 1] }}
185
+ transition={{ duration: 1.1, repeat: Infinity }}
186
+ style={{
187
+ width: 10, height: 10, borderRadius: "50%",
188
+ background: severityColor,
189
+ boxShadow: `0 0 12px ${severityColor}cc`,
190
+ flexShrink: 0,
191
+ }}
192
+ />
193
+ <div style={{ flex: 1 }}>
194
+ <span style={{ color: severityColor, fontWeight: 700, fontSize: "0.82rem" }}>
195
+ ⚠ Toxicity Spike Detected
196
+ </span>
197
+ <span style={{ color: "#9a8060", fontSize: "0.78rem", marginLeft: 8 }}>
198
+ {batchToxicRate.toFixed(1)}% of last {batchCount} posts flagged as toxic
199
+ </span>
200
+ <div style={{ marginTop: 4, display: "flex", alignItems: "center", gap: 6 }}>
201
+ <div style={{ flex: 1, height: 3, background: "#1a1d2e", borderRadius: 2, overflow: "hidden" }}>
202
+ <motion.div
203
+ initial={{ width: 0 }}
204
+ animate={{ width: `${Math.min(batchToxicRate, 100)}%` }}
205
+ transition={{ duration: 0.8, ease: "easeOut" }}
206
+ style={{ height: "100%", background: `linear-gradient(90deg, ${severityColor}88, ${severityColor})`, borderRadius: 2 }}
207
+ />
208
+ </div>
209
+ <span style={{ fontSize: "0.65rem", color: severityColor, fontWeight: 700, minWidth: 30 }}>
210
+ {severity.toUpperCase()}
211
+ </span>
212
+ </div>
213
+ </div>
214
+ <button
215
+ onClick={() => setDismissed(true)}
216
+ style={{ background: "none", border: "none", color: "#5a4040", cursor: "pointer", fontSize: "1rem", padding: "0 4px", flexShrink: 0 }}
217
+ >✕</button>
218
+ </motion.div>
219
+ </AnimatePresence>
220
+ );
221
+ }
222
+
223
+ // ── Custom SVG area chart (no Recharts) ────────────────────────────────────────
224
+ function CustomAreaChart({ data }: { data: { hour: string; value: number | null }[] }) {
225
+ const containerRef = useRef<HTMLDivElement>(null);
226
+ const [containerWidth, setContainerWidth] = useState(400);
227
+
228
+ useEffect(() => {
229
+ if (!containerRef.current) return;
230
+ const obs = new ResizeObserver(entries => {
231
+ for (const e of entries) setContainerWidth(e.contentRect.width);
232
+ });
233
+ obs.observe(containerRef.current);
234
+ setContainerWidth(containerRef.current.clientWidth);
235
+ return () => obs.disconnect();
236
+ }, []);
237
+
238
+ const W = containerWidth;
239
+ const H = 170;
240
+ const PAD = { top: 12, right: 12, bottom: 28, left: 36 };
241
+ const inner = { w: W - PAD.left - PAD.right, h: H - PAD.top - PAD.bottom };
242
+
243
+ // Fill null with 0 so the line is always continuous
244
+ const filled = data.map(d => ({ ...d, value: d.value ?? 0 }));
245
+
246
+ const vals = filled.map(d => d.value) as number[];
247
+ const minV = 0;
248
+ const maxV = Math.max(...vals, 0.01);
249
+ const rangeV = maxV - minV || 0.001;
250
+
251
+ const [tooltip, setTooltip] = useState<{ x: number; y: number; hour: string; val: number } | null>(null);
252
+
253
+ const toX = (i: number) => PAD.left + (i / (filled.length - 1)) * inner.w;
254
+ const toY = (v: number) => PAD.top + inner.h - ((v - minV) / rangeV) * inner.h;
255
+
256
+ const linePath = filled.map((d, i) => {
257
+ const x = toX(i); const y = toY(d.value);
258
+ return `${i === 0 ? "M" : "L"} ${x},${y}`;
259
+ }).join(" ");
260
+
261
+ const areaPath = `${linePath} L ${toX(filled.length - 1)},${PAD.top + inner.h} L ${toX(0)},${PAD.top + inner.h} Z`;
262
+
263
+ const yTicks = [0, 0.25, 0.5, 0.75, 1.0];
264
+ const xLabels = filled.filter((_, i) => i % 4 === 0);
265
+
266
+ return (
267
+ <div ref={containerRef} style={{ position: "relative", width: "100%" }}>
268
+ <svg
269
+ viewBox={`0 0 ${W} ${H}`}
270
+ width={W}
271
+ height={H}
272
+ style={{ display: "block", overflow: "visible", width: "100%" }}
273
+ onMouseLeave={() => setTooltip(null)}
274
+ >
275
+ <defs>
276
+ <linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
277
+ <stop offset="0%" stopColor="#ff4b4b" stopOpacity={0.25} />
278
+ <stop offset="100%" stopColor="#ff4b4b" stopOpacity={0.02} />
279
+ </linearGradient>
280
+ <filter id="glow">
281
+ <feGaussianBlur stdDeviation="2" result="coloredBlur" />
282
+ <feMerge><feMergeNode in="coloredBlur" /><feMergeNode in="SourceGraphic" /></feMerge>
283
+ </filter>
284
+ </defs>
285
+
286
+ {/* Grid lines */}
287
+ {yTicks.map(t => {
288
+ const y = PAD.top + inner.h - (t / 1) * inner.h;
289
+ return (
290
+ <g key={`grid-y-${t}`}>
291
+ <line x1={PAD.left} y1={y} x2={PAD.left + inner.w} y2={y} stroke="#1e2540" strokeDasharray="4 4" strokeOpacity={0.7} />
292
+ <text x={PAD.left - 6} y={y + 4} textAnchor="end" fill="#3a4060" fontSize={9}>{t.toFixed(1)}</text>
293
+ </g>
294
+ );
295
+ })}
296
+
297
+ {/* X labels */}
298
+ {xLabels.map(d => {
299
+ const i = filled.indexOf(d);
300
+ return (
301
+ <text key={`xlabel-${d.hour}`} x={toX(i)} y={H - 6} textAnchor="middle" fill="#3a4060" fontSize={9}>{d.hour}</text>
302
+ );
303
+ })}
304
+
305
+ {/* Area fill */}
306
+ <path d={areaPath} fill="url(#areaGrad)" strokeWidth={0} />
307
+
308
+ {/* Line */}
309
+ <path d={linePath} fill="none" stroke="#ff4b4b" strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" filter="url(#glow)" />
310
+
311
+ {/* Interactive dots — only at hours with real data */}
312
+ {data.map((d, i) => {
313
+ if (d.value === null || d.value === 0) return null;
314
+ const x = toX(i); const y = toY(d.value);
315
+ const c = d.value >= 0.7 ? "#ff4b4b" : d.value >= 0.4 ? "#ff8c42" : "#2ecc71";
316
+ return (
317
+ <circle
318
+ key={`dot-${d.hour}`}
319
+ cx={x} cy={y} r={3.5}
320
+ fill={c} stroke="#0a0d14" strokeWidth={1.5}
321
+ style={{ cursor: "pointer" }}
322
+ onMouseEnter={() => setTooltip({ x, y, hour: d.hour, val: d.value! })}
323
+ />
324
+ );
325
+ })}
326
+
327
+ {/* Tooltip */}
328
+ {tooltip && (
329
+ <g>
330
+ <line x1={tooltip.x} y1={PAD.top} x2={tooltip.x} y2={PAD.top + inner.h} stroke="#ff4b4b" strokeWidth={1} strokeDasharray="3 3" strokeOpacity={0.4} />
331
+ <rect x={tooltip.x - 40} y={tooltip.y - 40} width={80} height={32} rx={6} fill="#141826" stroke="#2a3050" strokeWidth={1} />
332
+ <text x={tooltip.x} y={tooltip.y - 26} textAnchor="middle" fill="#8a90ad" fontSize={9}>{tooltip.hour}</text>
333
+ <text x={tooltip.x} y={tooltip.y - 13} textAnchor="middle" fill="#ff6b6b" fontSize={11} fontWeight="bold">{tooltip.val.toFixed(3)}</text>
334
+ </g>
335
+ )}
336
+ </svg>
337
+ </div>
338
+ );
339
+ }
340
+
341
+ // ── Cool Score Distribution Chart ─────────────────────────────────────────────
342
+ function CustomScoreBars({ data }: { data: { label: string; count: number; color: string }[] }) {
343
+ const maxCount = Math.max(...data.map(d => d.count), 1);
344
+ const [hovered, setHovered] = useState<number | null>(null);
345
+ const containerRef = useRef<HTMLDivElement>(null);
346
+
347
+ const GRADIENTS: [string, string][] = [
348
+ ["#4ade80", "#22c55e"],
349
+ ["#a3e635", "#84cc16"],
350
+ ["#facc15", "#eab308"],
351
+ ["#fb923c", "#f97316"],
352
+ ["#f87171", "#ef4444"],
353
+ ];
354
+
355
+ return (
356
+ <div style={{ position: "relative" }}>
357
+ {/* Y grid lines */}
358
+ <div style={{ position: "relative", height: 160, display: "flex", alignItems: "flex-end", gap: 6, padding: "0 2px" }}>
359
+ {/* Grid lines overlay */}
360
+ {[0.25, 0.5, 0.75, 1].map((t, gi) => (
361
+ <div key={gi} style={{
362
+ position: "absolute",
363
+ bottom: `${t * 100}%`,
364
+ left: 0, right: 0,
365
+ height: 1,
366
+ background: "rgba(30,37,64,0.7)",
367
+ pointerEvents: "none",
368
+ }} />
369
+ ))}
370
+
371
+ {data.map((d, i) => {
372
+ const heightPct = (d.count / maxCount) * 100;
373
+ const isHov = hovered === i;
374
+ const [colorTop, colorBot] = GRADIENTS[i];
375
+ return (
376
+ <div
377
+ key={`score-bar-${d.label}`}
378
+ style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", height: "100%", justifyContent: "flex-end", position: "relative" }}
379
+ onMouseEnter={() => setHovered(i)}
380
+ onMouseLeave={() => setHovered(null)}
381
+ >
382
+ {/* Tooltip */}
383
+ {isHov && (
384
+ <motion.div
385
+ initial={{ opacity: 0, y: 4 }}
386
+ animate={{ opacity: 1, y: 0 }}
387
+ style={{
388
+ position: "absolute",
389
+ bottom: `calc(${heightPct}% + 10px)`,
390
+ left: "50%", transform: "translateX(-50%)",
391
+ background: "#141826",
392
+ border: `1px solid ${colorTop}55`,
393
+ borderRadius: 6,
394
+ padding: "3px 8px",
395
+ fontSize: 10,
396
+ color: colorTop,
397
+ whiteSpace: "nowrap",
398
+ zIndex: 10,
399
+ fontWeight: 700,
400
+ boxShadow: `0 0 8px ${colorTop}33`,
401
+ }}
402
+ >
403
+ {d.count} posts
404
+ </motion.div>
405
+ )}
406
+
407
+ {/* Bar */}
408
+ <motion.div
409
+ initial={{ height: 0 }}
410
+ animate={{ height: `${heightPct}%` }}
411
+ transition={{ delay: 0.3 + i * 0.07, duration: 0.6, ease: [0.34, 1.2, 0.64, 1] }}
412
+ style={{
413
+ width: "100%",
414
+ background: `linear-gradient(180deg, ${colorTop} 0%, ${colorBot} 100%)`,
415
+ borderRadius: "5px 5px 0 0",
416
+ minHeight: d.count > 0 ? 4 : 0,
417
+ opacity: hovered === null || isHov ? 1 : 0.4,
418
+ transition: "opacity 0.2s",
419
+ boxShadow: isHov ? `0 0 16px ${colorTop}80, 0 0 6px ${colorTop}40` : "none",
420
+ position: "relative",
421
+ }}
422
+ >
423
+ {/* Shine on top */}
424
+ <div style={{
425
+ position: "absolute",
426
+ top: 0, left: 0, right: 0,
427
+ height: 3,
428
+ background: `linear-gradient(90deg, transparent, ${colorTop}cc, transparent)`,
429
+ borderRadius: "5px 5px 0 0",
430
+ }} />
431
+ </motion.div>
432
+ </div>
433
+ );
434
+ })}
435
+ </div>
436
+
437
+ {/* Baseline */}
438
+ <div style={{ height: 1, background: "#1e2540", margin: "0 2px" }} />
439
+
440
+ {/* X labels */}
441
+ <div style={{ display: "flex", gap: 6, padding: "5px 2px 0", marginTop: 2 }}>
442
+ {data.map((d, i) => (
443
+ <div key={i} style={{
444
+ flex: 1,
445
+ fontSize: 8,
446
+ color: hovered === i ? GRADIENTS[i][0] : "#4a5070",
447
+ textAlign: "center",
448
+ transition: "color 0.2s",
449
+ lineHeight: 1.3,
450
+ }}>
451
+ {d.label}
452
+ </div>
453
+ ))}
454
+ </div>
455
+ </div>
456
+ );
457
+ }
458
+
459
+ const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
460
+
461
+ export function OverviewTab({ posts, batchPosts, selectedTerms, justFetched, totalAnalyzed }: OverviewTabProps) {
462
+ const totalPosts = posts.length;
463
+ const toxicCount = posts.filter(p => p.label === "toxic").length;
464
+ const toxicRate = totalPosts ? (toxicCount / totalPosts) * 100 : 0;
465
+
466
+ // Batch-specific spike detection
467
+ const batchToxicCount = batchPosts.filter(p => p.label === "toxic").length;
468
+ const batchToxicRate = batchPosts.length ? (batchToxicCount / batchPosts.length) * 100 : 0;
469
+
470
+ const avgScoreRaw = batchPosts.length
471
+ ? batchPosts.reduce((s, p) => s + p.score, 0) / batchPosts.length
472
+ : 0;
473
+
474
+ const termCounts: Record<string, number> = {};
475
+ for (const p of batchPosts) {
476
+ termCounts[p.query_term] = (termCounts[p.query_term] || 0) + 1;
477
+ }
478
+ const topTerm = Object.keys(termCounts).sort((a, b) => termCounts[b] - termCounts[a])[0] || "—";
479
+
480
+ // WHY totalAnalyzed directly (no || totalPosts fallback):
481
+ // The old fallback `totalAnalyzed || totalPosts` caused the counter to show
482
+ // totalPosts (200) on load, then jump DOWN to totalAnalyzed (25) after the
483
+ // first fetch. Now App.tsx seeds totalAnalyzed from the DB count on mount,
484
+ // so the fallback is no longer needed and only caused confusion.
485
+ const animatedTotal = useAnimatedNumber(totalAnalyzed);
486
+ const animatedToxicRate = useAnimatedFloat(toxicRate, 900, 1);
487
+ const animatedAvg = useAnimatedFloat(avgScoreRaw, 900, 3);
488
+
489
+ const timeData = useMemo(() => {
490
+ const buckets: Record<number, { sum: number; count: number }> = {};
491
+ for (const p of posts) {
492
+ const h = new Date(p.created_at).getUTCHours();
493
+ if (!buckets[h]) buckets[h] = { sum: 0, count: 0 };
494
+ buckets[h].sum += p.score;
495
+ buckets[h].count += 1;
496
+ }
497
+ return Array.from({ length: 24 }, (_, h) => ({
498
+ hour: `${String(h).padStart(2, "0")}:00`,
499
+ value: buckets[h] ? +(buckets[h].sum / buckets[h].count).toFixed(3) : null,
500
+ }));
501
+ }, [posts]);
502
+
503
+ const scoreDistData = useMemo(() => {
504
+ const bins = [0, 0, 0, 0, 0];
505
+ for (const p of batchPosts) {
506
+ const idx = Math.min(4, Math.floor(p.score / 0.2));
507
+ bins[idx]++;
508
+ }
509
+ const COLORS = ["#2ecc71", "#a3e635", "#ffeb3b", "#ff8c42", "#ff4b4b"];
510
+ const LABELS = ["0-0.2", "0.2-0.4", "0.4-0.6", "0.6-0.8", "0.8-1.0"];
511
+ return LABELS.map((label, i) => ({ label, count: bins[i], color: COLORS[i] }));
512
+ }, [batchPosts]);
513
+
514
+ const heatmapData = useMemo(() => {
515
+ const grid: Record<string, { sum: number; count: number }> = {};
516
+ for (const p of posts) {
517
+ const d = new Date(p.created_at);
518
+ const day = (d.getDay() + 6) % 7;
519
+ const hour = d.getHours();
520
+ const key = `${day}-${hour}`;
521
+ if (!grid[key]) grid[key] = { sum: 0, count: 0 };
522
+ grid[key].sum += p.score;
523
+ grid[key].count += 1;
524
+ }
525
+ return grid;
526
+ }, [posts]);
527
+
528
+ const maxHeat = useMemo(() => {
529
+ let mx = 0;
530
+ for (const v of Object.values(heatmapData)) {
531
+ mx = Math.max(mx, v.count ? v.sum / v.count : 0);
532
+ }
533
+ return mx || 1;
534
+ }, [heatmapData]);
535
+
536
+ const recentPosts = batchPosts.slice(0, 20);
537
+
538
+ return (
539
+ <div style={{ padding: "1.2rem 1.4rem", display: "flex", flexDirection: "column", gap: "1.1rem" }}>
540
+
541
+ <SpikeAlert batchToxicRate={batchToxicRate} batchCount={batchPosts.length} />
542
+
543
+ {/* Metric cards */}
544
+ <div style={{ display: "flex", gap: "0.9rem" }}>
545
+ <MetricCard
546
+ label="Posts analyzed"
547
+ displayValue={String(animatedTotal)}
548
+ sub={`+${batchPosts.length} this batch`}
549
+ subIcon="📥"
550
+ subColor="#4ade80"
551
+ subBg="rgba(74,222,128,0.08)"
552
+ valueColor="#fff"
553
+ delay={0}
554
+ />
555
+ <MetricCard
556
+ label="Toxic rate"
557
+ displayValue={`${animatedToxicRate}%`}
558
+ sub={toxicRate >= 38 ? "↑ High — monitor closely" : "✓ Within expected range"}
559
+ subIcon={toxicRate >= 38 ? "🔴" : undefined}
560
+ subColor={toxicRate >= 38 ? "#ff4b4b" : "#2ecc71"}
561
+ subBg={toxicRate >= 38 ? "rgba(255,75,75,0.1)" : "rgba(46,204,113,0.08)"}
562
+ valueColor="#ff4b4b"
563
+ delay={0.07}
564
+ isAlert={toxicRate >= 38}
565
+ />
566
+ <MetricCard
567
+ label="Avg score (last batch)"
568
+ displayValue={animatedAvg}
569
+ sub="mean toxicity · last fetch"
570
+ subIcon="📊"
571
+ subColor="#ff8c42"
572
+ subBg="rgba(255,140,66,0.08)"
573
+ valueColor="#ff8c42"
574
+ delay={0.14}
575
+ />
576
+ <MetricCard
577
+ label="Top term"
578
+ displayValue={topTerm}
579
+ sub="most frequent · last batch"
580
+ subIcon="🏷️"
581
+ subColor="#9b7fd4"
582
+ subBg="rgba(155,127,212,0.1)"
583
+ valueColor="#9b7fd4"
584
+ delay={0.21}
585
+ />
586
+ </div>
587
+
588
+ {/* Charts row */}
589
+ <div style={{ display: "flex", gap: "0.9rem" }}>
590
+ <motion.div
591
+ initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.28, duration: 0.45 }}
592
+ style={{ flex: 3, background: "#0d1120", border: "1px solid #1e2540", borderRadius: 10, padding: "0.9rem 1rem" }}
593
+ >
594
+ <div style={{ fontSize: "0.95rem", fontWeight: 700, color: "#e8eaf0", marginBottom: "0.7rem" }}>Toxicity over time</div>
595
+ <CustomAreaChart data={timeData} />
596
+ </motion.div>
597
+
598
+ <motion.div
599
+ initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.35, duration: 0.45 }}
600
+ style={{ flex: 1, background: "#0d1120", border: "1px solid #1e2540", borderRadius: 10, padding: "0.9rem 1rem" }}
601
+ >
602
+ <div style={{ fontSize: "0.95rem", fontWeight: 700, color: "#e8eaf0", marginBottom: "0.7rem" }}>Score distribution</div>
603
+ <CustomScoreBars data={scoreDistData} />
604
+ </motion.div>
605
+ </div>
606
+
607
+ {/* Activity heatmap */}
608
+ <motion.div
609
+ initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.42, duration: 0.45 }}
610
+ style={{ background: "#0d1120", border: "1px solid #1e2540", borderRadius: 10, padding: "0.9rem 1rem" }}
611
+ >
612
+ <div style={{ fontSize: "0.95rem", fontWeight: 700, color: "#e8eaf0", marginBottom: "0.8rem" }}>Activity heatmap</div>
613
+ <div style={{ overflowX: "auto" }}>
614
+ <div style={{ display: "flex", alignItems: "center", gap: 2, marginBottom: 4 }}>
615
+ <div style={{ width: 36 }} />
616
+ {Array.from({ length: 24 }, (_, h) => (
617
+ <div key={`hlabel-${h}`} style={{ width: 20, fontSize: "0.52rem", color: "#3a4060", textAlign: "center", userSelect: "none" }}>
618
+ {h % 6 === 0 ? h : ""}
619
+ </div>
620
+ ))}
621
+ </div>
622
+ {DAYS.map((day, d) => (
623
+ <div key={`day-${day}`} style={{ display: "flex", alignItems: "center", gap: 2, marginBottom: 2 }}>
624
+ <div style={{ width: 36, fontSize: "0.65rem", color: "#5a6080", textAlign: "right", paddingRight: 6 }}>{day}</div>
625
+ {Array.from({ length: 24 }, (_, h) => {
626
+ const cell = heatmapData[`${d}-${h}`];
627
+ const intensity = cell ? (cell.sum / cell.count) / maxHeat : 0;
628
+ const hasData = cell && cell.count > 0;
629
+ const r = hasData ? Math.round(30 + (255 - 30) * intensity) : 26;
630
+ const g = hasData ? Math.round(37 + (75 - 37) * intensity) : 29;
631
+ const b = hasData ? Math.round(64 + (75 - 64) * intensity) : 46;
632
+ return (
633
+ <motion.div
634
+ key={`cell-${day}-${h}`}
635
+ initial={{ opacity: 0, scale: 0.5 }}
636
+ animate={{ opacity: 1, scale: 1 }}
637
+ transition={{ delay: (d * 24 + h) * 0.002, duration: 0.25 }}
638
+ title={hasData ? `${day} ${String(h).padStart(2, "0")}:00 — avg ${(cell.sum / cell.count).toFixed(2)}` : `${day} ${String(h).padStart(2, "0")}:00 — no data`}
639
+ style={{ width: 20, height: 20, borderRadius: 3, background: `rgb(${r},${g},${b})`, cursor: hasData ? "pointer" : "default" }}
640
+ />
641
+ );
642
+ })}
643
+ </div>
644
+ ))}
645
+ <div style={{ marginTop: 8, display: "flex", alignItems: "center", gap: 7, fontSize: "0.65rem", color: "#5a6080" }}>
646
+ <span>Low</span>
647
+ <div style={{ width: 90, height: 7, borderRadius: 4, background: "linear-gradient(90deg, #1a1d2e, #ff4b4b)" }} />
648
+ <span>High toxicity</span>
649
+ </div>
650
+ </div>
651
+ </motion.div>
652
+
653
+ {/* Recent posts */}
654
+ <motion.div
655
+ initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.5, duration: 0.45 }}
656
+ style={{ background: "#0d1120", border: "1px solid #1e2540", borderRadius: 10, padding: "0.9rem 1rem" }}
657
+ >
658
+ <div style={{ fontSize: "0.95rem", fontWeight: 700, color: "#e8eaf0", marginBottom: "0.6rem" }}>Recent posts (last batch)</div>
659
+ {recentPosts.length === 0 ? (
660
+ <div style={{ color: "#5a6080", fontSize: "0.8rem", padding: "1rem 0" }}>Click &quot;Fetch &amp; Analyze&quot; to see recent posts.</div>
661
+ ) : (
662
+ <div style={{ maxHeight: 300, overflowY: "auto", display: "flex", flexDirection: "column" }}>
663
+ {recentPosts.map((p, i) => {
664
+ const sc = p.score >= 0.7 ? "#ff4b4b" : p.score >= 0.4 ? "#ff9f43" : "#2ecc71";
665
+ const scBg = p.score >= 0.7 ? "rgba(255,75,75,0.12)" : p.score >= 0.4 ? "rgba(255,159,67,0.12)" : "rgba(46,204,113,0.12)";
666
+ return (
667
+ <motion.div
668
+ key={p.id}
669
+ initial={{ opacity: 0, x: -10 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: i * 0.03 }}
670
+ style={{ display: "flex", alignItems: "center", gap: "0.6rem", padding: "0.55rem 0.3rem", borderBottom: "1px solid #141826" }}
671
+ >
672
+ <div style={{ minWidth: 50, textAlign: "center", fontSize: "0.7rem", fontWeight: 700, borderRadius: 6, padding: "0.2rem 0.3rem", background: scBg, color: sc, flexShrink: 0 }}>
673
+ {p.score.toFixed(3)}
674
+ </div>
675
+ <div style={{ flex: 1, fontSize: "0.77rem", color: "#c8cce0" }}>{p.text.slice(0, 110)}</div>
676
+ <div style={{ fontSize: "0.65rem", padding: "0.1rem 0.4rem", borderRadius: 999, background: "rgba(155,127,212,0.12)", color: "#c3a6ff", border: "1px solid rgba(155,127,212,0.4)", whiteSpace: "nowrap", flexShrink: 0 }}>
677
+ {p.query_term}
678
+ </div>
679
+ </motion.div>
680
+ );
681
+ })}
682
+ </div>
683
+ )}
684
+ </motion.div>
685
+ </div>
686
+ );
687
+ }
frontend/src/app/components/Sidebar.tsx ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { motion, AnimatePresence } from "motion/react";
3
+ import { X } from "lucide-react";
4
+ import { ALGOSPEAK_TERMS, Post, nodeColor } from "./mockData";
5
+
6
+ interface SidebarProps {
7
+ open: boolean;
8
+ selectedTerms: string[];
9
+ setSelectedTerms: (terms: string[]) => void;
10
+ threshold: number;
11
+ setThreshold: (v: number) => void;
12
+ sampling: number;
13
+ setSampling: (v: number) => void;
14
+ autoRefresh: boolean;
15
+ setAutoRefresh: (v: boolean) => void;
16
+ onFetch: () => void;
17
+ posts: Post[];
18
+ fetching: boolean;
19
+ }
20
+
21
+ function SectionLabel({ children }: { children: React.ReactNode }) {
22
+ return (
23
+ <div style={{
24
+ fontSize: "0.58rem",
25
+ textTransform: "uppercase",
26
+ letterSpacing: "1.4px",
27
+ color: "#3a4060",
28
+ marginTop: "1rem",
29
+ marginBottom: "0.35rem",
30
+ }}>
31
+ {children}
32
+ </div>
33
+ );
34
+ }
35
+
36
+ export function Sidebar({
37
+ open,
38
+ selectedTerms,
39
+ setSelectedTerms,
40
+ threshold,
41
+ setThreshold,
42
+ sampling,
43
+ setSampling,
44
+ autoRefresh,
45
+ setAutoRefresh,
46
+ onFetch,
47
+ posts,
48
+ fetching,
49
+ }: SidebarProps) {
50
+ const [customInput, setCustomInput] = useState("");
51
+ const [customTerms, setCustomTerms] = useState<string[]>([]);
52
+
53
+ const addCustom = () => {
54
+ const t = customInput.trim().toLowerCase();
55
+ if (!t) return;
56
+ if (!customTerms.includes(t)) setCustomTerms(prev => [...prev, t]);
57
+ if (!selectedTerms.includes(t)) setSelectedTerms([...selectedTerms, t]);
58
+ setCustomInput("");
59
+ };
60
+
61
+ // Compute toxic ratio per selected term from posts
62
+ const termRatio: Record<string, number> = {};
63
+ for (const term of selectedTerms) {
64
+ const matching = posts.filter(p => p.query_term === term || p.text.toLowerCase().includes(term));
65
+ if (matching.length === 0) { termRatio[term] = Math.random() * 0.8; continue; }
66
+ termRatio[term] = matching.filter(p => p.label === "toxic").length / matching.length;
67
+ }
68
+
69
+ return (
70
+ <AnimatePresence>
71
+ {open && (
72
+ <motion.aside
73
+ key="sidebar"
74
+ initial={{ x: -260, opacity: 0 }}
75
+ animate={{ x: 0, opacity: 1 }}
76
+ exit={{ x: -260, opacity: 0 }}
77
+ transition={{ type: "spring", stiffness: 280, damping: 30 }}
78
+ style={{
79
+ width: 240,
80
+ minWidth: 240,
81
+ background: "#0d1120",
82
+ borderRight: "1px solid #1e2540",
83
+ padding: "1rem 0.85rem 1.5rem",
84
+ display: "flex",
85
+ flexDirection: "column",
86
+ overflowY: "auto",
87
+ overflowX: "hidden",
88
+ }}
89
+ >
90
+ {/* Tracked terms */}
91
+ <SectionLabel>Tracked terms</SectionLabel>
92
+ <div style={{ display: "flex", flexDirection: "column", gap: 4, flexShrink: 0 }}>
93
+ {selectedTerms.slice(0, 6).map(term => {
94
+ const pct = Math.round((termRatio[term] ?? 0) * 100);
95
+ const color = nodeColor(termRatio[term] ?? 0);
96
+ return (
97
+ <motion.div
98
+ key={term}
99
+ initial={{ opacity: 0, x: -10 }}
100
+ animate={{ opacity: 1, x: 0 }}
101
+ style={{
102
+ display: "flex",
103
+ alignItems: "center",
104
+ justifyContent: "space-between",
105
+ background: "#141826",
106
+ border: "1px solid #1e2540",
107
+ borderRadius: 8,
108
+ padding: "5px 9px",
109
+ }}
110
+ >
111
+ <div style={{ display: "flex", alignItems: "center", gap: 7 }}>
112
+ <div style={{ width: 8, height: 8, borderRadius: "50%", background: color, flexShrink: 0 }} />
113
+ <span style={{ fontSize: "0.78rem", color: "#c8cce0" }}>{term}</span>
114
+ </div>
115
+ <span style={{ fontSize: "0.68rem", color: "#3a4060" }}>{pct}%</span>
116
+ </motion.div>
117
+ );
118
+ })}
119
+ </div>
120
+
121
+ {/* Algospeak terms multiselect */}
122
+ <SectionLabel>Algospeak terms</SectionLabel>
123
+ <div style={{
124
+ background: "#141826",
125
+ border: "1px solid #1e2540",
126
+ borderRadius: 8,
127
+ padding: "6px",
128
+ display: "flex",
129
+ flexWrap: "wrap",
130
+ gap: 4,
131
+ maxHeight: 160,
132
+ overflowY: "auto",
133
+ flexShrink: 0,
134
+ }}>
135
+ {[...ALGOSPEAK_TERMS, ...customTerms].map(t => {
136
+ const selected = selectedTerms.includes(t);
137
+ return (
138
+ <button
139
+ key={t}
140
+ onClick={() => {
141
+ setSelectedTerms(
142
+ selected
143
+ ? selectedTerms.filter(s => s !== t)
144
+ : [...selectedTerms, t]
145
+ );
146
+ }}
147
+ style={{
148
+ padding: "2px 6px 2px 8px",
149
+ borderRadius: 999,
150
+ fontSize: "0.68rem",
151
+ cursor: "pointer",
152
+ border: "1px solid",
153
+ transition: "all 0.15s",
154
+ background: selected ? "rgba(155,127,212,0.18)" : "transparent",
155
+ borderColor: selected ? "rgba(155,127,212,0.5)" : "#2a3050",
156
+ color: selected ? "#c3a6ff" : "#5a6080",
157
+ display: "flex",
158
+ alignItems: "center",
159
+ gap: 4,
160
+ }}
161
+ >
162
+ {t}
163
+ {selected && (
164
+ <span
165
+ onClick={e => {
166
+ e.stopPropagation();
167
+ setSelectedTerms(selectedTerms.filter(s => s !== t));
168
+ }}
169
+ style={{
170
+ display: "inline-flex",
171
+ alignItems: "center",
172
+ justifyContent: "center",
173
+ width: 12,
174
+ height: 12,
175
+ borderRadius: "50%",
176
+ background: "rgba(155,127,212,0.3)",
177
+ color: "#c3a6ff",
178
+ fontSize: "0.6rem",
179
+ lineHeight: 1,
180
+ flexShrink: 0,
181
+ cursor: "pointer",
182
+ }}
183
+ >
184
+
185
+ </span>
186
+ )}
187
+ </button>
188
+ );
189
+ })}
190
+ </div>
191
+
192
+ {/* Custom term input */}
193
+ <SectionLabel>Add custom term</SectionLabel>
194
+ <div style={{ display: "flex", gap: 6, flexShrink: 0 }}>
195
+ <input
196
+ value={customInput}
197
+ onChange={e => setCustomInput(e.target.value)}
198
+ onKeyDown={e => e.key === "Enter" && addCustom()}
199
+ placeholder="Type a term..."
200
+ style={{
201
+ flex: 1,
202
+ background: "#141826",
203
+ border: "1px solid #1e2540",
204
+ borderRadius: 7,
205
+ padding: "5px 9px",
206
+ color: "#e8eaf0",
207
+ fontSize: "0.78rem",
208
+ outline: "none",
209
+ }}
210
+ />
211
+ </div>
212
+ <button
213
+ onClick={addCustom}
214
+ style={{
215
+ marginTop: 6,
216
+ width: "100%",
217
+ background: "rgba(155,127,212,0.12)",
218
+ border: "1px solid rgba(155,127,212,0.3)",
219
+ borderRadius: 7,
220
+ color: "#c3a6ff",
221
+ fontSize: "0.78rem",
222
+ padding: "5px 0",
223
+ cursor: "pointer",
224
+ fontWeight: 600,
225
+ flexShrink: 0,
226
+ }}
227
+ >
228
+ + Add term
229
+ </button>
230
+
231
+ {/* Threshold slider */}
232
+ <SectionLabel>Threshold</SectionLabel>
233
+ <div style={{ paddingInline: 2, flexShrink: 0 }}>
234
+ <div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 4 }}>
235
+ <span style={{ fontSize: "0.72rem", color: "#ff6b3d", fontWeight: 700 }}>
236
+ {threshold.toFixed(2)}
237
+ </span>
238
+ </div>
239
+ <input
240
+ type="range"
241
+ min={0} max={1} step={0.05}
242
+ value={threshold}
243
+ onChange={e => setThreshold(parseFloat(e.target.value))}
244
+ style={{ width: "100%", accentColor: "#ff4b4b" }}
245
+ />
246
+ </div>
247
+
248
+ {/* Sampling slider */}
249
+ <SectionLabel>Sampling</SectionLabel>
250
+ <div style={{ paddingInline: 2, flexShrink: 0 }}>
251
+ <div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 4 }}>
252
+ <span style={{ fontSize: "0.72rem", color: "#ff6b3d", fontWeight: 700 }}>{sampling}</span>
253
+ </div>
254
+ <input
255
+ type="range"
256
+ min={5} max={100} step={5}
257
+ value={sampling}
258
+ onChange={e => setSampling(parseInt(e.target.value))}
259
+ style={{ width: "100%", accentColor: "#ff4b4b" }}
260
+ />
261
+ </div>
262
+
263
+ {/* Fetch section */}
264
+ <SectionLabel>Fetch</SectionLabel>
265
+ <label style={{ display: "flex", alignItems: "center", gap: 7, cursor: "pointer", marginBottom: 10, flexShrink: 0 }}>
266
+ <input
267
+ type="checkbox"
268
+ checked={autoRefresh}
269
+ onChange={e => setAutoRefresh(e.target.checked)}
270
+ style={{ accentColor: "#ff4b4b" }}
271
+ />
272
+ <span style={{ fontSize: "0.78rem", color: "#8a90ad" }}>Auto-refresh (60s)</span>
273
+ </label>
274
+
275
+ <motion.button
276
+ onClick={onFetch}
277
+ disabled={fetching}
278
+ whileHover={{ scale: fetching ? 1 : 1.02 }}
279
+ whileTap={{ scale: fetching ? 1 : 0.97 }}
280
+ style={{
281
+ width: "100%",
282
+ background: fetching
283
+ ? "linear-gradient(135deg, #aa3333, #aa5f2a)"
284
+ : "linear-gradient(135deg, #ff4b4b, #ff8c42)",
285
+ color: "#fff",
286
+ border: "none",
287
+ borderRadius: 9,
288
+ padding: "9px 0",
289
+ fontWeight: 700,
290
+ fontSize: "0.88rem",
291
+ cursor: fetching ? "not-allowed" : "pointer",
292
+ boxShadow: fetching ? "none" : "0 0 16px rgba(255,75,75,0.3)",
293
+ transition: "box-shadow 0.2s",
294
+ flexShrink: 0,
295
+ }}
296
+ >
297
+ {fetching ? "Fetching…" : "Fetch & Analyze"}
298
+ </motion.button>
299
+
300
+ {/* Custom terms pills at bottom */}
301
+ {customTerms.length > 0 && (
302
+ <div style={{ marginTop: "0.8rem", display: "flex", flexWrap: "wrap", gap: 4, flexShrink: 0 }}>
303
+ {customTerms.map(t => (
304
+ <span
305
+ key={t}
306
+ style={{
307
+ display: "inline-flex",
308
+ alignItems: "center",
309
+ gap: 3,
310
+ padding: "2px 7px",
311
+ borderRadius: 999,
312
+ background: "rgba(155,127,212,0.1)",
313
+ border: "1px solid rgba(155,127,212,0.4)",
314
+ color: "#c3a6ff",
315
+ fontSize: "0.65rem",
316
+ }}
317
+ >
318
+ {t}
319
+ <X
320
+ size={10}
321
+ style={{ cursor: "pointer" }}
322
+ onClick={() => {
323
+ setCustomTerms(prev => prev.filter(x => x !== t));
324
+ setSelectedTerms(selectedTerms.filter(x => x !== t));
325
+ }}
326
+ />
327
+ </span>
328
+ ))}
329
+ </div>
330
+ )}
331
+ </motion.aside>
332
+ )}
333
+ </AnimatePresence>
334
+ );
335
+ }
frontend/src/app/components/SplashScreen.tsx ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState, useRef } from "react";
2
+ import { motion, AnimatePresence } from "motion/react";
3
+
4
+ interface SplashScreenProps {
5
+ onDone: () => void;
6
+ }
7
+
8
+ const TITLE = "ALGOSCOPE";
9
+ const SUBTITLE = "Real-time algospeak & toxicity intelligence on Bluesky";
10
+
11
+ // Animated background nodes for the splash
12
+ const BG_NODES = Array.from({ length: 14 }, (_, i) => ({
13
+ id: i,
14
+ x: 8 + (i * 13.5) % 92,
15
+ y: 10 + (i * 17) % 80,
16
+ r: 3 + (i % 4) * 1.8,
17
+ color: i % 3 === 0 ? "#ff4b4b" : i % 3 === 1 ? "#ff8c42" : "#a6b0ff",
18
+ delay: i * 0.07,
19
+ }));
20
+
21
+ const BG_EDGES = [
22
+ [0, 3], [3, 7], [7, 11], [1, 5], [5, 9], [9, 12],
23
+ [2, 6], [6, 10], [0, 4], [4, 8], [8, 13], [2, 7],
24
+ [1, 6], [3, 10], [5, 12], [4, 11],
25
+ ];
26
+
27
+ export function SplashScreen({ onDone }: SplashScreenProps) {
28
+ const [lettersDone, setLettersDone] = useState(0);
29
+ const [subtitleVisible, setSubtitleVisible] = useState(false);
30
+ const [progress, setProgress] = useState(0);
31
+ const [exiting, setExiting] = useState(false);
32
+ const timerRef = useRef<ReturnType<typeof setTimeout>>(null);
33
+
34
+ useEffect(() => {
35
+ // Letter-by-letter reveal
36
+ let i = 0;
37
+ const letterInterval = setInterval(() => {
38
+ i++;
39
+ setLettersDone(i);
40
+ if (i >= TITLE.length) clearInterval(letterInterval);
41
+ }, 80);
42
+
43
+ // Show subtitle after title is done
44
+ timerRef.current = setTimeout(() => setSubtitleVisible(true), 900);
45
+
46
+ // Progress bar
47
+ let prog = 0;
48
+ const progInterval = setInterval(() => {
49
+ prog += 1.6;
50
+ setProgress(Math.min(100, prog));
51
+ if (prog >= 100) clearInterval(progInterval);
52
+ }, 28);
53
+
54
+ // Exit
55
+ const exitTimer = setTimeout(() => {
56
+ setExiting(true);
57
+ setTimeout(onDone, 700);
58
+ }, 2600);
59
+
60
+ return () => {
61
+ clearInterval(letterInterval);
62
+ clearInterval(progInterval);
63
+ if (timerRef.current) clearTimeout(timerRef.current);
64
+ clearTimeout(exitTimer);
65
+ };
66
+ }, [onDone]);
67
+
68
+ return (
69
+ <AnimatePresence>
70
+ {!exiting && (
71
+ <motion.div
72
+ initial={{ opacity: 1 }}
73
+ exit={{ opacity: 0, scale: 1.04 }}
74
+ transition={{ duration: 0.65, ease: "easeInOut" }}
75
+ style={{
76
+ position: "fixed",
77
+ inset: 0,
78
+ zIndex: 9999,
79
+ background: "#080b12",
80
+ display: "flex",
81
+ flexDirection: "column",
82
+ alignItems: "center",
83
+ justifyContent: "center",
84
+ overflow: "hidden",
85
+ }}
86
+ >
87
+ {/* Animated background graph */}
88
+ <svg
89
+ style={{
90
+ position: "absolute",
91
+ inset: 0,
92
+ width: "100%",
93
+ height: "100%",
94
+ opacity: 0.18,
95
+ }}
96
+ viewBox="0 0 100 100"
97
+ preserveAspectRatio="xMidYMid slice"
98
+ >
99
+ {BG_EDGES.map(([a, b], i) => {
100
+ const nodeA = BG_NODES[a];
101
+ const nodeB = BG_NODES[b];
102
+ return (
103
+ <motion.line
104
+ key={i}
105
+ x1={nodeA.x} y1={nodeA.y}
106
+ x2={nodeB.x} y2={nodeB.y}
107
+ stroke="#a6b0ff"
108
+ strokeWidth="0.3"
109
+ initial={{ pathLength: 0, opacity: 0 }}
110
+ animate={{ pathLength: 1, opacity: 1 }}
111
+ transition={{ delay: 0.3 + i * 0.04, duration: 0.5 }}
112
+ />
113
+ );
114
+ })}
115
+ {BG_NODES.map(node => (
116
+ <motion.circle
117
+ key={node.id}
118
+ cx={node.x} cy={node.y} r={node.r}
119
+ fill={node.color}
120
+ initial={{ scale: 0, opacity: 0 }}
121
+ animate={{ scale: 1, opacity: 0.7 }}
122
+ transition={{ delay: node.delay, duration: 0.4, type: "spring" }}
123
+ />
124
+ ))}
125
+ </svg>
126
+
127
+ {/* Radial glow */}
128
+ <div
129
+ style={{
130
+ position: "absolute",
131
+ width: 520,
132
+ height: 520,
133
+ borderRadius: "50%",
134
+ background:
135
+ "radial-gradient(circle, rgba(255,75,75,0.08) 0%, rgba(10,13,20,0) 70%)",
136
+ pointerEvents: "none",
137
+ }}
138
+ />
139
+
140
+ {/* Logo icon */}
141
+ <motion.div
142
+ initial={{ scale: 0, rotate: -15 }}
143
+ animate={{ scale: 1, rotate: 0 }}
144
+ transition={{ duration: 0.5, type: "spring", stiffness: 200 }}
145
+ style={{
146
+ width: 72,
147
+ height: 72,
148
+ borderRadius: 18,
149
+ background: "linear-gradient(135deg, #ff4b4b, #ff8c42)",
150
+ display: "flex",
151
+ alignItems: "center",
152
+ justifyContent: "center",
153
+ fontSize: "2.2rem",
154
+ fontWeight: 900,
155
+ color: "#fff",
156
+ boxShadow:
157
+ "0 0 40px rgba(255,75,75,0.5), 0 0 80px rgba(255,75,75,0.2)",
158
+ marginBottom: "1.5rem",
159
+ }}
160
+ >
161
+ A
162
+ </motion.div>
163
+
164
+ {/* Title: letter by letter */}
165
+ <div
166
+ style={{
167
+ display: "flex",
168
+ gap: 3,
169
+ marginBottom: "1rem",
170
+ }}
171
+ >
172
+ {TITLE.split("").map((char, i) => (
173
+ <motion.span
174
+ key={i}
175
+ initial={{ opacity: 0, y: 20, filter: "blur(8px)" }}
176
+ animate={
177
+ i < lettersDone
178
+ ? { opacity: 1, y: 0, filter: "blur(0px)" }
179
+ : { opacity: 0, y: 20, filter: "blur(8px)" }
180
+ }
181
+ transition={{ duration: 0.3, ease: "easeOut" }}
182
+ style={{
183
+ fontSize: "3.2rem",
184
+ fontWeight: 900,
185
+ letterSpacing: "0.06em",
186
+ color: i < 4 ? "#ff4b4b" : "#e8eaf0",
187
+ textShadow:
188
+ i < 4
189
+ ? "0 0 30px rgba(255,75,75,0.6)"
190
+ : "0 0 20px rgba(232,234,240,0.2)",
191
+ fontFamily: "system-ui, -apple-system, sans-serif",
192
+ }}
193
+ >
194
+ {char}
195
+ </motion.span>
196
+ ))}
197
+ </div>
198
+
199
+ {/* Subtitle */}
200
+ <AnimatePresence>
201
+ {subtitleVisible && (
202
+ <motion.div
203
+ initial={{ opacity: 0, y: 10 }}
204
+ animate={{ opacity: 1, y: 0 }}
205
+ transition={{ duration: 0.5 }}
206
+ style={{
207
+ fontSize: "0.9rem",
208
+ color: "#6a70a0",
209
+ letterSpacing: "0.04em",
210
+ maxWidth: 440,
211
+ textAlign: "center",
212
+ marginBottom: "2.5rem",
213
+ fontFamily: "system-ui, -apple-system, sans-serif",
214
+ }}
215
+ >
216
+ {SUBTITLE}
217
+ </motion.div>
218
+ )}
219
+ </AnimatePresence>
220
+
221
+ {/* Progress bar */}
222
+ <div
223
+ style={{
224
+ width: 260,
225
+ height: 3,
226
+ background: "#1a1f35",
227
+ borderRadius: 999,
228
+ overflow: "hidden",
229
+ }}
230
+ >
231
+ <motion.div
232
+ style={{
233
+ height: "100%",
234
+ background: "linear-gradient(90deg, #ff4b4b, #ff8c42)",
235
+ borderRadius: 999,
236
+ boxShadow: "0 0 10px rgba(255,75,75,0.5)",
237
+ width: `${progress}%`,
238
+ }}
239
+ />
240
+ </div>
241
+
242
+ {/* Loading label */}
243
+ <motion.div
244
+ initial={{ opacity: 0 }}
245
+ animate={{ opacity: 1 }}
246
+ transition={{ delay: 0.4 }}
247
+ style={{
248
+ marginTop: "0.75rem",
249
+ fontSize: "0.62rem",
250
+ color: "#3a4060",
251
+ letterSpacing: "2px",
252
+ textTransform: "uppercase",
253
+ fontFamily: "system-ui, -apple-system, sans-serif",
254
+ }}
255
+ >
256
+ {progress < 100 ? "Loading intelligence engine…" : "Ready"}
257
+ </motion.div>
258
+
259
+ {/* Version badge */}
260
+ <motion.div
261
+ initial={{ opacity: 0 }}
262
+ animate={{ opacity: 1 }}
263
+ transition={{ delay: 0.8 }}
264
+ style={{
265
+ position: "absolute",
266
+ bottom: "1.5rem",
267
+ right: "1.5rem",
268
+ fontSize: "0.62rem",
269
+ color: "#2a3050",
270
+ fontFamily: "system-ui, -apple-system, sans-serif",
271
+ }}
272
+ >
273
+ by Odeliya Charitonova · TAU CS
274
+ </motion.div>
275
+ </motion.div>
276
+ )}
277
+ </AnimatePresence>
278
+ );
279
+ }
frontend/src/app/components/TermComparisonTab.tsx ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { motion } from "motion/react";
3
+ import { Post, ALGOSPEAK_TERMS, nodeColor } from "./mockData";
4
+
5
+ interface Props {
6
+ posts: Post[];
7
+ }
8
+
9
+ function termStats(posts: Post[], term: string) {
10
+ const matched = posts.filter(p =>
11
+ p.query_term === term || p.text.toLowerCase().includes(term)
12
+ );
13
+ if (!matched.length) return null;
14
+ const scores = matched.map(p => p.score);
15
+ const toxicN = matched.filter(p => p.label === "toxic").length;
16
+ return {
17
+ count: matched.length,
18
+ toxicRate: (toxicN / matched.length) * 100,
19
+ avgScore: scores.reduce((a, b) => a + b, 0) / scores.length,
20
+ maxScore: Math.max(...scores),
21
+ posts: matched,
22
+ };
23
+ }
24
+
25
+ const BIN_LABELS = ["0-0.2", "0.2-0.4", "0.4-0.6", "0.6-0.8", "0.8-1.0"];
26
+
27
+ function binPosts(posts: Post[]) {
28
+ const bins = [0, 0, 0, 0, 0];
29
+ for (const p of posts) {
30
+ const idx = Math.min(4, Math.floor(p.score / 0.2));
31
+ bins[idx]++;
32
+ }
33
+ return bins;
34
+ }
35
+
36
+ export function TermComparisonTab({ posts }: Props) {
37
+ const [termA, setTermA] = useState(ALGOSPEAK_TERMS[0]);
38
+ const [termB, setTermB] = useState(ALGOSPEAK_TERMS[1]);
39
+ const [hovered, setHovered] = useState<{ binIdx: number; series: "a" | "b" } | null>(null);
40
+
41
+ const sA = termStats(posts, termA);
42
+ const sB = termStats(posts, termB);
43
+
44
+ const selectStyle: React.CSSProperties = {
45
+ background: "#141826",
46
+ border: "1px solid #1e2540",
47
+ borderRadius: 8,
48
+ padding: "6px 10px",
49
+ color: "#e8eaf0",
50
+ fontSize: "0.83rem",
51
+ width: "100%",
52
+ cursor: "pointer",
53
+ };
54
+
55
+ const compData = BIN_LABELS.map((label, i) => ({
56
+ label,
57
+ a: sA ? binPosts(sA.posts)[i] : 0,
58
+ b: sB ? binPosts(sB.posts)[i] : 0,
59
+ }));
60
+
61
+ return (
62
+ <div style={{ padding: "1.2rem 1.4rem", display: "flex", flexDirection: "column", gap: "1.1rem" }}>
63
+ <motion.div
64
+ initial={{ opacity: 0, y: 12 }}
65
+ animate={{ opacity: 1, y: 0 }}
66
+ style={{ color: "#5a6080", fontSize: "0.82rem" }}
67
+ >
68
+ Select two algospeak terms to compare their toxicity profiles from all stored posts.
69
+ </motion.div>
70
+
71
+ {/* Term selectors */}
72
+ <div style={{ display: "flex", gap: "1rem" }}>
73
+ <motion.div
74
+ initial={{ opacity: 0, y: 14 }}
75
+ animate={{ opacity: 1, y: 0 }}
76
+ transition={{ delay: 0.07 }}
77
+ style={{ flex: 1 }}
78
+ >
79
+ <div style={{ fontSize: "0.68rem", color: "#5a6080", marginBottom: 5 }}>Term A</div>
80
+ <select value={termA} onChange={e => setTermA(e.target.value)} style={selectStyle}>
81
+ {ALGOSPEAK_TERMS.map(t => <option key={t} value={t}>{t}</option>)}
82
+ </select>
83
+ </motion.div>
84
+ <motion.div
85
+ initial={{ opacity: 0, y: 14 }}
86
+ animate={{ opacity: 1, y: 0 }}
87
+ transition={{ delay: 0.12 }}
88
+ style={{ flex: 1 }}
89
+ >
90
+ <div style={{ fontSize: "0.68rem", color: "#5a6080", marginBottom: 5 }}>Term B</div>
91
+ <select value={termB} onChange={e => setTermB(e.target.value)} style={selectStyle}>
92
+ {ALGOSPEAK_TERMS.map(t => <option key={t} value={t}>{t}</option>)}
93
+ </select>
94
+ </motion.div>
95
+ </div>
96
+
97
+ {termA === termB && (
98
+ <div style={{
99
+ background: "rgba(255,159,67,0.08)",
100
+ border: "1px solid rgba(255,159,67,0.2)",
101
+ borderRadius: 8,
102
+ padding: "0.6rem 1rem",
103
+ color: "#ff9f43",
104
+ fontSize: "0.8rem",
105
+ }}>
106
+ Select two different terms to compare.
107
+ </div>
108
+ )}
109
+
110
+ {termA !== termB && (
111
+ <>
112
+ {/* Stat cards */}
113
+ <div style={{ display: "flex", gap: "1rem" }}>
114
+ {[
115
+ { term: termA, stats: sA, color: "#a6b0ff" },
116
+ { term: termB, stats: sB, color: "#ff8c42" },
117
+ ].map(({ term, stats, color }, idx) => (
118
+ <motion.div
119
+ key={term}
120
+ initial={{ opacity: 0, y: 18 }}
121
+ animate={{ opacity: 1, y: 0 }}
122
+ transition={{ delay: idx * 0.08 + 0.15 }}
123
+ style={{
124
+ flex: 1,
125
+ background: "#0d1120",
126
+ border: "1px solid #1e2540",
127
+ borderRadius: 10,
128
+ padding: "0.9rem 1rem",
129
+ }}
130
+ >
131
+ <div style={{ fontSize: "0.95rem", fontWeight: 700, color: "#e8eaf0", marginBottom: "0.6rem" }}>
132
+ &ldquo;{term}&rdquo;
133
+ </div>
134
+ {!stats ? (
135
+ <div style={{ color: "#5a6080", fontSize: "0.8rem" }}>No data — fetch more posts first.</div>
136
+ ) : (
137
+ <div style={{ display: "flex", gap: "1.5rem", flexWrap: "wrap" }}>
138
+ {[
139
+ { label: "Posts", value: String(stats.count), valueColor: color },
140
+ { label: "Toxic rate", value: `${stats.toxicRate.toFixed(1)}%`, valueColor: nodeColor(stats.toxicRate / 100) },
141
+ { label: "Avg score", value: stats.avgScore.toFixed(3), valueColor: nodeColor(stats.avgScore) },
142
+ { label: "Max score", value: stats.maxScore.toFixed(3), valueColor: "#ff4b4b" },
143
+ ].map(({ label, value, valueColor }) => (
144
+ <div key={label}>
145
+ <div style={{ fontSize: "0.58rem", textTransform: "uppercase", letterSpacing: "1px", color: "#3a4060", marginBottom: 4 }}>
146
+ {label}
147
+ </div>
148
+ <div style={{ fontSize: "1.25rem", fontWeight: 700, color: valueColor }}>
149
+ {value}
150
+ </div>
151
+ </div>
152
+ ))}
153
+ </div>
154
+ )}
155
+ </motion.div>
156
+ ))}
157
+ </div>
158
+
159
+ {/* Comparison chart — custom div-based with enhanced visuals */}
160
+ {sA && sB && (() => {
161
+ const binsA = binPosts(sA.posts);
162
+ const binsB = binPosts(sB.posts);
163
+ const maxVal = Math.max(...binsA, ...binsB, 1);
164
+ const gridLines = [0.25, 0.5, 0.75, 1.0];
165
+ return (
166
+ <motion.div
167
+ initial={{ opacity: 0, y: 18 }}
168
+ animate={{ opacity: 1, y: 0 }}
169
+ transition={{ delay: 0.3 }}
170
+ style={{
171
+ background: "#0d1120",
172
+ border: "1px solid #1e2540",
173
+ borderRadius: 10,
174
+ padding: "1rem 1.1rem 0.8rem",
175
+ }}
176
+ >
177
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.6rem" }}>
178
+ <div style={{ fontSize: "0.95rem", fontWeight: 700, color: "#e8eaf0" }}>
179
+ Score distribution comparison
180
+ </div>
181
+ {/* Legend */}
182
+ <div style={{ display: "flex", gap: "1rem" }}>
183
+ {[{ label: termA, color: "#a6b0ff", glow: "rgba(166,176,255,0.4)" }, { label: termB, color: "#ff8c42", glow: "rgba(255,140,66,0.4)" }].map(({ label, color, glow }) => (
184
+ <div key={label} style={{ display: "flex", alignItems: "center", gap: 6, fontSize: "0.72rem", color: "#8a90ad" }}>
185
+ <div style={{
186
+ width: 10, height: 10, borderRadius: 2,
187
+ background: color,
188
+ boxShadow: `0 0 6px ${glow}`,
189
+ }} />
190
+ {label}
191
+ </div>
192
+ ))}
193
+ </div>
194
+ </div>
195
+
196
+ {/* Chart wrapper with y-axis */}
197
+ <div style={{ display: "flex", gap: 6 }}>
198
+ {/* Y-axis labels */}
199
+ <div style={{ display: "flex", flexDirection: "column", justifyContent: "space-between", alignItems: "flex-end", height: 180, paddingBottom: 18, paddingTop: 2 }}>
200
+ {[maxVal, Math.round(maxVal * 0.75), Math.round(maxVal * 0.5), Math.round(maxVal * 0.25), 0].map((v, i) => (
201
+ <div key={i} style={{ fontSize: 9, color: "#3a4060", lineHeight: 1 }}>{v}</div>
202
+ ))}
203
+ </div>
204
+
205
+ {/* Chart area */}
206
+ <div style={{ flex: 1, position: "relative" }}>
207
+ {/* Grid lines */}
208
+ <div style={{ position: "absolute", inset: "0 0 18px 0", display: "flex", flexDirection: "column", justifyContent: "space-between", pointerEvents: "none" }}>
209
+ {gridLines.map((_, i) => (
210
+ <div key={i} style={{ height: 1, background: "rgba(30,37,64,0.8)", width: "100%" }} />
211
+ ))}
212
+ <div style={{ height: 1, background: "#1e2540", width: "100%" }} />
213
+ </div>
214
+
215
+ {/* Bars */}
216
+ <div style={{ display: "flex", alignItems: "flex-end", gap: "0.5rem", height: 162, padding: "0 2px" }}>
217
+ {BIN_LABELS.map((label, i) => (
218
+ <div key={`bin-group-${i}`} style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 0, height: "100%" }}>
219
+ <div style={{ flex: 1, display: "flex", alignItems: "flex-end", gap: 2, width: "100%" }}>
220
+ {/* Bar A */}
221
+ <div
222
+ style={{ flex: 1, display: "flex", alignItems: "flex-end", height: "100%", cursor: "pointer" }}
223
+ onMouseEnter={() => setHovered({ binIdx: i, series: "a" })}
224
+ onMouseLeave={() => setHovered(null)}
225
+ >
226
+ <motion.div
227
+ initial={{ height: 0 }}
228
+ animate={{ height: `${(binsA[i] / maxVal) * 100}%` }}
229
+ transition={{ delay: 0.35 + i * 0.04, duration: 0.55, ease: "easeOut" }}
230
+ style={{
231
+ width: "100%",
232
+ background: "linear-gradient(180deg, #c0c8ff 0%, #7080e8 100%)",
233
+ opacity: hovered && hovered.binIdx === i && hovered.series === "a" ? 1 : 0.82,
234
+ borderRadius: "3px 3px 0 0",
235
+ minHeight: binsA[i] > 0 ? 3 : 0,
236
+ position: "relative",
237
+ boxShadow: hovered?.binIdx === i && hovered?.series === "a"
238
+ ? "0 0 12px rgba(166,176,255,0.6)"
239
+ : "none",
240
+ transition: "box-shadow 0.15s, opacity 0.15s",
241
+ }}
242
+ >
243
+ {hovered?.binIdx === i && hovered?.series === "a" && (
244
+ <div style={{
245
+ position: "absolute", bottom: "calc(100% + 5px)", left: "50%", transform: "translateX(-50%)",
246
+ background: "#1a2038", border: "1px solid #2e3a5e", borderRadius: 5,
247
+ padding: "4px 8px", fontSize: 10, color: "#a6b0ff", whiteSpace: "nowrap", zIndex: 10,
248
+ boxShadow: "0 2px 8px rgba(0,0,0,0.4)",
249
+ }}>
250
+ <span style={{ fontWeight: 700 }}>{termA}</span>: {binsA[i]}
251
+ </div>
252
+ )}
253
+ </motion.div>
254
+ </div>
255
+ {/* Bar B */}
256
+ <div
257
+ style={{ flex: 1, display: "flex", alignItems: "flex-end", height: "100%", cursor: "pointer" }}
258
+ onMouseEnter={() => setHovered({ binIdx: i, series: "b" })}
259
+ onMouseLeave={() => setHovered(null)}
260
+ >
261
+ <motion.div
262
+ initial={{ height: 0 }}
263
+ animate={{ height: `${(binsB[i] / maxVal) * 100}%` }}
264
+ transition={{ delay: 0.38 + i * 0.04, duration: 0.55, ease: "easeOut" }}
265
+ style={{
266
+ width: "100%",
267
+ background: "linear-gradient(180deg, #ffa96a 0%, #e06820 100%)",
268
+ opacity: hovered && hovered.binIdx === i && hovered.series === "b" ? 1 : 0.82,
269
+ borderRadius: "3px 3px 0 0",
270
+ minHeight: binsB[i] > 0 ? 3 : 0,
271
+ position: "relative",
272
+ boxShadow: hovered?.binIdx === i && hovered?.series === "b"
273
+ ? "0 0 12px rgba(255,140,66,0.6)"
274
+ : "none",
275
+ transition: "box-shadow 0.15s, opacity 0.15s",
276
+ }}
277
+ >
278
+ {hovered?.binIdx === i && hovered?.series === "b" && (
279
+ <div style={{
280
+ position: "absolute", bottom: "calc(100% + 5px)", left: "50%", transform: "translateX(-50%)",
281
+ background: "#1a2038", border: "1px solid #2e3a5e", borderRadius: 5,
282
+ padding: "4px 8px", fontSize: 10, color: "#ff8c42", whiteSpace: "nowrap", zIndex: 10,
283
+ boxShadow: "0 2px 8px rgba(0,0,0,0.4)",
284
+ }}>
285
+ <span style={{ fontWeight: 700 }}>{termB}</span>: {binsB[i]}
286
+ </div>
287
+ )}
288
+ </motion.div>
289
+ </div>
290
+ </div>
291
+ </div>
292
+ ))}
293
+ </div>
294
+
295
+ {/* Baseline */}
296
+ <div style={{ height: 1, background: "#1e2540" }} />
297
+
298
+ {/* X-axis labels */}
299
+ <div style={{ display: "flex", gap: "0.5rem", padding: "4px 2px 0" }}>
300
+ {BIN_LABELS.map((label, i) => (
301
+ <div key={i} style={{ flex: 1, fontSize: 9, color: "#4a5070", textAlign: "center" }}>{label}</div>
302
+ ))}
303
+ </div>
304
+ </div>
305
+ </div>
306
+
307
+ {/* X-axis title */}
308
+ <div style={{ textAlign: "center", fontSize: "0.65rem", color: "#3a4060", marginTop: "0.35rem", letterSpacing: "0.5px" }}>
309
+ Toxicity score range
310
+ </div>
311
+ </motion.div>
312
+ );
313
+ })()}
314
+
315
+ {/* Per-term bar comparisons */}
316
+ {sA && sB && (
317
+ <motion.div
318
+ initial={{ opacity: 0, y: 18 }}
319
+ animate={{ opacity: 1, y: 0 }}
320
+ transition={{ delay: 0.38 }}
321
+ style={{
322
+ background: "#0d1120",
323
+ border: "1px solid #1e2540",
324
+ borderRadius: 10,
325
+ padding: "0.9rem 1rem",
326
+ }}
327
+ >
328
+ <div style={{ fontSize: "0.95rem", fontWeight: 700, color: "#e8eaf0", marginBottom: "1rem" }}>
329
+ Key metrics at a glance
330
+ </div>
331
+ {[
332
+ { label: "Toxic rate (%)", a: sA.toxicRate, b: sB.toxicRate, max: 100, colorA: "#a6b0ff", colorB: "#ff8c42" },
333
+ { label: "Avg score", a: sA.avgScore * 100, b: sB.avgScore * 100, max: 100, colorA: "#a6b0ff", colorB: "#ff8c42" },
334
+ ].map(({ label, a, b, max, colorA, colorB }) => (
335
+ <div key={label} style={{ marginBottom: "0.8rem" }}>
336
+ <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 5 }}>
337
+ <span style={{ fontSize: "0.72rem", color: "#5a6080" }}>{label}</span>
338
+ <span style={{ fontSize: "0.72rem", color: "#5a6080" }}>
339
+ <span style={{ color: colorA }}>{termA}: {a.toFixed(1)}</span>
340
+ {" vs "}
341
+ <span style={{ color: colorB }}>{termB}: {b.toFixed(1)}</span>
342
+ </span>
343
+ </div>
344
+ <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
345
+ {[{ pct: a / max * 100, color: colorA }, { pct: b / max * 100, color: colorB }].map((bar, bi) => (
346
+ <div key={bi} style={{ background: "#1a1f35", borderRadius: 4, height: 6, overflow: "hidden" }}>
347
+ <motion.div
348
+ initial={{ width: 0 }}
349
+ animate={{ width: `${bar.pct}%` }}
350
+ transition={{ delay: 0.4 + bi * 0.07, duration: 0.6, ease: "easeOut" }}
351
+ style={{ height: "100%", background: bar.color, borderRadius: 4 }}
352
+ />
353
+ </div>
354
+ ))}
355
+ </div>
356
+ </div>
357
+ ))}
358
+ </motion.div>
359
+ )}
360
+ </>
361
+ )}
362
+ </div>
363
+ );
364
+ }
frontend/src/app/components/figma/ImageWithFallback.tsx ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react'
2
+
3
+ const ERROR_IMG_SRC =
4
+ 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODgiIGhlaWdodD0iODgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBvcGFjaXR5PSIuMyIgZmlsbD0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIzLjciPjxyZWN0IHg9IjE2IiB5PSIxNiIgd2lkdGg9IjU2IiBoZWlnaHQ9IjU2IiByeD0iNiIvPjxwYXRoIGQ9Im0xNiA1OCAxNi0xOCAzMiAzMiIvPjxjaXJjbGUgY3g9IjUzIiBjeT0iMzUiIHI9IjciLz48L3N2Zz4KCg=='
5
+
6
+ export function ImageWithFallback(props: React.ImgHTMLAttributes<HTMLImageElement>) {
7
+ const [didError, setDidError] = useState(false)
8
+
9
+ const handleError = () => {
10
+ setDidError(true)
11
+ }
12
+
13
+ const { src, alt, style, className, ...rest } = props
14
+
15
+ return didError ? (
16
+ <div
17
+ className={`inline-block bg-gray-100 text-center align-middle ${className ?? ''}`}
18
+ style={style}
19
+ >
20
+ <div className="flex items-center justify-center w-full h-full">
21
+ <img src={ERROR_IMG_SRC} alt="Error loading image" {...rest} data-original-url={src} />
22
+ </div>
23
+ </div>
24
+ ) : (
25
+ <img src={src} alt={alt} className={className} style={style} {...rest} onError={handleError} />
26
+ )
27
+ }
frontend/src/app/components/mockData.ts ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ── API base URL ───────────────────────────────────────────────────────────────
2
+ // WHY import.meta.env: Vite reads environment variables at build time.
3
+ // VITE_API_URL is empty string in production (same origin as FastAPI),
4
+ // and http://localhost:8000 in development.
5
+ // This means the same compiled artifact works in both environments without
6
+ // any code change — just a different .env file.
7
+ const API_BASE = import.meta.env.VITE_API_URL ?? "";
8
+
9
+ // ── Shared types ───────────────────────────────────────────────────────────────
10
+ export const ALGOSPEAK_TERMS = [
11
+ "unalive", "le dollar bean", "seggs", "cute", "based",
12
+ "cornhole", "nsfw", "eggplant", "spicy", "ratio",
13
+ "touch grass", "down bad", "pretty", "why", "mean",
14
+ "better", "someone", "having", "harder", "top",
15
+ ];
16
+
17
+ export interface Post {
18
+ id: number | string; // DB returns number; mock generator uses string
19
+ text: string;
20
+ score: number;
21
+ label: "toxic" | "non-toxic";
22
+ query_term: string;
23
+ created_at: string;
24
+ }
25
+
26
+ export interface GraphNode {
27
+ id: string;
28
+ frequency: number; // maps to "count" from the API
29
+ toxicRatio: number; // maps to "toxic_ratio" from the API
30
+ }
31
+
32
+ export interface GraphEdge {
33
+ source: string;
34
+ target: string;
35
+ weight: number;
36
+ }
37
+
38
+ // ── Real API client functions ──────────────────────────────────────────────────
39
+
40
+ /**
41
+ * Fetch recent classified posts from the database.
42
+ * Called on mount to populate initial dashboard state.
43
+ */
44
+ export async function apiFetchPosts(limit = 100): Promise<Post[]> {
45
+ const res = await fetch(`${API_BASE}/posts?limit=${limit}`);
46
+ if (!res.ok) throw new Error(`/posts failed: ${res.status}`);
47
+ const data = await res.json();
48
+ // Normalize: API returns id as number, mock uses string — cast to keep
49
+ // downstream components happy with either.
50
+ return (data.posts as Post[]).map(p => ({
51
+ ...p,
52
+ id: p.id ?? 0,
53
+ query_term: p.query_term ?? "",
54
+ created_at: p.created_at ?? "",
55
+ label: p.label === "toxic" ? "toxic" : "non-toxic",
56
+ }));
57
+ }
58
+
59
+ /**
60
+ * Fetch just the total post count from the DB without pulling all rows.
61
+ * Used to keep the "Posts analyzed" counter in sync with the real DB after
62
+ * every fetch — avoids stale-closure bugs in useCallback.
63
+ */
64
+ export async function apiFetchTotal(): Promise<number> {
65
+ const res = await fetch(`${API_BASE}/posts?limit=1`);
66
+ if (!res.ok) return -1;
67
+ const data = await res.json();
68
+ return typeof data.total === "number" ? data.total : -1;
69
+ }
70
+
71
+ /**
72
+ * Trigger a Bluesky fetch + batch inference cycle.
73
+ * Returns the new posts plus timing metadata for the UI.
74
+ */
75
+ export async function apiFetchAndAnalyze(
76
+ queries: string[],
77
+ limit: number,
78
+ threshold: number,
79
+ ): Promise<{ posts: Post[]; fetchTime: number; inferTime: number; count: number; message?: string }> {
80
+ const res = await fetch(`${API_BASE}/fetch-and-analyze`, {
81
+ method: "POST",
82
+ headers: { "Content-Type": "application/json" },
83
+ body: JSON.stringify({ queries, limit, threshold }),
84
+ });
85
+ if (!res.ok) throw new Error(`/fetch-and-analyze failed: ${res.status}`);
86
+ const data = await res.json();
87
+ const posts = (data.posts as Post[]).map(p => ({
88
+ ...p,
89
+ id: p.id ?? 0,
90
+ query_term: p.query_term ?? "",
91
+ created_at: p.created_at ?? "",
92
+ label: p.label === "toxic" ? "toxic" : "non-toxic",
93
+ }));
94
+ return {
95
+ posts,
96
+ fetchTime: data.fetch_time,
97
+ inferTime: data.infer_time,
98
+ count: data.count,
99
+ message: data.message,
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Fetch co-occurrence graph data as nodes + edges JSON.
105
+ * The React canvas simulation handles layout — the server only provides structure.
106
+ */
107
+ export async function apiGetGraphData(
108
+ minCooccurrence: number,
109
+ toxicOnly: boolean,
110
+ ): Promise<{ nodes: GraphNode[]; edges: GraphEdge[] }> {
111
+ const res = await fetch(
112
+ `${API_BASE}/graph-data?min_cooccurrence=${minCooccurrence}&toxic_only=${toxicOnly}`
113
+ );
114
+ if (!res.ok) throw new Error(`/graph-data failed: ${res.status}`);
115
+ const data = await res.json();
116
+ // Map API field names (count, toxic_ratio) to the names the graph
117
+ // simulation already uses (frequency, toxicRatio).
118
+ const nodes: GraphNode[] = (data.nodes as Array<{
119
+ id: string; count: number; toxic_ratio: number;
120
+ }>).map(n => ({
121
+ id: n.id,
122
+ frequency: n.count,
123
+ toxicRatio: n.toxic_ratio,
124
+ }));
125
+ return { nodes, edges: data.edges as GraphEdge[] };
126
+ }
127
+
128
+ // ── Mock data (kept for local dev / offline fallback) ─────────────────────────
129
+ // These are only used if the API is unreachable. They are never sent to the
130
+ // backend — no secrets here.
131
+
132
+ const MOCK_TEMPLATES = [
133
+ "I can't believe they used {term} like that, this is getting out of hand",
134
+ "Found another account using {term} to avoid filters, reported it immediately",
135
+ "The {term} discourse is absolutely wild today, why are people like this",
136
+ "Seeing {term} trending again. Not a great sign for the platform tbh",
137
+ "Whoever invented {term} to avoid filters is frankly unhinged",
138
+ "Just saw {term} used unironically in a serious post, internet is something",
139
+ "Moderators need to update their filters, {term} is literally everywhere now",
140
+ "The {term} to english translation is honestly concerning for society",
141
+ "Why does {term} keep showing up in my feed, algorithm is broken again",
142
+ "Teaching my mom what {term} means was the hardest conversation I've had this year",
143
+ "{term} energy today, not gonna lie tbh",
144
+ "Petition to ban {term} from the platform. Who's with me",
145
+ "Using {term} unironically is such a major red flag ngl",
146
+ "The way {term} has evolved over time is fascinating from a linguistics POV",
147
+ "Just another day of seeing {term} everywhere on here smh",
148
+ "Why is {term} suddenly all over my fyp, is this a new trend",
149
+ "Blocked 3 accounts today for using {term} in my replies",
150
+ "{term} popped up again in the discourse, same old same old",
151
+ "Can we talk about how normalized {term} has become? Not ok.",
152
+ "Researchers studying {term} usage patterns should check bluesky tbh",
153
+ ];
154
+
155
+ function randomScore(): number {
156
+ const r = Math.random();
157
+ if (r < 0.55) return 0.62 + Math.random() * 0.38;
158
+ if (r < 0.75) return 0.40 + Math.random() * 0.30;
159
+ return Math.random() * 0.38;
160
+ }
161
+
162
+ export function generateMockPosts(terms: string[], count = 180): Post[] {
163
+ const posts: Post[] = [];
164
+ const now = Date.now();
165
+ const dayMs = 24 * 60 * 60 * 1000;
166
+
167
+ for (let i = 0; i < count; i++) {
168
+ const term = terms[Math.floor(Math.random() * terms.length)];
169
+ const tpl = MOCK_TEMPLATES[Math.floor(Math.random() * MOCK_TEMPLATES.length)];
170
+ const text = tpl.replace("{term}", term);
171
+ const score = randomScore();
172
+ const created_at = new Date(now - Math.random() * dayMs).toISOString();
173
+ posts.push({
174
+ id: `mock-${i}`,
175
+ text,
176
+ score: Math.round(score * 1000) / 1000,
177
+ label: score >= 0.7 ? "toxic" : "non-toxic",
178
+ query_term: term,
179
+ created_at,
180
+ });
181
+ }
182
+ return posts.sort(
183
+ (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
184
+ );
185
+ }
186
+
187
+ // ── Graph colour helpers (used by CoOccurrenceGraph.tsx) ──────────────────────
188
+ export function nodeColor(toxicRatio: number): string {
189
+ if (toxicRatio >= 0.7) return "#ff4b4b";
190
+ if (toxicRatio >= 0.4) return "#ff8c42";
191
+ return "#2ecc71";
192
+ }
193
+
194
+ export function nodeColorAlpha(toxicRatio: number, alpha = 0.25): string {
195
+ if (toxicRatio >= 0.7) return `rgba(255,75,75,${alpha})`;
196
+ if (toxicRatio >= 0.4) return `rgba(255,140,66,${alpha})`;
197
+ return `rgba(46,204,113,${alpha})`;
198
+ }
frontend/src/app/components/ui/accordion.tsx ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as AccordionPrimitive from "@radix-ui/react-accordion";
5
+ import { ChevronDownIcon } from "lucide-react";
6
+
7
+ import { cn } from "./utils";
8
+
9
+ function Accordion({
10
+ ...props
11
+ }: React.ComponentProps<typeof AccordionPrimitive.Root>) {
12
+ return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
13
+ }
14
+
15
+ function AccordionItem({
16
+ className,
17
+ ...props
18
+ }: React.ComponentProps<typeof AccordionPrimitive.Item>) {
19
+ return (
20
+ <AccordionPrimitive.Item
21
+ data-slot="accordion-item"
22
+ className={cn("border-b last:border-b-0", className)}
23
+ {...props}
24
+ />
25
+ );
26
+ }
27
+
28
+ function AccordionTrigger({
29
+ className,
30
+ children,
31
+ ...props
32
+ }: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
33
+ return (
34
+ <AccordionPrimitive.Header className="flex">
35
+ <AccordionPrimitive.Trigger
36
+ data-slot="accordion-trigger"
37
+ className={cn(
38
+ "focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
39
+ className,
40
+ )}
41
+ {...props}
42
+ >
43
+ {children}
44
+ <ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
45
+ </AccordionPrimitive.Trigger>
46
+ </AccordionPrimitive.Header>
47
+ );
48
+ }
49
+
50
+ function AccordionContent({
51
+ className,
52
+ children,
53
+ ...props
54
+ }: React.ComponentProps<typeof AccordionPrimitive.Content>) {
55
+ return (
56
+ <AccordionPrimitive.Content
57
+ data-slot="accordion-content"
58
+ className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
59
+ {...props}
60
+ >
61
+ <div className={cn("pt-0 pb-4", className)}>{children}</div>
62
+ </AccordionPrimitive.Content>
63
+ );
64
+ }
65
+
66
+ export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
frontend/src/app/components/ui/alert-dialog.tsx ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
5
+
6
+ import { cn } from "./utils";
7
+ import { buttonVariants } from "./button";
8
+
9
+ function AlertDialog({
10
+ ...props
11
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
12
+ return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
13
+ }
14
+
15
+ function AlertDialogTrigger({
16
+ ...props
17
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
18
+ return (
19
+ <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
20
+ );
21
+ }
22
+
23
+ function AlertDialogPortal({
24
+ ...props
25
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
26
+ return (
27
+ <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
28
+ );
29
+ }
30
+
31
+ function AlertDialogOverlay({
32
+ className,
33
+ ...props
34
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
35
+ return (
36
+ <AlertDialogPrimitive.Overlay
37
+ data-slot="alert-dialog-overlay"
38
+ className={cn(
39
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
40
+ className,
41
+ )}
42
+ {...props}
43
+ />
44
+ );
45
+ }
46
+
47
+ function AlertDialogContent({
48
+ className,
49
+ ...props
50
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
51
+ return (
52
+ <AlertDialogPortal>
53
+ <AlertDialogOverlay />
54
+ <AlertDialogPrimitive.Content
55
+ data-slot="alert-dialog-content"
56
+ className={cn(
57
+ "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
58
+ className,
59
+ )}
60
+ {...props}
61
+ />
62
+ </AlertDialogPortal>
63
+ );
64
+ }
65
+
66
+ function AlertDialogHeader({
67
+ className,
68
+ ...props
69
+ }: React.ComponentProps<"div">) {
70
+ return (
71
+ <div
72
+ data-slot="alert-dialog-header"
73
+ className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
74
+ {...props}
75
+ />
76
+ );
77
+ }
78
+
79
+ function AlertDialogFooter({
80
+ className,
81
+ ...props
82
+ }: React.ComponentProps<"div">) {
83
+ return (
84
+ <div
85
+ data-slot="alert-dialog-footer"
86
+ className={cn(
87
+ "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
88
+ className,
89
+ )}
90
+ {...props}
91
+ />
92
+ );
93
+ }
94
+
95
+ function AlertDialogTitle({
96
+ className,
97
+ ...props
98
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
99
+ return (
100
+ <AlertDialogPrimitive.Title
101
+ data-slot="alert-dialog-title"
102
+ className={cn("text-lg font-semibold", className)}
103
+ {...props}
104
+ />
105
+ );
106
+ }
107
+
108
+ function AlertDialogDescription({
109
+ className,
110
+ ...props
111
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
112
+ return (
113
+ <AlertDialogPrimitive.Description
114
+ data-slot="alert-dialog-description"
115
+ className={cn("text-muted-foreground text-sm", className)}
116
+ {...props}
117
+ />
118
+ );
119
+ }
120
+
121
+ function AlertDialogAction({
122
+ className,
123
+ ...props
124
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
125
+ return (
126
+ <AlertDialogPrimitive.Action
127
+ className={cn(buttonVariants(), className)}
128
+ {...props}
129
+ />
130
+ );
131
+ }
132
+
133
+ function AlertDialogCancel({
134
+ className,
135
+ ...props
136
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
137
+ return (
138
+ <AlertDialogPrimitive.Cancel
139
+ className={cn(buttonVariants({ variant: "outline" }), className)}
140
+ {...props}
141
+ />
142
+ );
143
+ }
144
+
145
+ export {
146
+ AlertDialog,
147
+ AlertDialogPortal,
148
+ AlertDialogOverlay,
149
+ AlertDialogTrigger,
150
+ AlertDialogContent,
151
+ AlertDialogHeader,
152
+ AlertDialogFooter,
153
+ AlertDialogTitle,
154
+ AlertDialogDescription,
155
+ AlertDialogAction,
156
+ AlertDialogCancel,
157
+ };
frontend/src/app/components/ui/alert.tsx ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+
4
+ import { cn } from "./utils";
5
+
6
+ const alertVariants = cva(
7
+ "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default: "bg-card text-card-foreground",
12
+ destructive:
13
+ "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
14
+ },
15
+ },
16
+ defaultVariants: {
17
+ variant: "default",
18
+ },
19
+ },
20
+ );
21
+
22
+ function Alert({
23
+ className,
24
+ variant,
25
+ ...props
26
+ }: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
27
+ return (
28
+ <div
29
+ data-slot="alert"
30
+ role="alert"
31
+ className={cn(alertVariants({ variant }), className)}
32
+ {...props}
33
+ />
34
+ );
35
+ }
36
+
37
+ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
38
+ return (
39
+ <div
40
+ data-slot="alert-title"
41
+ className={cn(
42
+ "col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
43
+ className,
44
+ )}
45
+ {...props}
46
+ />
47
+ );
48
+ }
49
+
50
+ function AlertDescription({
51
+ className,
52
+ ...props
53
+ }: React.ComponentProps<"div">) {
54
+ return (
55
+ <div
56
+ data-slot="alert-description"
57
+ className={cn(
58
+ "text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
59
+ className,
60
+ )}
61
+ {...props}
62
+ />
63
+ );
64
+ }
65
+
66
+ export { Alert, AlertTitle, AlertDescription };
frontend/src/app/components/ui/aspect-ratio.tsx ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
4
+
5
+ function AspectRatio({
6
+ ...props
7
+ }: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
8
+ return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />;
9
+ }
10
+
11
+ export { AspectRatio };
frontend/src/app/components/ui/avatar.tsx ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as AvatarPrimitive from "@radix-ui/react-avatar";
5
+
6
+ import { cn } from "./utils";
7
+
8
+ function Avatar({
9
+ className,
10
+ ...props
11
+ }: React.ComponentProps<typeof AvatarPrimitive.Root>) {
12
+ return (
13
+ <AvatarPrimitive.Root
14
+ data-slot="avatar"
15
+ className={cn(
16
+ "relative flex size-10 shrink-0 overflow-hidden rounded-full",
17
+ className,
18
+ )}
19
+ {...props}
20
+ />
21
+ );
22
+ }
23
+
24
+ function AvatarImage({
25
+ className,
26
+ ...props
27
+ }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
28
+ return (
29
+ <AvatarPrimitive.Image
30
+ data-slot="avatar-image"
31
+ className={cn("aspect-square size-full", className)}
32
+ {...props}
33
+ />
34
+ );
35
+ }
36
+
37
+ function AvatarFallback({
38
+ className,
39
+ ...props
40
+ }: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
41
+ return (
42
+ <AvatarPrimitive.Fallback
43
+ data-slot="avatar-fallback"
44
+ className={cn(
45
+ "bg-muted flex size-full items-center justify-center rounded-full",
46
+ className,
47
+ )}
48
+ {...props}
49
+ />
50
+ );
51
+ }
52
+
53
+ export { Avatar, AvatarImage, AvatarFallback };
frontend/src/app/components/ui/badge.tsx ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import { Slot } from "@radix-ui/react-slot";
3
+ import { cva, type VariantProps } from "class-variance-authority";
4
+
5
+ import { cn } from "./utils";
6
+
7
+ const badgeVariants = cva(
8
+ "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default:
13
+ "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
14
+ secondary:
15
+ "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
16
+ destructive:
17
+ "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
18
+ outline:
19
+ "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
20
+ },
21
+ },
22
+ defaultVariants: {
23
+ variant: "default",
24
+ },
25
+ },
26
+ );
27
+
28
+ function Badge({
29
+ className,
30
+ variant,
31
+ asChild = false,
32
+ ...props
33
+ }: React.ComponentProps<"span"> &
34
+ VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
35
+ const Comp = asChild ? Slot : "span";
36
+
37
+ return (
38
+ <Comp
39
+ data-slot="badge"
40
+ className={cn(badgeVariants({ variant }), className)}
41
+ {...props}
42
+ />
43
+ );
44
+ }
45
+
46
+ export { Badge, badgeVariants };
frontend/src/app/components/ui/breadcrumb.tsx ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import { Slot } from "@radix-ui/react-slot";
3
+ import { ChevronRight, MoreHorizontal } from "lucide-react";
4
+
5
+ import { cn } from "./utils";
6
+
7
+ function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
8
+ return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
9
+ }
10
+
11
+ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
12
+ return (
13
+ <ol
14
+ data-slot="breadcrumb-list"
15
+ className={cn(
16
+ "text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
17
+ className,
18
+ )}
19
+ {...props}
20
+ />
21
+ );
22
+ }
23
+
24
+ function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
25
+ return (
26
+ <li
27
+ data-slot="breadcrumb-item"
28
+ className={cn("inline-flex items-center gap-1.5", className)}
29
+ {...props}
30
+ />
31
+ );
32
+ }
33
+
34
+ function BreadcrumbLink({
35
+ asChild,
36
+ className,
37
+ ...props
38
+ }: React.ComponentProps<"a"> & {
39
+ asChild?: boolean;
40
+ }) {
41
+ const Comp = asChild ? Slot : "a";
42
+
43
+ return (
44
+ <Comp
45
+ data-slot="breadcrumb-link"
46
+ className={cn("hover:text-foreground transition-colors", className)}
47
+ {...props}
48
+ />
49
+ );
50
+ }
51
+
52
+ function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
53
+ return (
54
+ <span
55
+ data-slot="breadcrumb-page"
56
+ role="link"
57
+ aria-disabled="true"
58
+ aria-current="page"
59
+ className={cn("text-foreground font-normal", className)}
60
+ {...props}
61
+ />
62
+ );
63
+ }
64
+
65
+ function BreadcrumbSeparator({
66
+ children,
67
+ className,
68
+ ...props
69
+ }: React.ComponentProps<"li">) {
70
+ return (
71
+ <li
72
+ data-slot="breadcrumb-separator"
73
+ role="presentation"
74
+ aria-hidden="true"
75
+ className={cn("[&>svg]:size-3.5", className)}
76
+ {...props}
77
+ >
78
+ {children ?? <ChevronRight />}
79
+ </li>
80
+ );
81
+ }
82
+
83
+ function BreadcrumbEllipsis({
84
+ className,
85
+ ...props
86
+ }: React.ComponentProps<"span">) {
87
+ return (
88
+ <span
89
+ data-slot="breadcrumb-ellipsis"
90
+ role="presentation"
91
+ aria-hidden="true"
92
+ className={cn("flex size-9 items-center justify-center", className)}
93
+ {...props}
94
+ >
95
+ <MoreHorizontal className="size-4" />
96
+ <span className="sr-only">More</span>
97
+ </span>
98
+ );
99
+ }
100
+
101
+ export {
102
+ Breadcrumb,
103
+ BreadcrumbList,
104
+ BreadcrumbItem,
105
+ BreadcrumbLink,
106
+ BreadcrumbPage,
107
+ BreadcrumbSeparator,
108
+ BreadcrumbEllipsis,
109
+ };
frontend/src/app/components/ui/button.tsx ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import { Slot } from "@radix-ui/react-slot";
3
+ import { cva, type VariantProps } from "class-variance-authority";
4
+
5
+ import { cn } from "./utils";
6
+
7
+ const buttonVariants = cva(
8
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
13
+ destructive:
14
+ "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
15
+ outline:
16
+ "border bg-background text-foreground hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
17
+ secondary:
18
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19
+ ghost:
20
+ "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
21
+ link: "text-primary underline-offset-4 hover:underline",
22
+ },
23
+ size: {
24
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
25
+ sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
26
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
27
+ icon: "size-9 rounded-md",
28
+ },
29
+ },
30
+ defaultVariants: {
31
+ variant: "default",
32
+ size: "default",
33
+ },
34
+ },
35
+ );
36
+
37
+ function Button({
38
+ className,
39
+ variant,
40
+ size,
41
+ asChild = false,
42
+ ...props
43
+ }: React.ComponentProps<"button"> &
44
+ VariantProps<typeof buttonVariants> & {
45
+ asChild?: boolean;
46
+ }) {
47
+ const Comp = asChild ? Slot : "button";
48
+
49
+ return (
50
+ <Comp
51
+ data-slot="button"
52
+ className={cn(buttonVariants({ variant, size, className }))}
53
+ {...props}
54
+ />
55
+ );
56
+ }
57
+
58
+ export { Button, buttonVariants };
frontend/src/app/components/ui/calendar.tsx ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { ChevronLeft, ChevronRight } from "lucide-react";
5
+ import { DayPicker } from "react-day-picker";
6
+
7
+ import { cn } from "./utils";
8
+ import { buttonVariants } from "./button";
9
+
10
+ function Calendar({
11
+ className,
12
+ classNames,
13
+ showOutsideDays = true,
14
+ ...props
15
+ }: React.ComponentProps<typeof DayPicker>) {
16
+ return (
17
+ <DayPicker
18
+ showOutsideDays={showOutsideDays}
19
+ className={cn("p-3", className)}
20
+ classNames={{
21
+ months: "flex flex-col sm:flex-row gap-2",
22
+ month: "flex flex-col gap-4",
23
+ caption: "flex justify-center pt-1 relative items-center w-full",
24
+ caption_label: "text-sm font-medium",
25
+ nav: "flex items-center gap-1",
26
+ nav_button: cn(
27
+ buttonVariants({ variant: "outline" }),
28
+ "size-7 bg-transparent p-0 opacity-50 hover:opacity-100",
29
+ ),
30
+ nav_button_previous: "absolute left-1",
31
+ nav_button_next: "absolute right-1",
32
+ table: "w-full border-collapse space-x-1",
33
+ head_row: "flex",
34
+ head_cell:
35
+ "text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
36
+ row: "flex w-full mt-2",
37
+ cell: cn(
38
+ "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-r-md",
39
+ props.mode === "range"
40
+ ? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
41
+ : "[&:has([aria-selected])]:rounded-md",
42
+ ),
43
+ day: cn(
44
+ buttonVariants({ variant: "ghost" }),
45
+ "size-8 p-0 font-normal aria-selected:opacity-100",
46
+ ),
47
+ day_range_start:
48
+ "day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground",
49
+ day_range_end:
50
+ "day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground",
51
+ day_selected:
52
+ "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
53
+ day_today: "bg-accent text-accent-foreground",
54
+ day_outside:
55
+ "day-outside text-muted-foreground aria-selected:text-muted-foreground",
56
+ day_disabled: "text-muted-foreground opacity-50",
57
+ day_range_middle:
58
+ "aria-selected:bg-accent aria-selected:text-accent-foreground",
59
+ day_hidden: "invisible",
60
+ ...classNames,
61
+ }}
62
+ components={{
63
+ IconLeft: ({ className, ...props }) => (
64
+ <ChevronLeft className={cn("size-4", className)} {...props} />
65
+ ),
66
+ IconRight: ({ className, ...props }) => (
67
+ <ChevronRight className={cn("size-4", className)} {...props} />
68
+ ),
69
+ }}
70
+ {...props}
71
+ />
72
+ );
73
+ }
74
+
75
+ export { Calendar };
frontend/src/app/components/ui/card.tsx ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+
3
+ import { cn } from "./utils";
4
+
5
+ function Card({ className, ...props }: React.ComponentProps<"div">) {
6
+ return (
7
+ <div
8
+ data-slot="card"
9
+ className={cn(
10
+ "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border",
11
+ className,
12
+ )}
13
+ {...props}
14
+ />
15
+ );
16
+ }
17
+
18
+ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
19
+ return (
20
+ <div
21
+ data-slot="card-header"
22
+ className={cn(
23
+ "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 pt-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
24
+ className,
25
+ )}
26
+ {...props}
27
+ />
28
+ );
29
+ }
30
+
31
+ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
32
+ return (
33
+ <h4
34
+ data-slot="card-title"
35
+ className={cn("leading-none", className)}
36
+ {...props}
37
+ />
38
+ );
39
+ }
40
+
41
+ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
42
+ return (
43
+ <p
44
+ data-slot="card-description"
45
+ className={cn("text-muted-foreground", className)}
46
+ {...props}
47
+ />
48
+ );
49
+ }
50
+
51
+ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
52
+ return (
53
+ <div
54
+ data-slot="card-action"
55
+ className={cn(
56
+ "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
57
+ className,
58
+ )}
59
+ {...props}
60
+ />
61
+ );
62
+ }
63
+
64
+ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
65
+ return (
66
+ <div
67
+ data-slot="card-content"
68
+ className={cn("px-6 [&:last-child]:pb-6", className)}
69
+ {...props}
70
+ />
71
+ );
72
+ }
73
+
74
+ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
75
+ return (
76
+ <div
77
+ data-slot="card-footer"
78
+ className={cn("flex items-center px-6 pb-6 [.border-t]:pt-6", className)}
79
+ {...props}
80
+ />
81
+ );
82
+ }
83
+
84
+ export {
85
+ Card,
86
+ CardHeader,
87
+ CardFooter,
88
+ CardTitle,
89
+ CardAction,
90
+ CardDescription,
91
+ CardContent,
92
+ };
frontend/src/app/components/ui/carousel.tsx ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import useEmblaCarousel, {
5
+ type UseEmblaCarouselType,
6
+ } from "embla-carousel-react";
7
+ import { ArrowLeft, ArrowRight } from "lucide-react";
8
+
9
+ import { cn } from "./utils";
10
+ import { Button } from "./button";
11
+
12
+ type CarouselApi = UseEmblaCarouselType[1];
13
+ type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
14
+ type CarouselOptions = UseCarouselParameters[0];
15
+ type CarouselPlugin = UseCarouselParameters[1];
16
+
17
+ type CarouselProps = {
18
+ opts?: CarouselOptions;
19
+ plugins?: CarouselPlugin;
20
+ orientation?: "horizontal" | "vertical";
21
+ setApi?: (api: CarouselApi) => void;
22
+ };
23
+
24
+ type CarouselContextProps = {
25
+ carouselRef: ReturnType<typeof useEmblaCarousel>[0];
26
+ api: ReturnType<typeof useEmblaCarousel>[1];
27
+ scrollPrev: () => void;
28
+ scrollNext: () => void;
29
+ canScrollPrev: boolean;
30
+ canScrollNext: boolean;
31
+ } & CarouselProps;
32
+
33
+ const CarouselContext = React.createContext<CarouselContextProps | null>(null);
34
+
35
+ function useCarousel() {
36
+ const context = React.useContext(CarouselContext);
37
+
38
+ if (!context) {
39
+ throw new Error("useCarousel must be used within a <Carousel />");
40
+ }
41
+
42
+ return context;
43
+ }
44
+
45
+ function Carousel({
46
+ orientation = "horizontal",
47
+ opts,
48
+ setApi,
49
+ plugins,
50
+ className,
51
+ children,
52
+ ...props
53
+ }: React.ComponentProps<"div"> & CarouselProps) {
54
+ const [carouselRef, api] = useEmblaCarousel(
55
+ {
56
+ ...opts,
57
+ axis: orientation === "horizontal" ? "x" : "y",
58
+ },
59
+ plugins,
60
+ );
61
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false);
62
+ const [canScrollNext, setCanScrollNext] = React.useState(false);
63
+
64
+ const onSelect = React.useCallback((api: CarouselApi) => {
65
+ if (!api) return;
66
+ setCanScrollPrev(api.canScrollPrev());
67
+ setCanScrollNext(api.canScrollNext());
68
+ }, []);
69
+
70
+ const scrollPrev = React.useCallback(() => {
71
+ api?.scrollPrev();
72
+ }, [api]);
73
+
74
+ const scrollNext = React.useCallback(() => {
75
+ api?.scrollNext();
76
+ }, [api]);
77
+
78
+ const handleKeyDown = React.useCallback(
79
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
80
+ if (event.key === "ArrowLeft") {
81
+ event.preventDefault();
82
+ scrollPrev();
83
+ } else if (event.key === "ArrowRight") {
84
+ event.preventDefault();
85
+ scrollNext();
86
+ }
87
+ },
88
+ [scrollPrev, scrollNext],
89
+ );
90
+
91
+ React.useEffect(() => {
92
+ if (!api || !setApi) return;
93
+ setApi(api);
94
+ }, [api, setApi]);
95
+
96
+ React.useEffect(() => {
97
+ if (!api) return;
98
+ onSelect(api);
99
+ api.on("reInit", onSelect);
100
+ api.on("select", onSelect);
101
+
102
+ return () => {
103
+ api?.off("select", onSelect);
104
+ };
105
+ }, [api, onSelect]);
106
+
107
+ return (
108
+ <CarouselContext.Provider
109
+ value={{
110
+ carouselRef,
111
+ api: api,
112
+ opts,
113
+ orientation:
114
+ orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
115
+ scrollPrev,
116
+ scrollNext,
117
+ canScrollPrev,
118
+ canScrollNext,
119
+ }}
120
+ >
121
+ <div
122
+ onKeyDownCapture={handleKeyDown}
123
+ className={cn("relative", className)}
124
+ role="region"
125
+ aria-roledescription="carousel"
126
+ data-slot="carousel"
127
+ {...props}
128
+ >
129
+ {children}
130
+ </div>
131
+ </CarouselContext.Provider>
132
+ );
133
+ }
134
+
135
+ function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
136
+ const { carouselRef, orientation } = useCarousel();
137
+
138
+ return (
139
+ <div
140
+ ref={carouselRef}
141
+ className="overflow-hidden"
142
+ data-slot="carousel-content"
143
+ >
144
+ <div
145
+ className={cn(
146
+ "flex",
147
+ orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
148
+ className,
149
+ )}
150
+ {...props}
151
+ />
152
+ </div>
153
+ );
154
+ }
155
+
156
+ function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
157
+ const { orientation } = useCarousel();
158
+
159
+ return (
160
+ <div
161
+ role="group"
162
+ aria-roledescription="slide"
163
+ data-slot="carousel-item"
164
+ className={cn(
165
+ "min-w-0 shrink-0 grow-0 basis-full",
166
+ orientation === "horizontal" ? "pl-4" : "pt-4",
167
+ className,
168
+ )}
169
+ {...props}
170
+ />
171
+ );
172
+ }
173
+
174
+ function CarouselPrevious({
175
+ className,
176
+ variant = "outline",
177
+ size = "icon",
178
+ ...props
179
+ }: React.ComponentProps<typeof Button>) {
180
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel();
181
+
182
+ return (
183
+ <Button
184
+ data-slot="carousel-previous"
185
+ variant={variant}
186
+ size={size}
187
+ className={cn(
188
+ "absolute size-8 rounded-full",
189
+ orientation === "horizontal"
190
+ ? "top-1/2 -left-12 -translate-y-1/2"
191
+ : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
192
+ className,
193
+ )}
194
+ disabled={!canScrollPrev}
195
+ onClick={scrollPrev}
196
+ {...props}
197
+ >
198
+ <ArrowLeft />
199
+ <span className="sr-only">Previous slide</span>
200
+ </Button>
201
+ );
202
+ }
203
+
204
+ function CarouselNext({
205
+ className,
206
+ variant = "outline",
207
+ size = "icon",
208
+ ...props
209
+ }: React.ComponentProps<typeof Button>) {
210
+ const { orientation, scrollNext, canScrollNext } = useCarousel();
211
+
212
+ return (
213
+ <Button
214
+ data-slot="carousel-next"
215
+ variant={variant}
216
+ size={size}
217
+ className={cn(
218
+ "absolute size-8 rounded-full",
219
+ orientation === "horizontal"
220
+ ? "top-1/2 -right-12 -translate-y-1/2"
221
+ : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
222
+ className,
223
+ )}
224
+ disabled={!canScrollNext}
225
+ onClick={scrollNext}
226
+ {...props}
227
+ >
228
+ <ArrowRight />
229
+ <span className="sr-only">Next slide</span>
230
+ </Button>
231
+ );
232
+ }
233
+
234
+ export {
235
+ type CarouselApi,
236
+ Carousel,
237
+ CarouselContent,
238
+ CarouselItem,
239
+ CarouselPrevious,
240
+ CarouselNext,
241
+ };
frontend/src/app/components/ui/chart.tsx ADDED
@@ -0,0 +1,353 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as RechartsPrimitive from "recharts";
5
+
6
+ import { cn } from "./utils";
7
+
8
+ // Format: { THEME_NAME: CSS_SELECTOR }
9
+ const THEMES = { light: "", dark: ".dark" } as const;
10
+
11
+ export type ChartConfig = {
12
+ [k in string]: {
13
+ label?: React.ReactNode;
14
+ icon?: React.ComponentType;
15
+ } & (
16
+ | { color?: string; theme?: never }
17
+ | { color?: never; theme: Record<keyof typeof THEMES, string> }
18
+ );
19
+ };
20
+
21
+ type ChartContextProps = {
22
+ config: ChartConfig;
23
+ };
24
+
25
+ const ChartContext = React.createContext<ChartContextProps | null>(null);
26
+
27
+ function useChart() {
28
+ const context = React.useContext(ChartContext);
29
+
30
+ if (!context) {
31
+ throw new Error("useChart must be used within a <ChartContainer />");
32
+ }
33
+
34
+ return context;
35
+ }
36
+
37
+ function ChartContainer({
38
+ id,
39
+ className,
40
+ children,
41
+ config,
42
+ ...props
43
+ }: React.ComponentProps<"div"> & {
44
+ config: ChartConfig;
45
+ children: React.ComponentProps<
46
+ typeof RechartsPrimitive.ResponsiveContainer
47
+ >["children"];
48
+ }) {
49
+ const uniqueId = React.useId();
50
+ const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
51
+
52
+ return (
53
+ <ChartContext.Provider value={{ config }}>
54
+ <div
55
+ data-slot="chart"
56
+ data-chart={chartId}
57
+ className={cn(
58
+ "[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
59
+ className,
60
+ )}
61
+ {...props}
62
+ >
63
+ <ChartStyle id={chartId} config={config} />
64
+ <RechartsPrimitive.ResponsiveContainer>
65
+ {children}
66
+ </RechartsPrimitive.ResponsiveContainer>
67
+ </div>
68
+ </ChartContext.Provider>
69
+ );
70
+ }
71
+
72
+ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
73
+ const colorConfig = Object.entries(config).filter(
74
+ ([, config]) => config.theme || config.color,
75
+ );
76
+
77
+ if (!colorConfig.length) {
78
+ return null;
79
+ }
80
+
81
+ return (
82
+ <style
83
+ dangerouslySetInnerHTML={{
84
+ __html: Object.entries(THEMES)
85
+ .map(
86
+ ([theme, prefix]) => `
87
+ ${prefix} [data-chart=${id}] {
88
+ ${colorConfig
89
+ .map(([key, itemConfig]) => {
90
+ const color =
91
+ itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
92
+ itemConfig.color;
93
+ return color ? ` --color-${key}: ${color};` : null;
94
+ })
95
+ .join("\n")}
96
+ }
97
+ `,
98
+ )
99
+ .join("\n"),
100
+ }}
101
+ />
102
+ );
103
+ };
104
+
105
+ const ChartTooltip = RechartsPrimitive.Tooltip;
106
+
107
+ function ChartTooltipContent({
108
+ active,
109
+ payload,
110
+ className,
111
+ indicator = "dot",
112
+ hideLabel = false,
113
+ hideIndicator = false,
114
+ label,
115
+ labelFormatter,
116
+ labelClassName,
117
+ formatter,
118
+ color,
119
+ nameKey,
120
+ labelKey,
121
+ }: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
122
+ React.ComponentProps<"div"> & {
123
+ hideLabel?: boolean;
124
+ hideIndicator?: boolean;
125
+ indicator?: "line" | "dot" | "dashed";
126
+ nameKey?: string;
127
+ labelKey?: string;
128
+ }) {
129
+ const { config } = useChart();
130
+
131
+ const tooltipLabel = React.useMemo(() => {
132
+ if (hideLabel || !payload?.length) {
133
+ return null;
134
+ }
135
+
136
+ const [item] = payload;
137
+ const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
138
+ const itemConfig = getPayloadConfigFromPayload(config, item, key);
139
+ const value =
140
+ !labelKey && typeof label === "string"
141
+ ? config[label as keyof typeof config]?.label || label
142
+ : itemConfig?.label;
143
+
144
+ if (labelFormatter) {
145
+ return (
146
+ <div className={cn("font-medium", labelClassName)}>
147
+ {labelFormatter(value, payload)}
148
+ </div>
149
+ );
150
+ }
151
+
152
+ if (!value) {
153
+ return null;
154
+ }
155
+
156
+ return <div className={cn("font-medium", labelClassName)}>{value}</div>;
157
+ }, [
158
+ label,
159
+ labelFormatter,
160
+ payload,
161
+ hideLabel,
162
+ labelClassName,
163
+ config,
164
+ labelKey,
165
+ ]);
166
+
167
+ if (!active || !payload?.length) {
168
+ return null;
169
+ }
170
+
171
+ const nestLabel = payload.length === 1 && indicator !== "dot";
172
+
173
+ return (
174
+ <div
175
+ className={cn(
176
+ "border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
177
+ className,
178
+ )}
179
+ >
180
+ {!nestLabel ? tooltipLabel : null}
181
+ <div className="grid gap-1.5">
182
+ {payload.map((item, index) => {
183
+ const key = `${nameKey || item.name || item.dataKey || "value"}`;
184
+ const itemConfig = getPayloadConfigFromPayload(config, item, key);
185
+ const indicatorColor = color || item.payload.fill || item.color;
186
+
187
+ return (
188
+ <div
189
+ key={item.dataKey}
190
+ className={cn(
191
+ "[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
192
+ indicator === "dot" && "items-center",
193
+ )}
194
+ >
195
+ {formatter && item?.value !== undefined && item.name ? (
196
+ formatter(item.value, item.name, item, index, item.payload)
197
+ ) : (
198
+ <>
199
+ {itemConfig?.icon ? (
200
+ <itemConfig.icon />
201
+ ) : (
202
+ !hideIndicator && (
203
+ <div
204
+ className={cn(
205
+ "shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
206
+ {
207
+ "h-2.5 w-2.5": indicator === "dot",
208
+ "w-1": indicator === "line",
209
+ "w-0 border-[1.5px] border-dashed bg-transparent":
210
+ indicator === "dashed",
211
+ "my-0.5": nestLabel && indicator === "dashed",
212
+ },
213
+ )}
214
+ style={
215
+ {
216
+ "--color-bg": indicatorColor,
217
+ "--color-border": indicatorColor,
218
+ } as React.CSSProperties
219
+ }
220
+ />
221
+ )
222
+ )}
223
+ <div
224
+ className={cn(
225
+ "flex flex-1 justify-between leading-none",
226
+ nestLabel ? "items-end" : "items-center",
227
+ )}
228
+ >
229
+ <div className="grid gap-1.5">
230
+ {nestLabel ? tooltipLabel : null}
231
+ <span className="text-muted-foreground">
232
+ {itemConfig?.label || item.name}
233
+ </span>
234
+ </div>
235
+ {item.value && (
236
+ <span className="text-foreground font-mono font-medium tabular-nums">
237
+ {item.value.toLocaleString()}
238
+ </span>
239
+ )}
240
+ </div>
241
+ </>
242
+ )}
243
+ </div>
244
+ );
245
+ })}
246
+ </div>
247
+ </div>
248
+ );
249
+ }
250
+
251
+ const ChartLegend = RechartsPrimitive.Legend;
252
+
253
+ function ChartLegendContent({
254
+ className,
255
+ hideIcon = false,
256
+ payload,
257
+ verticalAlign = "bottom",
258
+ nameKey,
259
+ }: React.ComponentProps<"div"> &
260
+ Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
261
+ hideIcon?: boolean;
262
+ nameKey?: string;
263
+ }) {
264
+ const { config } = useChart();
265
+
266
+ if (!payload?.length) {
267
+ return null;
268
+ }
269
+
270
+ return (
271
+ <div
272
+ className={cn(
273
+ "flex items-center justify-center gap-4",
274
+ verticalAlign === "top" ? "pb-3" : "pt-3",
275
+ className,
276
+ )}
277
+ >
278
+ {payload.map((item) => {
279
+ const key = `${nameKey || item.dataKey || "value"}`;
280
+ const itemConfig = getPayloadConfigFromPayload(config, item, key);
281
+
282
+ return (
283
+ <div
284
+ key={item.value}
285
+ className={cn(
286
+ "[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
287
+ )}
288
+ >
289
+ {itemConfig?.icon && !hideIcon ? (
290
+ <itemConfig.icon />
291
+ ) : (
292
+ <div
293
+ className="h-2 w-2 shrink-0 rounded-[2px]"
294
+ style={{
295
+ backgroundColor: item.color,
296
+ }}
297
+ />
298
+ )}
299
+ {itemConfig?.label}
300
+ </div>
301
+ );
302
+ })}
303
+ </div>
304
+ );
305
+ }
306
+
307
+ // Helper to extract item config from a payload.
308
+ function getPayloadConfigFromPayload(
309
+ config: ChartConfig,
310
+ payload: unknown,
311
+ key: string,
312
+ ) {
313
+ if (typeof payload !== "object" || payload === null) {
314
+ return undefined;
315
+ }
316
+
317
+ const payloadPayload =
318
+ "payload" in payload &&
319
+ typeof payload.payload === "object" &&
320
+ payload.payload !== null
321
+ ? payload.payload
322
+ : undefined;
323
+
324
+ let configLabelKey: string = key;
325
+
326
+ if (
327
+ key in payload &&
328
+ typeof payload[key as keyof typeof payload] === "string"
329
+ ) {
330
+ configLabelKey = payload[key as keyof typeof payload] as string;
331
+ } else if (
332
+ payloadPayload &&
333
+ key in payloadPayload &&
334
+ typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
335
+ ) {
336
+ configLabelKey = payloadPayload[
337
+ key as keyof typeof payloadPayload
338
+ ] as string;
339
+ }
340
+
341
+ return configLabelKey in config
342
+ ? config[configLabelKey]
343
+ : config[key as keyof typeof config];
344
+ }
345
+
346
+ export {
347
+ ChartContainer,
348
+ ChartTooltip,
349
+ ChartTooltipContent,
350
+ ChartLegend,
351
+ ChartLegendContent,
352
+ ChartStyle,
353
+ };
frontend/src/app/components/ui/checkbox.tsx ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
5
+ import { CheckIcon } from "lucide-react";
6
+
7
+ import { cn } from "./utils";
8
+
9
+ function Checkbox({
10
+ className,
11
+ ...props
12
+ }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
13
+ return (
14
+ <CheckboxPrimitive.Root
15
+ data-slot="checkbox"
16
+ className={cn(
17
+ "peer border bg-input-background dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
18
+ className,
19
+ )}
20
+ {...props}
21
+ >
22
+ <CheckboxPrimitive.Indicator
23
+ data-slot="checkbox-indicator"
24
+ className="flex items-center justify-center text-current transition-none"
25
+ >
26
+ <CheckIcon className="size-3.5" />
27
+ </CheckboxPrimitive.Indicator>
28
+ </CheckboxPrimitive.Root>
29
+ );
30
+ }
31
+
32
+ export { Checkbox };
frontend/src/app/components/ui/collapsible.tsx ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
4
+
5
+ function Collapsible({
6
+ ...props
7
+ }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
8
+ return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
9
+ }
10
+
11
+ function CollapsibleTrigger({
12
+ ...props
13
+ }: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
14
+ return (
15
+ <CollapsiblePrimitive.CollapsibleTrigger
16
+ data-slot="collapsible-trigger"
17
+ {...props}
18
+ />
19
+ );
20
+ }
21
+
22
+ function CollapsibleContent({
23
+ ...props
24
+ }: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
25
+ return (
26
+ <CollapsiblePrimitive.CollapsibleContent
27
+ data-slot="collapsible-content"
28
+ {...props}
29
+ />
30
+ );
31
+ }
32
+
33
+ export { Collapsible, CollapsibleTrigger, CollapsibleContent };
frontend/src/app/components/ui/command.tsx ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Command as CommandPrimitive } from "cmdk";
5
+ import { SearchIcon } from "lucide-react";
6
+
7
+ import { cn } from "./utils";
8
+ import {
9
+ Dialog,
10
+ DialogContent,
11
+ DialogDescription,
12
+ DialogHeader,
13
+ DialogTitle,
14
+ } from "./dialog";
15
+
16
+ function Command({
17
+ className,
18
+ ...props
19
+ }: React.ComponentProps<typeof CommandPrimitive>) {
20
+ return (
21
+ <CommandPrimitive
22
+ data-slot="command"
23
+ className={cn(
24
+ "bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
25
+ className,
26
+ )}
27
+ {...props}
28
+ />
29
+ );
30
+ }
31
+
32
+ function CommandDialog({
33
+ title = "Command Palette",
34
+ description = "Search for a command to run...",
35
+ children,
36
+ ...props
37
+ }: React.ComponentProps<typeof Dialog> & {
38
+ title?: string;
39
+ description?: string;
40
+ }) {
41
+ return (
42
+ <Dialog {...props}>
43
+ <DialogHeader className="sr-only">
44
+ <DialogTitle>{title}</DialogTitle>
45
+ <DialogDescription>{description}</DialogDescription>
46
+ </DialogHeader>
47
+ <DialogContent className="overflow-hidden p-0">
48
+ <Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
49
+ {children}
50
+ </Command>
51
+ </DialogContent>
52
+ </Dialog>
53
+ );
54
+ }
55
+
56
+ function CommandInput({
57
+ className,
58
+ ...props
59
+ }: React.ComponentProps<typeof CommandPrimitive.Input>) {
60
+ return (
61
+ <div
62
+ data-slot="command-input-wrapper"
63
+ className="flex h-9 items-center gap-2 border-b px-3"
64
+ >
65
+ <SearchIcon className="size-4 shrink-0 opacity-50" />
66
+ <CommandPrimitive.Input
67
+ data-slot="command-input"
68
+ className={cn(
69
+ "placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
70
+ className,
71
+ )}
72
+ {...props}
73
+ />
74
+ </div>
75
+ );
76
+ }
77
+
78
+ function CommandList({
79
+ className,
80
+ ...props
81
+ }: React.ComponentProps<typeof CommandPrimitive.List>) {
82
+ return (
83
+ <CommandPrimitive.List
84
+ data-slot="command-list"
85
+ className={cn(
86
+ "max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
87
+ className,
88
+ )}
89
+ {...props}
90
+ />
91
+ );
92
+ }
93
+
94
+ function CommandEmpty({
95
+ ...props
96
+ }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
97
+ return (
98
+ <CommandPrimitive.Empty
99
+ data-slot="command-empty"
100
+ className="py-6 text-center text-sm"
101
+ {...props}
102
+ />
103
+ );
104
+ }
105
+
106
+ function CommandGroup({
107
+ className,
108
+ ...props
109
+ }: React.ComponentProps<typeof CommandPrimitive.Group>) {
110
+ return (
111
+ <CommandPrimitive.Group
112
+ data-slot="command-group"
113
+ className={cn(
114
+ "text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
115
+ className,
116
+ )}
117
+ {...props}
118
+ />
119
+ );
120
+ }
121
+
122
+ function CommandSeparator({
123
+ className,
124
+ ...props
125
+ }: React.ComponentProps<typeof CommandPrimitive.Separator>) {
126
+ return (
127
+ <CommandPrimitive.Separator
128
+ data-slot="command-separator"
129
+ className={cn("bg-border -mx-1 h-px", className)}
130
+ {...props}
131
+ />
132
+ );
133
+ }
134
+
135
+ function CommandItem({
136
+ className,
137
+ ...props
138
+ }: React.ComponentProps<typeof CommandPrimitive.Item>) {
139
+ return (
140
+ <CommandPrimitive.Item
141
+ data-slot="command-item"
142
+ className={cn(
143
+ "data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
144
+ className,
145
+ )}
146
+ {...props}
147
+ />
148
+ );
149
+ }
150
+
151
+ function CommandShortcut({
152
+ className,
153
+ ...props
154
+ }: React.ComponentProps<"span">) {
155
+ return (
156
+ <span
157
+ data-slot="command-shortcut"
158
+ className={cn(
159
+ "text-muted-foreground ml-auto text-xs tracking-widest",
160
+ className,
161
+ )}
162
+ {...props}
163
+ />
164
+ );
165
+ }
166
+
167
+ export {
168
+ Command,
169
+ CommandDialog,
170
+ CommandInput,
171
+ CommandList,
172
+ CommandEmpty,
173
+ CommandGroup,
174
+ CommandItem,
175
+ CommandShortcut,
176
+ CommandSeparator,
177
+ };
frontend/src/app/components/ui/context-menu.tsx ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
5
+ import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
6
+
7
+ import { cn } from "./utils";
8
+
9
+ function ContextMenu({
10
+ ...props
11
+ }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
12
+ return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
13
+ }
14
+
15
+ function ContextMenuTrigger({
16
+ ...props
17
+ }: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
18
+ return (
19
+ <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
20
+ );
21
+ }
22
+
23
+ function ContextMenuGroup({
24
+ ...props
25
+ }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
26
+ return (
27
+ <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
28
+ );
29
+ }
30
+
31
+ function ContextMenuPortal({
32
+ ...props
33
+ }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
34
+ return (
35
+ <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
36
+ );
37
+ }
38
+
39
+ function ContextMenuSub({
40
+ ...props
41
+ }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
42
+ return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
43
+ }
44
+
45
+ function ContextMenuRadioGroup({
46
+ ...props
47
+ }: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
48
+ return (
49
+ <ContextMenuPrimitive.RadioGroup
50
+ data-slot="context-menu-radio-group"
51
+ {...props}
52
+ />
53
+ );
54
+ }
55
+
56
+ function ContextMenuSubTrigger({
57
+ className,
58
+ inset,
59
+ children,
60
+ ...props
61
+ }: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
62
+ inset?: boolean;
63
+ }) {
64
+ return (
65
+ <ContextMenuPrimitive.SubTrigger
66
+ data-slot="context-menu-sub-trigger"
67
+ data-inset={inset}
68
+ className={cn(
69
+ "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
70
+ className,
71
+ )}
72
+ {...props}
73
+ >
74
+ {children}
75
+ <ChevronRightIcon className="ml-auto" />
76
+ </ContextMenuPrimitive.SubTrigger>
77
+ );
78
+ }
79
+
80
+ function ContextMenuSubContent({
81
+ className,
82
+ ...props
83
+ }: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
84
+ return (
85
+ <ContextMenuPrimitive.SubContent
86
+ data-slot="context-menu-sub-content"
87
+ className={cn(
88
+ "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
89
+ className,
90
+ )}
91
+ {...props}
92
+ />
93
+ );
94
+ }
95
+
96
+ function ContextMenuContent({
97
+ className,
98
+ ...props
99
+ }: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
100
+ return (
101
+ <ContextMenuPrimitive.Portal>
102
+ <ContextMenuPrimitive.Content
103
+ data-slot="context-menu-content"
104
+ className={cn(
105
+ "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
106
+ className,
107
+ )}
108
+ {...props}
109
+ />
110
+ </ContextMenuPrimitive.Portal>
111
+ );
112
+ }
113
+
114
+ function ContextMenuItem({
115
+ className,
116
+ inset,
117
+ variant = "default",
118
+ ...props
119
+ }: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
120
+ inset?: boolean;
121
+ variant?: "default" | "destructive";
122
+ }) {
123
+ return (
124
+ <ContextMenuPrimitive.Item
125
+ data-slot="context-menu-item"
126
+ data-inset={inset}
127
+ data-variant={variant}
128
+ className={cn(
129
+ "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
130
+ className,
131
+ )}
132
+ {...props}
133
+ />
134
+ );
135
+ }
136
+
137
+ function ContextMenuCheckboxItem({
138
+ className,
139
+ children,
140
+ checked,
141
+ ...props
142
+ }: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
143
+ return (
144
+ <ContextMenuPrimitive.CheckboxItem
145
+ data-slot="context-menu-checkbox-item"
146
+ className={cn(
147
+ "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
148
+ className,
149
+ )}
150
+ checked={checked}
151
+ {...props}
152
+ >
153
+ <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
154
+ <ContextMenuPrimitive.ItemIndicator>
155
+ <CheckIcon className="size-4" />
156
+ </ContextMenuPrimitive.ItemIndicator>
157
+ </span>
158
+ {children}
159
+ </ContextMenuPrimitive.CheckboxItem>
160
+ );
161
+ }
162
+
163
+ function ContextMenuRadioItem({
164
+ className,
165
+ children,
166
+ ...props
167
+ }: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
168
+ return (
169
+ <ContextMenuPrimitive.RadioItem
170
+ data-slot="context-menu-radio-item"
171
+ className={cn(
172
+ "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
173
+ className,
174
+ )}
175
+ {...props}
176
+ >
177
+ <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
178
+ <ContextMenuPrimitive.ItemIndicator>
179
+ <CircleIcon className="size-2 fill-current" />
180
+ </ContextMenuPrimitive.ItemIndicator>
181
+ </span>
182
+ {children}
183
+ </ContextMenuPrimitive.RadioItem>
184
+ );
185
+ }
186
+
187
+ function ContextMenuLabel({
188
+ className,
189
+ inset,
190
+ ...props
191
+ }: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
192
+ inset?: boolean;
193
+ }) {
194
+ return (
195
+ <ContextMenuPrimitive.Label
196
+ data-slot="context-menu-label"
197
+ data-inset={inset}
198
+ className={cn(
199
+ "text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
200
+ className,
201
+ )}
202
+ {...props}
203
+ />
204
+ );
205
+ }
206
+
207
+ function ContextMenuSeparator({
208
+ className,
209
+ ...props
210
+ }: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
211
+ return (
212
+ <ContextMenuPrimitive.Separator
213
+ data-slot="context-menu-separator"
214
+ className={cn("bg-border -mx-1 my-1 h-px", className)}
215
+ {...props}
216
+ />
217
+ );
218
+ }
219
+
220
+ function ContextMenuShortcut({
221
+ className,
222
+ ...props
223
+ }: React.ComponentProps<"span">) {
224
+ return (
225
+ <span
226
+ data-slot="context-menu-shortcut"
227
+ className={cn(
228
+ "text-muted-foreground ml-auto text-xs tracking-widest",
229
+ className,
230
+ )}
231
+ {...props}
232
+ />
233
+ );
234
+ }
235
+
236
+ export {
237
+ ContextMenu,
238
+ ContextMenuTrigger,
239
+ ContextMenuContent,
240
+ ContextMenuItem,
241
+ ContextMenuCheckboxItem,
242
+ ContextMenuRadioItem,
243
+ ContextMenuLabel,
244
+ ContextMenuSeparator,
245
+ ContextMenuShortcut,
246
+ ContextMenuGroup,
247
+ ContextMenuPortal,
248
+ ContextMenuSub,
249
+ ContextMenuSubContent,
250
+ ContextMenuSubTrigger,
251
+ ContextMenuRadioGroup,
252
+ };
frontend/src/app/components/ui/dialog.tsx ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as DialogPrimitive from "@radix-ui/react-dialog";
5
+ import { XIcon } from "lucide-react";
6
+
7
+ import { cn } from "./utils";
8
+
9
+ function Dialog({
10
+ ...props
11
+ }: React.ComponentProps<typeof DialogPrimitive.Root>) {
12
+ return <DialogPrimitive.Root data-slot="dialog" {...props} />;
13
+ }
14
+
15
+ function DialogTrigger({
16
+ ...props
17
+ }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
18
+ return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
19
+ }
20
+
21
+ function DialogPortal({
22
+ ...props
23
+ }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
24
+ return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
25
+ }
26
+
27
+ function DialogClose({
28
+ ...props
29
+ }: React.ComponentProps<typeof DialogPrimitive.Close>) {
30
+ return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
31
+ }
32
+
33
+ function DialogOverlay({
34
+ className,
35
+ ...props
36
+ }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
37
+ return (
38
+ <DialogPrimitive.Overlay
39
+ data-slot="dialog-overlay"
40
+ className={cn(
41
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
42
+ className,
43
+ )}
44
+ {...props}
45
+ />
46
+ );
47
+ }
48
+
49
+ function DialogContent({
50
+ className,
51
+ children,
52
+ ...props
53
+ }: React.ComponentProps<typeof DialogPrimitive.Content>) {
54
+ return (
55
+ <DialogPortal data-slot="dialog-portal">
56
+ <DialogOverlay />
57
+ <DialogPrimitive.Content
58
+ data-slot="dialog-content"
59
+ className={cn(
60
+ "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
61
+ className,
62
+ )}
63
+ {...props}
64
+ >
65
+ {children}
66
+ <DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
67
+ <XIcon />
68
+ <span className="sr-only">Close</span>
69
+ </DialogPrimitive.Close>
70
+ </DialogPrimitive.Content>
71
+ </DialogPortal>
72
+ );
73
+ }
74
+
75
+ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
76
+ return (
77
+ <div
78
+ data-slot="dialog-header"
79
+ className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
80
+ {...props}
81
+ />
82
+ );
83
+ }
84
+
85
+ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
86
+ return (
87
+ <div
88
+ data-slot="dialog-footer"
89
+ className={cn(
90
+ "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
91
+ className,
92
+ )}
93
+ {...props}
94
+ />
95
+ );
96
+ }
97
+
98
+ function DialogTitle({
99
+ className,
100
+ ...props
101
+ }: React.ComponentProps<typeof DialogPrimitive.Title>) {
102
+ return (
103
+ <DialogPrimitive.Title
104
+ data-slot="dialog-title"
105
+ className={cn("text-lg leading-none font-semibold", className)}
106
+ {...props}
107
+ />
108
+ );
109
+ }
110
+
111
+ function DialogDescription({
112
+ className,
113
+ ...props
114
+ }: React.ComponentProps<typeof DialogPrimitive.Description>) {
115
+ return (
116
+ <DialogPrimitive.Description
117
+ data-slot="dialog-description"
118
+ className={cn("text-muted-foreground text-sm", className)}
119
+ {...props}
120
+ />
121
+ );
122
+ }
123
+
124
+ export {
125
+ Dialog,
126
+ DialogClose,
127
+ DialogContent,
128
+ DialogDescription,
129
+ DialogFooter,
130
+ DialogHeader,
131
+ DialogOverlay,
132
+ DialogPortal,
133
+ DialogTitle,
134
+ DialogTrigger,
135
+ };