GitHub Actions commited on
Commit ·
2bc9b4f
0
Parent(s):
Deploy to HuggingFace Spaces
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .github/workflows/ci.yml +131 -0
- .github/workflows/hf-deploy.yml +53 -0
- .gitignore +38 -0
- Dockerfile +13 -0
- LICENSE +21 -0
- Makefile +20 -0
- README.md +205 -0
- app/__init__.py +1 -0
- app/database.py +164 -0
- app/graph.py +211 -0
- app/ingestion.py +221 -0
- app/main.py +297 -0
- app/model.py +103 -0
- dashboard.py +953 -0
- frontend/.env.development +4 -0
- frontend/.env.production +4 -0
- frontend/dist/assets/index-CZMDWpNf.js +0 -0
- frontend/dist/assets/index-DgSDpXn3.css +1 -0
- frontend/dist/index.html +16 -0
- frontend/index.html +15 -0
- frontend/package.json +81 -0
- frontend/pnpm-lock.yaml +0 -0
- frontend/postcss.config.mjs +15 -0
- frontend/src/app/App.tsx +333 -0
- frontend/src/app/components/CoOccurrenceGraph.tsx +609 -0
- frontend/src/app/components/ExportTab.tsx +242 -0
- frontend/src/app/components/Header.tsx +140 -0
- frontend/src/app/components/OverviewTab.tsx +687 -0
- frontend/src/app/components/Sidebar.tsx +335 -0
- frontend/src/app/components/SplashScreen.tsx +279 -0
- frontend/src/app/components/TermComparisonTab.tsx +364 -0
- frontend/src/app/components/figma/ImageWithFallback.tsx +27 -0
- frontend/src/app/components/mockData.ts +198 -0
- frontend/src/app/components/ui/accordion.tsx +66 -0
- frontend/src/app/components/ui/alert-dialog.tsx +157 -0
- frontend/src/app/components/ui/alert.tsx +66 -0
- frontend/src/app/components/ui/aspect-ratio.tsx +11 -0
- frontend/src/app/components/ui/avatar.tsx +53 -0
- frontend/src/app/components/ui/badge.tsx +46 -0
- frontend/src/app/components/ui/breadcrumb.tsx +109 -0
- frontend/src/app/components/ui/button.tsx +58 -0
- frontend/src/app/components/ui/calendar.tsx +75 -0
- frontend/src/app/components/ui/card.tsx +92 -0
- frontend/src/app/components/ui/carousel.tsx +241 -0
- frontend/src/app/components/ui/chart.tsx +353 -0
- frontend/src/app/components/ui/checkbox.tsx +32 -0
- frontend/src/app/components/ui/collapsible.tsx +33 -0
- frontend/src/app/components/ui/command.tsx +177 -0
- frontend/src/app/components/ui/context-menu.tsx +252 -0
- 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 |
+
[](https://github.com/odeliyach/Algoscope/actions/workflows/ci.yml)
|
| 17 |
+
[](https://codecov.io/gh/odeliyach/Algoscope)
|
| 18 |
+
[](https://github.com/odeliyach/Algoscope/actions/workflows/ci.yml)
|
| 19 |
+
[](https://python.org)
|
| 20 |
+
[](https://huggingface.co/odeliyach/AlgoShield-Algospeak-Detection)
|
| 21 |
+
[](https://huggingface.co/spaces/odeliyach/algoscope)
|
| 22 |
+
[](https://streamlit.io)
|
| 23 |
+
[](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 |
+
">☰</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 & 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> — {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 · {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 |
|
| 759 |
+
<span style="color:#ff4b4b">red >70% toxic</span>
|
| 760 |
+
<span style="color:#ff9f43">orange 40-70% mixed</span>
|
| 761 |
+
<span style="color:#2ecc71">green <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 |
|
| 318 |
+
<span style={{ color: "#ff4b4b" }}>red >70% toxic</span>
|
| 319 |
+
{" "}<span style={{ color: "#ff9f43" }}>orange 40-70% mixed</span>
|
| 320 |
+
{" "}<span style={{ color: "#2ecc71" }}>green <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 "Build Graph" 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 & 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 "Fetch & Analyze" 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 |
+
“{term}”
|
| 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 |
+
};
|