Spaces:
Runtime error
Runtime error
Aryan Mishra commited on
Commit ·
d0a5e7c
1
Parent(s): d9530b5
Phase 2: Jinja2 infrastructure
Browse files- api/app/core/templates.py +23 -0
- api/app/main.py +23 -0
- api/app/routes/pages.py +111 -0
- api/app/static/css/app.css +497 -0
- api/app/templates/base.html +438 -0
- api/app/templates/macros/ui.html +178 -0
- api/app/templates/pages/batch.html +104 -0
- api/app/templates/pages/monitor.html +168 -0
- api/app/templates/pages/predict.html +114 -0
- requirements.txt +1 -0
- tests/api/test_api.py +17 -0
- tests/web/__init__.py +1 -0
- tests/web/test_pages.py +197 -0
api/app/core/templates.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Centralized Jinja2Templates instance.
|
| 3 |
+
|
| 4 |
+
Kept in api/app/core/ so every page-rendering router imports from one place,
|
| 5 |
+
avoiding multiple conflicting Template objects pointing at the same directory.
|
| 6 |
+
|
| 7 |
+
WHY THIS FILE EXISTS
|
| 8 |
+
--------------------
|
| 9 |
+
FastAPI's Jinja2Templates must be initialised with a directory path.
|
| 10 |
+
Centralising it here means that when Phase 3-5 routers add fragment endpoints
|
| 11 |
+
they import `templates` from here — no duplication, no divergence.
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
from fastapi.templating import Jinja2Templates
|
| 18 |
+
|
| 19 |
+
# Resolve relative to this file:
|
| 20 |
+
# api/app/core/templates.py → api/app/templates/
|
| 21 |
+
_TEMPLATE_DIR: Path = Path(__file__).parent.parent / "templates"
|
| 22 |
+
|
| 23 |
+
templates = Jinja2Templates(directory=str(_TEMPLATE_DIR))
|
api/app/main.py
CHANGED
|
@@ -2,10 +2,13 @@ from fastapi import FastAPI
|
|
| 2 |
from contextlib import asynccontextmanager
|
| 3 |
from dotenv import load_dotenv
|
| 4 |
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
|
|
|
| 5 |
|
| 6 |
load_dotenv()
|
| 7 |
|
| 8 |
from api.app.routes import predict, results # noqa: E402
|
|
|
|
| 9 |
from api.app.middleware.metrics import instrumentator # noqa: E402
|
| 10 |
from api.app.services.absa_pipeline import pipeline # noqa: E402
|
| 11 |
from api.app.schemas.db_models import Base # noqa: E402
|
|
@@ -46,3 +49,23 @@ app.include_router(predict.router, tags=["Predict"])
|
|
| 46 |
app.include_router(results.router, tags=["System"])
|
| 47 |
|
| 48 |
instrumentator.instrument(app).expose(app, endpoint="/metrics")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from contextlib import asynccontextmanager
|
| 3 |
from dotenv import load_dotenv
|
| 4 |
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
+
from fastapi.staticfiles import StaticFiles # Phase 2: serves api/app/static/
|
| 6 |
+
from pathlib import Path # Phase 2: resolve static directory path
|
| 7 |
|
| 8 |
load_dotenv()
|
| 9 |
|
| 10 |
from api.app.routes import predict, results # noqa: E402
|
| 11 |
+
from api.app.routes import pages # noqa: E402 Phase 2: Jinja2 page routes
|
| 12 |
from api.app.middleware.metrics import instrumentator # noqa: E402
|
| 13 |
from api.app.services.absa_pipeline import pipeline # noqa: E402
|
| 14 |
from api.app.schemas.db_models import Base # noqa: E402
|
|
|
|
| 49 |
app.include_router(results.router, tags=["System"])
|
| 50 |
|
| 51 |
instrumentator.instrument(app).expose(app, endpoint="/metrics")
|
| 52 |
+
|
| 53 |
+
# ── Phase 2: Jinja2 / HTMX frontend ──────────────────────────────────────────
|
| 54 |
+
#
|
| 55 |
+
# WHY pages router is included AFTER instrumentator:
|
| 56 |
+
# The instrumentator middleware wraps the entire ASGI app; order of
|
| 57 |
+
# router inclusion does not affect which routes get instrumented. Including
|
| 58 |
+
# the pages router last is simply a readability convention — API routes first.
|
| 59 |
+
#
|
| 60 |
+
# WHY StaticFiles is mounted AFTER instrumentator:
|
| 61 |
+
# app.mount() creates a sub-application. Mounting after the instrumentator
|
| 62 |
+
# call means the Prometheus middleware still wraps /static/* requests, but
|
| 63 |
+
# since static files are not hot paths for an ML tool this is acceptable.
|
| 64 |
+
# (Phase 7 cleanup will add /static to excluded_handlers in metrics.py.)
|
| 65 |
+
|
| 66 |
+
app.include_router(pages.router) # include_in_schema=False is set on the router itself
|
| 67 |
+
|
| 68 |
+
# Resolve path relative to this file so it works regardless of CWD.
|
| 69 |
+
_STATIC_DIR = Path(__file__).parent / "static"
|
| 70 |
+
_STATIC_DIR.mkdir(parents=True, exist_ok=True) # idempotent safety guard
|
| 71 |
+
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
|
api/app/routes/pages.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Page routes — Jinja2/HTMX web frontend.
|
| 3 |
+
|
| 4 |
+
WHY THIS FILE EXISTS
|
| 5 |
+
--------------------
|
| 6 |
+
All HTML-serving GET routes live here, completely separate from the JSON REST
|
| 7 |
+
routes in routes/predict.py and routes/results.py. This boundary means:
|
| 8 |
+
|
| 9 |
+
• REST routes never return HTML accidentally.
|
| 10 |
+
• Page routes never appear in the OpenAPI schema (include_in_schema=False).
|
| 11 |
+
• Future phases add HTMX fragment endpoints alongside these page routes
|
| 12 |
+
without touching any existing API code.
|
| 13 |
+
|
| 14 |
+
WHAT THIS FILE DOES (Phase 2)
|
| 15 |
+
------------------------------
|
| 16 |
+
Registers four GET routes that render placeholder Jinja2 templates:
|
| 17 |
+
GET / → redirect to /predict
|
| 18 |
+
GET /predict → pages/predict.html
|
| 19 |
+
GET /batch → pages/batch.html
|
| 20 |
+
GET /monitor → pages/monitor.html
|
| 21 |
+
|
| 22 |
+
No business logic. No inference. No database queries.
|
| 23 |
+
The routes exist only to prove the template rendering infrastructure works.
|
| 24 |
+
|
| 25 |
+
HTMX fragment endpoints (POST /predict/fragment, GET /batch/progress/{id},
|
| 26 |
+
GET /monitor/health-partial) will be added in Phases 3-5.
|
| 27 |
+
"""
|
| 28 |
+
from __future__ import annotations
|
| 29 |
+
|
| 30 |
+
from fastapi import APIRouter, Request
|
| 31 |
+
from fastapi.responses import HTMLResponse
|
| 32 |
+
|
| 33 |
+
from api.app.core.templates import templates
|
| 34 |
+
|
| 35 |
+
# include_in_schema=False keeps these HTML routes out of the OpenAPI / Swagger UI.
|
| 36 |
+
router = APIRouter(include_in_schema=False)
|
| 37 |
+
|
| 38 |
+
# ── Navigation structure ───────────────────────────────────────────────────────
|
| 39 |
+
# Mirrors the NAV constant in the React Sidebar.jsx so sidebar rendering is
|
| 40 |
+
# driven from a single Python list rather than hard-coded in every template.
|
| 41 |
+
_NAV_ITEMS: list[dict[str, str]] = [
|
| 42 |
+
{"path": "/predict", "icon": "psychology", "label": "Predictor"},
|
| 43 |
+
{"path": "/batch", "icon": "cloud_upload", "label": "Batch Analytics"},
|
| 44 |
+
{"path": "/monitor", "icon": "monitoring", "label": "System Health"},
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _base_ctx(request: Request, page_title: str, **extra: object) -> dict:
|
| 49 |
+
"""
|
| 50 |
+
Build the Jinja2 template context that base.html expects.
|
| 51 |
+
|
| 52 |
+
Every page renderer calls this so the sidebar and header always receive
|
| 53 |
+
the nav items and the current path (for active-link highlighting).
|
| 54 |
+
"""
|
| 55 |
+
return {
|
| 56 |
+
"request": request, # required by Jinja2Templates
|
| 57 |
+
"page_title": page_title,
|
| 58 |
+
"nav_items": _NAV_ITEMS,
|
| 59 |
+
"current_path": request.url.path,
|
| 60 |
+
**extra,
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ── Routes ─────────────────────────────────────────────────────────────────────
|
| 65 |
+
|
| 66 |
+
@router.get("/", response_class=HTMLResponse)
|
| 67 |
+
async def index(request: Request) -> HTMLResponse:
|
| 68 |
+
"""Root → serve the Predict page (same behaviour as React's Navigate redirect)."""
|
| 69 |
+
return templates.TemplateResponse(
|
| 70 |
+
"pages/predict.html",
|
| 71 |
+
_base_ctx(request, "Live Predictor"),
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@router.get("/predict", response_class=HTMLResponse)
|
| 76 |
+
async def predict_page(request: Request) -> HTMLResponse:
|
| 77 |
+
"""
|
| 78 |
+
Jinja2 Live Predictor page.
|
| 79 |
+
Phase 2: renders the layout shell with a placeholder content block.
|
| 80 |
+
Phase 3: the content block will contain the HTMX predict form + result panel.
|
| 81 |
+
"""
|
| 82 |
+
return templates.TemplateResponse(
|
| 83 |
+
"pages/predict.html",
|
| 84 |
+
_base_ctx(request, "Live Predictor"),
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@router.get("/batch", response_class=HTMLResponse)
|
| 89 |
+
async def batch_page(request: Request) -> HTMLResponse:
|
| 90 |
+
"""
|
| 91 |
+
Jinja2 Batch Analytics page.
|
| 92 |
+
Phase 2: placeholder.
|
| 93 |
+
Phase 4: file upload form + progress polling.
|
| 94 |
+
"""
|
| 95 |
+
return templates.TemplateResponse(
|
| 96 |
+
"pages/batch.html",
|
| 97 |
+
_base_ctx(request, "Batch Analytics"),
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@router.get("/monitor", response_class=HTMLResponse)
|
| 102 |
+
async def monitor_page(request: Request) -> HTMLResponse:
|
| 103 |
+
"""
|
| 104 |
+
Jinja2 System Monitor page.
|
| 105 |
+
Phase 2: placeholder.
|
| 106 |
+
Phase 5: live health status + performance metrics.
|
| 107 |
+
"""
|
| 108 |
+
return templates.TemplateResponse(
|
| 109 |
+
"pages/monitor.html",
|
| 110 |
+
_base_ctx(request, "System Monitor"),
|
| 111 |
+
)
|
api/app/static/css/app.css
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
* SentimentAI — Application Design System
|
| 3 |
+
*
|
| 4 |
+
* WHY THIS FILE EXISTS
|
| 5 |
+
* --------------------
|
| 6 |
+
* The React dashboard used Tailwind's @apply directive to define component
|
| 7 |
+
* classes (badge-positive, card, btn-primary, etc.) inside index.css. Those
|
| 8 |
+
* @apply rules require a compiled Tailwind build step which we are eliminating.
|
| 9 |
+
*
|
| 10 |
+
* This file replaces index.css + the @apply rules with equivalent plain CSS.
|
| 11 |
+
* Tailwind utility classes (bg-*, text-*, flex, etc.) are still available via
|
| 12 |
+
* the CDN Play CDN loaded in base.html — this file only contains component-level
|
| 13 |
+
* classes that the CDN cannot generate from the HTML scan.
|
| 14 |
+
*
|
| 15 |
+
* Design token values are taken verbatim from dashboard/tailwind.config.js.
|
| 16 |
+
* Do not change token values here without updating the Tailwind CDN config in
|
| 17 |
+
* base.html — they must stay in sync.
|
| 18 |
+
*
|
| 19 |
+
* SECTIONS
|
| 20 |
+
* --------
|
| 21 |
+
* 1. Base / Reset
|
| 22 |
+
* 2. Material Symbols Outlined icon font
|
| 23 |
+
* 3. Scrollbar
|
| 24 |
+
* 4. Focus ring
|
| 25 |
+
* 5. Layout helpers (glass-panel, sidebar, overlay)
|
| 26 |
+
* 6. Navigation (nav-item, nav-item-active)
|
| 27 |
+
* 7. Badges (badge-positive, -negative, -neutral, -processing, -error)
|
| 28 |
+
* 8. Highlights (highlight-positive, -negative, -neutral)
|
| 29 |
+
* 9. Cards (card, card-low, stat-card)
|
| 30 |
+
* 10. Form controls (input-base, textarea reset)
|
| 31 |
+
* 11. Button (btn-primary)
|
| 32 |
+
* 12. Drag-and-drop (drag-active)
|
| 33 |
+
* 13. HTMX (htmx-indicator)
|
| 34 |
+
* 14. Animations (keyframes + helper classes)
|
| 35 |
+
* 15. Toast notices
|
| 36 |
+
*/
|
| 37 |
+
|
| 38 |
+
/* ── 1. Base / Reset ────────────────────────────────────────────────────────── */
|
| 39 |
+
|
| 40 |
+
html {
|
| 41 |
+
color-scheme: dark;
|
| 42 |
+
scroll-behavior: smooth;
|
| 43 |
+
-webkit-font-smoothing: antialiased;
|
| 44 |
+
-moz-osx-font-smoothing: grayscale;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
body {
|
| 48 |
+
background-color: #0b1326;
|
| 49 |
+
color: #dae2fd;
|
| 50 |
+
font-family: 'Inter', ui-sans-serif, system-ui, sans-serif;
|
| 51 |
+
min-height: 100vh;
|
| 52 |
+
margin: 0;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
*,
|
| 56 |
+
*::before,
|
| 57 |
+
*::after {
|
| 58 |
+
box-sizing: border-box;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
/* ── 2. Material Symbols Outlined ───────────────────────────────────────────── */
|
| 62 |
+
/*
|
| 63 |
+
* Mirrors the class defined in React's index.css exactly.
|
| 64 |
+
* The font itself is loaded via Google Fonts CDN in base.html.
|
| 65 |
+
*/
|
| 66 |
+
.material-symbols-outlined {
|
| 67 |
+
font-family: 'Material Symbols Outlined';
|
| 68 |
+
font-weight: normal;
|
| 69 |
+
font-style: normal;
|
| 70 |
+
font-size: 20px;
|
| 71 |
+
line-height: 1;
|
| 72 |
+
letter-spacing: normal;
|
| 73 |
+
text-transform: none;
|
| 74 |
+
display: inline-block;
|
| 75 |
+
white-space: nowrap;
|
| 76 |
+
word-wrap: normal;
|
| 77 |
+
direction: ltr;
|
| 78 |
+
-webkit-font-smoothing: antialiased;
|
| 79 |
+
user-select: none;
|
| 80 |
+
vertical-align: middle;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
/* ── 3. Scrollbar ────────────────────────────────────────────────────────────── */
|
| 84 |
+
|
| 85 |
+
::-webkit-scrollbar { width: 6px; height: 6px; }
|
| 86 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 87 |
+
::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.12); border-radius: 3px; }
|
| 88 |
+
::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.22); }
|
| 89 |
+
|
| 90 |
+
/* ── 4. Focus ring ──────────────────────────────────────────────────────────── */
|
| 91 |
+
|
| 92 |
+
:focus-visible {
|
| 93 |
+
outline: 2px solid #c0c1ff;
|
| 94 |
+
outline-offset: 2px;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
/* ── 5. Layout helpers ──────────────────────────────────────────────────────── */
|
| 98 |
+
|
| 99 |
+
.glass-panel {
|
| 100 |
+
background-color: rgba(23, 31, 51, 0.75);
|
| 101 |
+
backdrop-filter: blur(12px);
|
| 102 |
+
-webkit-backdrop-filter: blur(12px);
|
| 103 |
+
border: 1px solid rgba(255, 255, 255, 0.06);
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
/* Sidebar slide-in / slide-out on mobile */
|
| 107 |
+
.sidebar {
|
| 108 |
+
transform: translateX(-100%);
|
| 109 |
+
transition: transform 250ms ease-out;
|
| 110 |
+
}
|
| 111 |
+
.sidebar.sidebar--open {
|
| 112 |
+
transform: translateX(0);
|
| 113 |
+
}
|
| 114 |
+
@media (min-width: 768px) {
|
| 115 |
+
.sidebar {
|
| 116 |
+
transform: translateX(0);
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
/* Mobile overlay (backdrop) */
|
| 121 |
+
.sidebar-overlay {
|
| 122 |
+
display: none;
|
| 123 |
+
position: fixed;
|
| 124 |
+
inset: 0;
|
| 125 |
+
background-color: rgba(0, 0, 0, 0.6);
|
| 126 |
+
backdrop-filter: blur(4px);
|
| 127 |
+
-webkit-backdrop-filter: blur(4px);
|
| 128 |
+
z-index: 40;
|
| 129 |
+
}
|
| 130 |
+
.sidebar-overlay.sidebar-overlay--visible {
|
| 131 |
+
display: block;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
/* ── 6. Navigation ──────────────────────────────────────────────────────────── */
|
| 135 |
+
|
| 136 |
+
.nav-item {
|
| 137 |
+
display: flex;
|
| 138 |
+
align-items: center;
|
| 139 |
+
gap: 12px;
|
| 140 |
+
padding: 10px 12px;
|
| 141 |
+
border-radius: 8px;
|
| 142 |
+
color: #c7c4d7;
|
| 143 |
+
font-size: 14px;
|
| 144 |
+
line-height: 20px;
|
| 145 |
+
font-weight: 500;
|
| 146 |
+
text-decoration: none;
|
| 147 |
+
cursor: pointer;
|
| 148 |
+
transition: color 150ms ease, background-color 150ms ease;
|
| 149 |
+
}
|
| 150 |
+
.nav-item:hover {
|
| 151 |
+
background-color: rgba(255, 255, 255, 0.05);
|
| 152 |
+
color: #dae2fd;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
.nav-item-active {
|
| 156 |
+
display: flex;
|
| 157 |
+
align-items: center;
|
| 158 |
+
gap: 12px;
|
| 159 |
+
padding: 10px 12px;
|
| 160 |
+
border-radius: 8px;
|
| 161 |
+
color: #c0c1ff;
|
| 162 |
+
background-color: rgba(255, 255, 255, 0.07);
|
| 163 |
+
border-right: 2px solid #c0c1ff;
|
| 164 |
+
font-size: 14px;
|
| 165 |
+
line-height: 20px;
|
| 166 |
+
font-weight: 600;
|
| 167 |
+
text-decoration: none;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
/* ── 7. Badges ──────────────────────────────────────────────────────────────── */
|
| 171 |
+
/*
|
| 172 |
+
* All badges share the same structural CSS. The colour variant is applied
|
| 173 |
+
* via the class suffix. Each badge is intentionally uppercase + monospace
|
| 174 |
+
* to match the React component styling.
|
| 175 |
+
*/
|
| 176 |
+
|
| 177 |
+
.badge-base {
|
| 178 |
+
display: inline-flex;
|
| 179 |
+
align-items: center;
|
| 180 |
+
gap: 6px;
|
| 181 |
+
padding: 2px 8px;
|
| 182 |
+
border-radius: 9999px;
|
| 183 |
+
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
| 184 |
+
font-size: 11px;
|
| 185 |
+
line-height: 16px;
|
| 186 |
+
font-weight: 500;
|
| 187 |
+
letter-spacing: 0.06em;
|
| 188 |
+
text-transform: uppercase;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.badge-positive {
|
| 192 |
+
display: inline-flex;
|
| 193 |
+
align-items: center;
|
| 194 |
+
gap: 6px;
|
| 195 |
+
padding: 2px 8px;
|
| 196 |
+
border-radius: 9999px;
|
| 197 |
+
background-color: rgba(78, 222, 163, 0.10);
|
| 198 |
+
color: #4edea3;
|
| 199 |
+
border: 1px solid rgba(78, 222, 163, 0.25);
|
| 200 |
+
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
| 201 |
+
font-size: 11px;
|
| 202 |
+
line-height: 16px;
|
| 203 |
+
font-weight: 500;
|
| 204 |
+
letter-spacing: 0.06em;
|
| 205 |
+
text-transform: uppercase;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
.badge-negative {
|
| 209 |
+
display: inline-flex;
|
| 210 |
+
align-items: center;
|
| 211 |
+
gap: 6px;
|
| 212 |
+
padding: 2px 8px;
|
| 213 |
+
border-radius: 9999px;
|
| 214 |
+
background-color: rgba(255, 180, 171, 0.10);
|
| 215 |
+
color: #ffb4ab;
|
| 216 |
+
border: 1px solid rgba(255, 180, 171, 0.25);
|
| 217 |
+
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
| 218 |
+
font-size: 11px;
|
| 219 |
+
line-height: 16px;
|
| 220 |
+
font-weight: 500;
|
| 221 |
+
letter-spacing: 0.06em;
|
| 222 |
+
text-transform: uppercase;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
.badge-neutral {
|
| 226 |
+
display: inline-flex;
|
| 227 |
+
align-items: center;
|
| 228 |
+
gap: 6px;
|
| 229 |
+
padding: 2px 8px;
|
| 230 |
+
border-radius: 9999px;
|
| 231 |
+
background-color: rgba(144, 143, 160, 0.10);
|
| 232 |
+
color: #c7c4d7;
|
| 233 |
+
border: 1px solid rgba(144, 143, 160, 0.25);
|
| 234 |
+
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
| 235 |
+
font-size: 11px;
|
| 236 |
+
line-height: 16px;
|
| 237 |
+
font-weight: 500;
|
| 238 |
+
letter-spacing: 0.06em;
|
| 239 |
+
text-transform: uppercase;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
.badge-processing {
|
| 243 |
+
display: inline-flex;
|
| 244 |
+
align-items: center;
|
| 245 |
+
gap: 6px;
|
| 246 |
+
padding: 2px 8px;
|
| 247 |
+
border-radius: 9999px;
|
| 248 |
+
background-color: rgba(192, 193, 255, 0.10);
|
| 249 |
+
color: #c0c1ff;
|
| 250 |
+
border: 1px solid rgba(192, 193, 255, 0.25);
|
| 251 |
+
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
| 252 |
+
font-size: 11px;
|
| 253 |
+
line-height: 16px;
|
| 254 |
+
font-weight: 500;
|
| 255 |
+
letter-spacing: 0.06em;
|
| 256 |
+
text-transform: uppercase;
|
| 257 |
+
animation: pulse-badge 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
.badge-error {
|
| 261 |
+
display: inline-flex;
|
| 262 |
+
align-items: center;
|
| 263 |
+
gap: 6px;
|
| 264 |
+
padding: 2px 8px;
|
| 265 |
+
border-radius: 9999px;
|
| 266 |
+
background-color: rgba(255, 180, 171, 0.10);
|
| 267 |
+
color: #ffb4ab;
|
| 268 |
+
border: 1px solid rgba(255, 180, 171, 0.25);
|
| 269 |
+
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
| 270 |
+
font-size: 11px;
|
| 271 |
+
line-height: 16px;
|
| 272 |
+
font-weight: 500;
|
| 273 |
+
letter-spacing: 0.06em;
|
| 274 |
+
text-transform: uppercase;
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
/* Dot inside badge */
|
| 278 |
+
.badge-dot {
|
| 279 |
+
width: 6px;
|
| 280 |
+
height: 6px;
|
| 281 |
+
border-radius: 9999px;
|
| 282 |
+
flex-shrink: 0;
|
| 283 |
+
display: inline-block;
|
| 284 |
+
}
|
| 285 |
+
.badge-dot--positive { background-color: #4edea3; }
|
| 286 |
+
.badge-dot--negative { background-color: #ffb4ab; }
|
| 287 |
+
.badge-dot--neutral { background-color: #908fa0; }
|
| 288 |
+
.badge-dot--primary { background-color: #c0c1ff; }
|
| 289 |
+
.badge-dot--error { background-color: #ffb4ab; }
|
| 290 |
+
|
| 291 |
+
/* ── 8. Highlights (annotated text) ─────────────────────────────────────────── */
|
| 292 |
+
/*
|
| 293 |
+
* Applied by the server-side annotated-text builder (Phase 3) to wrap
|
| 294 |
+
* aspect spans inside the review text.
|
| 295 |
+
*/
|
| 296 |
+
|
| 297 |
+
.highlight-positive {
|
| 298 |
+
background-color: rgba(78, 222, 163, 0.15);
|
| 299 |
+
color: #4edea3;
|
| 300 |
+
border: 1px solid rgba(78, 222, 163, 0.30);
|
| 301 |
+
border-radius: 4px;
|
| 302 |
+
padding: 0 4px;
|
| 303 |
+
margin: 0 2px;
|
| 304 |
+
font-weight: 500;
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
.highlight-negative {
|
| 308 |
+
background-color: rgba(255, 180, 171, 0.15);
|
| 309 |
+
color: #ffb4ab;
|
| 310 |
+
border: 1px solid rgba(255, 180, 171, 0.30);
|
| 311 |
+
border-radius: 4px;
|
| 312 |
+
padding: 0 4px;
|
| 313 |
+
margin: 0 2px;
|
| 314 |
+
font-weight: 500;
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
.highlight-neutral {
|
| 318 |
+
background-color: rgba(144, 143, 160, 0.15);
|
| 319 |
+
color: #c7c4d7;
|
| 320 |
+
border: 1px solid rgba(144, 143, 160, 0.25);
|
| 321 |
+
border-radius: 4px;
|
| 322 |
+
padding: 0 4px;
|
| 323 |
+
margin: 0 2px;
|
| 324 |
+
font-weight: 500;
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
/* ── 9. Cards ────────────────────────────────────────────────────────────────── */
|
| 328 |
+
|
| 329 |
+
.card {
|
| 330 |
+
background-color: #171f33;
|
| 331 |
+
border-radius: 12px;
|
| 332 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 333 |
+
padding: 24px;
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
.card-low {
|
| 337 |
+
background-color: #131b2e;
|
| 338 |
+
border-radius: 12px;
|
| 339 |
+
border: 1px solid rgba(255, 255, 255, 0.06);
|
| 340 |
+
padding: 24px;
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
.stat-card {
|
| 344 |
+
background-color: #171f33;
|
| 345 |
+
border-radius: 12px;
|
| 346 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 347 |
+
padding: 24px;
|
| 348 |
+
position: relative;
|
| 349 |
+
overflow: hidden;
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
/* ── 10. Form controls ──────────────────────────────────────────────────────── */
|
| 353 |
+
|
| 354 |
+
.input-base {
|
| 355 |
+
background-color: #0b1326;
|
| 356 |
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
| 357 |
+
border-radius: 8px;
|
| 358 |
+
padding: 8px 12px;
|
| 359 |
+
font-size: 14px;
|
| 360 |
+
line-height: 20px;
|
| 361 |
+
color: #dae2fd;
|
| 362 |
+
width: 100%;
|
| 363 |
+
transition: border-color 150ms ease, box-shadow 150ms ease;
|
| 364 |
+
appearance: none;
|
| 365 |
+
-webkit-appearance: none;
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
.input-base::placeholder {
|
| 369 |
+
color: rgba(199, 196, 215, 0.60);
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
.input-base:focus {
|
| 373 |
+
outline: none;
|
| 374 |
+
border-color: #c0c1ff;
|
| 375 |
+
box-shadow: 0 0 0 1px rgba(192, 193, 255, 0.40);
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
/* Select arrow */
|
| 379 |
+
select.input-base {
|
| 380 |
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23c7c4d7' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
| 381 |
+
background-repeat: no-repeat;
|
| 382 |
+
background-position: right 10px center;
|
| 383 |
+
padding-right: 32px;
|
| 384 |
+
cursor: pointer;
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
/* Textarea */
|
| 388 |
+
textarea.input-base {
|
| 389 |
+
resize: vertical;
|
| 390 |
+
font-family: inherit;
|
| 391 |
+
line-height: 1.6;
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
/* ── 11. Button — primary ────────────────────────────────────────────────────── */
|
| 395 |
+
|
| 396 |
+
.btn-primary {
|
| 397 |
+
display: inline-flex;
|
| 398 |
+
align-items: center;
|
| 399 |
+
justify-content: center;
|
| 400 |
+
gap: 8px;
|
| 401 |
+
background-color: #c0c1ff;
|
| 402 |
+
color: #1000a9;
|
| 403 |
+
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
| 404 |
+
font-size: 12px;
|
| 405 |
+
line-height: 16px;
|
| 406 |
+
font-weight: 500;
|
| 407 |
+
letter-spacing: 0.05em;
|
| 408 |
+
padding: 10px 20px;
|
| 409 |
+
border-radius: 8px;
|
| 410 |
+
border: none;
|
| 411 |
+
cursor: pointer;
|
| 412 |
+
text-decoration: none;
|
| 413 |
+
transition: filter 150ms ease, transform 150ms ease;
|
| 414 |
+
white-space: nowrap;
|
| 415 |
+
}
|
| 416 |
+
.btn-primary:hover { filter: brightness(1.10); }
|
| 417 |
+
.btn-primary:active { transform: scale(0.98); }
|
| 418 |
+
.btn-primary:disabled,
|
| 419 |
+
.btn-primary[disabled] {
|
| 420 |
+
opacity: 0.50;
|
| 421 |
+
cursor: not-allowed;
|
| 422 |
+
transform: none;
|
| 423 |
+
pointer-events: none;
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
/* ── 12. Drag-and-drop ──────────────────────────────────────────────────────── */
|
| 427 |
+
|
| 428 |
+
.drag-active {
|
| 429 |
+
border-color: rgba(192, 193, 255, 0.70) !important;
|
| 430 |
+
background-color: rgba(192, 193, 255, 0.04) !important;
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
/* ── 13. HTMX indicators ────────────────────────────────────────────────────── */
|
| 434 |
+
/*
|
| 435 |
+
* HTMX adds .htmx-request to the element that triggered the request.
|
| 436 |
+
* Elements with .htmx-indicator are hidden by default and shown during request.
|
| 437 |
+
*/
|
| 438 |
+
.htmx-indicator { display: none; }
|
| 439 |
+
.htmx-request .htmx-indicator { display: flex; }
|
| 440 |
+
.htmx-request.htmx-indicator { display: flex; }
|
| 441 |
+
|
| 442 |
+
/* Progress bar fill animation */
|
| 443 |
+
.progress-bar {
|
| 444 |
+
transition: width 500ms ease;
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
/* ── 14. Animations ──────────────────────────────────────────────────────────── */
|
| 448 |
+
|
| 449 |
+
@keyframes fadeIn {
|
| 450 |
+
from { opacity: 0; }
|
| 451 |
+
to { opacity: 1; }
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
@keyframes slideIn {
|
| 455 |
+
from { opacity: 0; transform: translateY(8px); }
|
| 456 |
+
to { opacity: 1; transform: translateY(0); }
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
@keyframes pulse-badge {
|
| 460 |
+
0%, 100% { opacity: 1; }
|
| 461 |
+
50% { opacity: 0.6; }
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
@keyframes pulse-dot {
|
| 465 |
+
0%, 100% { opacity: 1; }
|
| 466 |
+
50% { opacity: 0.4; }
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
@keyframes spin {
|
| 470 |
+
from { transform: rotate(0deg); }
|
| 471 |
+
to { transform: rotate(360deg); }
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
.animate-fade-in { animation: fadeIn 0.20s ease-out; }
|
| 475 |
+
.animate-slide-in { animation: slideIn 0.25s ease-out; }
|
| 476 |
+
.animate-spin { animation: spin 1s linear infinite; }
|
| 477 |
+
.animate-pulse-slow { animation: pulse-dot 3s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
|
| 478 |
+
|
| 479 |
+
/* ── 15. Toast notifications ─────────────────────────────────────────────────── */
|
| 480 |
+
/*
|
| 481 |
+
* Toasts are managed by Alpine.js appState().
|
| 482 |
+
* Base styles here; position and z-index are set with Tailwind utilities in base.html.
|
| 483 |
+
*/
|
| 484 |
+
.toast {
|
| 485 |
+
padding: 12px 16px;
|
| 486 |
+
border-radius: 8px;
|
| 487 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 488 |
+
background-color: #222a3d;
|
| 489 |
+
color: #dae2fd;
|
| 490 |
+
font-size: 14px;
|
| 491 |
+
max-width: 380px;
|
| 492 |
+
pointer-events: auto;
|
| 493 |
+
transition: opacity 150ms ease, transform 150ms ease;
|
| 494 |
+
}
|
| 495 |
+
.toast--success { border-color: rgba(78, 222, 163, 0.30); color: #4edea3; }
|
| 496 |
+
.toast--error { border-color: rgba(255, 180, 171, 0.30); color: #ffb4ab; }
|
| 497 |
+
.toast--info { border-color: rgba(192, 193, 255, 0.20); color: #dae2fd; }
|
api/app/templates/base.html
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en" class="dark">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>{% block title %}{{ page_title }}{% endblock %} — SentimentAI</title>
|
| 7 |
+
<meta name="description" content="{% block description %}Multilingual Aspect-Based Sentiment Analysis Dashboard{% endblock %}" />
|
| 8 |
+
|
| 9 |
+
{# ── Fonts ────────────────────────────────────────────────────────────────── #}
|
| 10 |
+
{# Inter replaces Geist (same design language, available on Google Fonts CDN). #}
|
| 11 |
+
{# JetBrains Mono is used verbatim from the original tailwind.config.js. #}
|
| 12 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 13 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 14 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
|
| 15 |
+
rel="stylesheet" />
|
| 16 |
+
|
| 17 |
+
{# ── Material Symbols Outlined icon font ──────────────────────────────────── #}
|
| 18 |
+
{# Variable-font version so FILL and wght axes are available (matching React). #}
|
| 19 |
+
<link rel="stylesheet"
|
| 20 |
+
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" />
|
| 21 |
+
|
| 22 |
+
{#
|
| 23 |
+
── Tailwind CSS (Play CDN) ───────────────────────────────────────────────
|
| 24 |
+
The CDN version scans the DOM at runtime and generates utilities on demand.
|
| 25 |
+
This eliminates the npm build step. Custom design tokens from the original
|
| 26 |
+
tailwind.config.js are provided in the tailwind.config object below.
|
| 27 |
+
|
| 28 |
+
NOTE: The config <script> MUST come AFTER the CDN <script> tag.
|
| 29 |
+
#}
|
| 30 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 31 |
+
<script>
|
| 32 |
+
tailwind.config = {
|
| 33 |
+
darkMode: 'class',
|
| 34 |
+
theme: {
|
| 35 |
+
extend: {
|
| 36 |
+
colors: {
|
| 37 |
+
// Surface palette — from tailwind.config.js
|
| 38 |
+
"surface": "#0b1326",
|
| 39 |
+
"surface-dim": "#0b1326",
|
| 40 |
+
"surface-bright": "#31394d",
|
| 41 |
+
"surface-container-lowest": "#060e20",
|
| 42 |
+
"surface-container-low": "#131b2e",
|
| 43 |
+
"surface-container": "#171f33",
|
| 44 |
+
"surface-container-high": "#222a3d",
|
| 45 |
+
"surface-container-highest": "#2d3449",
|
| 46 |
+
"surface-variant": "#2d3449",
|
| 47 |
+
"background": "#0b1326",
|
| 48 |
+
// On-surface
|
| 49 |
+
"on-surface": "#dae2fd",
|
| 50 |
+
"on-surface-variant": "#c7c4d7",
|
| 51 |
+
"on-background": "#dae2fd",
|
| 52 |
+
// Primary
|
| 53 |
+
"primary": "#c0c1ff",
|
| 54 |
+
"primary-container": "#8083ff",
|
| 55 |
+
"on-primary": "#1000a9",
|
| 56 |
+
// Secondary
|
| 57 |
+
"secondary": "#c4c7c9",
|
| 58 |
+
// Tertiary (positive sentiment — green)
|
| 59 |
+
"tertiary": "#4edea3",
|
| 60 |
+
"tertiary-container": "#00885d",
|
| 61 |
+
"on-tertiary": "#003824",
|
| 62 |
+
// Error (negative sentiment — red)
|
| 63 |
+
"error": "#ffb4ab",
|
| 64 |
+
"error-container": "#93000a",
|
| 65 |
+
"on-error": "#690005",
|
| 66 |
+
// Outline
|
| 67 |
+
"outline": "#908fa0",
|
| 68 |
+
"outline-variant": "#464554",
|
| 69 |
+
// Semantic aliases
|
| 70 |
+
"positive": "#4edea3",
|
| 71 |
+
"negative": "#ffb4ab",
|
| 72 |
+
"warning": "#f5c542",
|
| 73 |
+
},
|
| 74 |
+
fontFamily: {
|
| 75 |
+
sans: ["Inter", "ui-sans-serif", "system-ui", "sans-serif"],
|
| 76 |
+
mono: ["JetBrains Mono", "ui-monospace", "monospace"],
|
| 77 |
+
},
|
| 78 |
+
fontSize: {
|
| 79 |
+
"display": ["36px", { lineHeight: "44px", letterSpacing: "-0.025em", fontWeight: "600" }],
|
| 80 |
+
"headline-lg": ["32px", { lineHeight: "40px", letterSpacing: "-0.02em", fontWeight: "600" }],
|
| 81 |
+
"headline-md": ["24px", { lineHeight: "32px", letterSpacing: "-0.01em", fontWeight: "600" }],
|
| 82 |
+
"headline-sm": ["20px", { lineHeight: "28px", fontWeight: "500" }],
|
| 83 |
+
"title-lg": ["16px", { lineHeight: "24px", fontWeight: "600" }],
|
| 84 |
+
"title-md": ["14px", { lineHeight: "20px", fontWeight: "600" }],
|
| 85 |
+
"body-lg": ["16px", { lineHeight: "24px", fontWeight: "400" }],
|
| 86 |
+
"body-md": ["14px", { lineHeight: "20px", fontWeight: "400" }],
|
| 87 |
+
"body-sm": ["12px", { lineHeight: "16px", fontWeight: "400" }],
|
| 88 |
+
"label-lg": ["14px", { lineHeight: "20px", letterSpacing: "0.02em", fontWeight: "500" }],
|
| 89 |
+
"label-md": ["12px", { lineHeight: "16px", letterSpacing: "0.05em", fontWeight: "500" }],
|
| 90 |
+
"label-sm": ["11px", { lineHeight: "16px", letterSpacing: "0.06em", fontWeight: "500" }],
|
| 91 |
+
},
|
| 92 |
+
spacing: {
|
| 93 |
+
"xs": "4px",
|
| 94 |
+
"sm": "8px",
|
| 95 |
+
"md": "12px",
|
| 96 |
+
"lg": "16px",
|
| 97 |
+
"xl": "24px",
|
| 98 |
+
"2xl": "32px",
|
| 99 |
+
"3xl": "48px",
|
| 100 |
+
},
|
| 101 |
+
borderRadius: {
|
| 102 |
+
"sm": "4px",
|
| 103 |
+
"DEFAULT": "6px",
|
| 104 |
+
"md": "8px",
|
| 105 |
+
"lg": "12px",
|
| 106 |
+
"xl": "16px",
|
| 107 |
+
"2xl": "20px",
|
| 108 |
+
"full": "9999px",
|
| 109 |
+
},
|
| 110 |
+
boxShadow: {
|
| 111 |
+
"card": "0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3)",
|
| 112 |
+
"elevated": "0 4px 16px rgba(0,0,0,0.5)",
|
| 113 |
+
"glow-primary": "0 0 20px rgba(192,193,255,0.15)",
|
| 114 |
+
"glow-positive": "0 0 12px rgba(78,222,163,0.20)",
|
| 115 |
+
"glow-negative": "0 0 12px rgba(255,180,171,0.20)",
|
| 116 |
+
},
|
| 117 |
+
backdropBlur: {
|
| 118 |
+
"glass": "12px",
|
| 119 |
+
},
|
| 120 |
+
animation: {
|
| 121 |
+
"pulse-slow": "pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite",
|
| 122 |
+
"fade-in": "fadeIn 0.2s ease-out",
|
| 123 |
+
"slide-in": "slideIn 0.25s ease-out",
|
| 124 |
+
},
|
| 125 |
+
keyframes: {
|
| 126 |
+
fadeIn: { from: { opacity: "0" }, to: { opacity: "1" } },
|
| 127 |
+
slideIn: { from: { opacity: "0", transform: "translateY(8px)" }, to: { opacity: "1", transform: "translateY(0)" } },
|
| 128 |
+
},
|
| 129 |
+
// Safelist ensures Alpine.js-toggled classes are always generated
|
| 130 |
+
safelist: ["translate-x-0", "-translate-x-full"],
|
| 131 |
+
},
|
| 132 |
+
},
|
| 133 |
+
};
|
| 134 |
+
</script>
|
| 135 |
+
|
| 136 |
+
{# ── Custom design system CSS ──────────────────────────────────────────────── #}
|
| 137 |
+
{# Component classes that use @apply in the React version are written as plain #}
|
| 138 |
+
{# CSS in app.css (since the Play CDN does not process @apply in external CSS). #}
|
| 139 |
+
<link rel="stylesheet" href="{{ request.url_for('static', path='css/app.css') }}" />
|
| 140 |
+
|
| 141 |
+
{# ── Per-page extra head content ──────────────────────────────────────────── #}
|
| 142 |
+
{% block head %}{% endblock %}
|
| 143 |
+
</head>
|
| 144 |
+
|
| 145 |
+
<body class="bg-[#0b1326] text-[#dae2fd] antialiased font-sans"
|
| 146 |
+
x-data="appState()"
|
| 147 |
+
@notify.window="addToast($event.detail.msg, $event.detail.type)">
|
| 148 |
+
|
| 149 |
+
{# ── Toast notification container ────────────────────────────────────────── #}
|
| 150 |
+
{# Managed by Alpine.js appState(). HTMX error events dispatch to this. #}
|
| 151 |
+
<div class="fixed top-4 right-4 z-[100] space-y-2 pointer-events-none w-80"
|
| 152 |
+
role="status"
|
| 153 |
+
aria-live="polite"
|
| 154 |
+
aria-atomic="false">
|
| 155 |
+
<template x-for="toast in toasts" :key="toast.id">
|
| 156 |
+
<div x-show="toast.visible"
|
| 157 |
+
x-transition:enter="transition ease-out duration-200"
|
| 158 |
+
x-transition:enter-start="opacity-0 translate-y-1"
|
| 159 |
+
x-transition:enter-end="opacity-100 translate-y-0"
|
| 160 |
+
x-transition:leave="transition ease-in duration-150"
|
| 161 |
+
x-transition:leave-start="opacity-100"
|
| 162 |
+
x-transition:leave-end="opacity-0"
|
| 163 |
+
class="toast pointer-events-auto"
|
| 164 |
+
:class="{
|
| 165 |
+
'toast--success': toast.type === 'success',
|
| 166 |
+
'toast--error': toast.type === 'error',
|
| 167 |
+
'toast--info': toast.type === 'info',
|
| 168 |
+
}"
|
| 169 |
+
x-text="toast.msg">
|
| 170 |
+
</div>
|
| 171 |
+
</template>
|
| 172 |
+
</div>
|
| 173 |
+
|
| 174 |
+
{# ── Page wrapper ─────────────────────────────────────────────────────────── #}
|
| 175 |
+
<div class="min-h-screen flex">
|
| 176 |
+
|
| 177 |
+
{# ── Mobile sidebar overlay ──────────────────────────────────────────────── #}
|
| 178 |
+
<div x-show="sidebarOpen"
|
| 179 |
+
x-transition:enter="transition ease-out duration-200"
|
| 180 |
+
x-transition:enter-start="opacity-0"
|
| 181 |
+
x-transition:enter-end="opacity-100"
|
| 182 |
+
x-transition:leave="transition ease-in duration-150"
|
| 183 |
+
x-transition:leave-start="opacity-100"
|
| 184 |
+
x-transition:leave-end="opacity-0"
|
| 185 |
+
@click="sidebarOpen = false"
|
| 186 |
+
class="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 md:hidden"
|
| 187 |
+
aria-hidden="true"
|
| 188 |
+
style="display: none;"></div>
|
| 189 |
+
|
| 190 |
+
{# ── Sidebar ─────────────────────────────────────────��───────────────────── #}
|
| 191 |
+
{#
|
| 192 |
+
The sidebar is always visible on desktop (md:translate-x-0) and slides in
|
| 193 |
+
on mobile when sidebarOpen is true. Alpine.js toggles the translation via
|
| 194 |
+
:class binding. The safelist in tailwind.config ensures translate-x-0 and
|
| 195 |
+
-translate-x-full are always generated by the CDN.
|
| 196 |
+
#}
|
| 197 |
+
<nav aria-label="Main navigation"
|
| 198 |
+
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
|
| 199 |
+
class="fixed left-0 top-0 h-screen w-64 z-50 flex flex-col
|
| 200 |
+
bg-[#171f33] border-r border-white/[0.07]
|
| 201 |
+
transition-transform duration-[250ms] ease-out
|
| 202 |
+
md:translate-x-0">
|
| 203 |
+
|
| 204 |
+
{# Logo #}
|
| 205 |
+
<div class="flex items-center gap-3 px-xl py-xl">
|
| 206 |
+
<span class="material-symbols-outlined text-[#c0c1ff]"
|
| 207 |
+
style="font-size:28px; font-variation-settings: 'FILL' 1, 'wght' 400;"
|
| 208 |
+
aria-hidden="true">psychology</span>
|
| 209 |
+
<div>
|
| 210 |
+
<p class="text-[16px] font-semibold text-[#c0c1ff] leading-tight">SentimentAI</p>
|
| 211 |
+
<p class="font-mono text-[11px] text-[#c7c4d7] tracking-wider">Analysis Engine v2.4</p>
|
| 212 |
+
</div>
|
| 213 |
+
</div>
|
| 214 |
+
|
| 215 |
+
{# CTA — New Analysis #}
|
| 216 |
+
<div class="px-xl mb-xl">
|
| 217 |
+
<a href="/predict"
|
| 218 |
+
@click="sidebarOpen = false"
|
| 219 |
+
class="btn-primary w-full text-[12px]">
|
| 220 |
+
<span class="material-symbols-outlined"
|
| 221 |
+
style="font-size:18px; font-variation-settings: 'FILL' 0, 'wght' 300;"
|
| 222 |
+
aria-hidden="true">add</span>
|
| 223 |
+
New Analysis
|
| 224 |
+
</a>
|
| 225 |
+
</div>
|
| 226 |
+
|
| 227 |
+
{# Navigation links #}
|
| 228 |
+
<ul class="flex-1 px-sm space-y-0.5 overflow-y-auto" role="list">
|
| 229 |
+
{% for item in nav_items %}
|
| 230 |
+
{% set active = current_path == item.path or (current_path == '/' and item.path == '/predict') %}
|
| 231 |
+
<li role="listitem">
|
| 232 |
+
<a href="{{ item.path }}"
|
| 233 |
+
@click="sidebarOpen = false"
|
| 234 |
+
class="{{ 'nav-item-active' if active else 'nav-item' }}"
|
| 235 |
+
{% if active %}aria-current="page"{% endif %}>
|
| 236 |
+
<span class="material-symbols-outlined"
|
| 237 |
+
style="font-size:20px; font-variation-settings: 'FILL' {{ '1' if active else '0' }}, 'wght' 300;"
|
| 238 |
+
aria-hidden="true">{{ item.icon }}</span>
|
| 239 |
+
<span>{{ item.label }}</span>
|
| 240 |
+
</a>
|
| 241 |
+
</li>
|
| 242 |
+
{% endfor %}
|
| 243 |
+
</ul>
|
| 244 |
+
|
| 245 |
+
{# Bottom section: health pill + secondary nav #}
|
| 246 |
+
<div class="px-sm pt-sm pb-xl border-t border-white/[0.06] space-y-0.5 mt-auto">
|
| 247 |
+
|
| 248 |
+
{# API health indicator — static in Phase 2; HTMX polling added in Phase 5 #}
|
| 249 |
+
<div id="sidebar-health"
|
| 250 |
+
class="flex items-center gap-2 px-3 py-2 mb-1">
|
| 251 |
+
<span class="w-2 h-2 rounded-full bg-[#4edea3] flex-shrink-0 animate-pulse-slow"
|
| 252 |
+
aria-hidden="true"></span>
|
| 253 |
+
<span class="font-mono text-[12px] text-[#c7c4d7]">API Online</span>
|
| 254 |
+
</div>
|
| 255 |
+
|
| 256 |
+
<a href="/monitor"
|
| 257 |
+
@click="sidebarOpen = false"
|
| 258 |
+
class="{{ 'nav-item-active' if current_path == '/monitor' else 'nav-item' }}">
|
| 259 |
+
<span class="material-symbols-outlined"
|
| 260 |
+
style="font-size:20px; font-variation-settings: 'FILL' 0, 'wght' 300;"
|
| 261 |
+
aria-hidden="true">settings</span>
|
| 262 |
+
<span>Settings</span>
|
| 263 |
+
</a>
|
| 264 |
+
|
| 265 |
+
<a href="/docs"
|
| 266 |
+
target="_blank"
|
| 267 |
+
rel="noopener noreferrer"
|
| 268 |
+
class="nav-item">
|
| 269 |
+
<span class="material-symbols-outlined"
|
| 270 |
+
style="font-size:20px; font-variation-settings: 'FILL' 0, 'wght' 300;"
|
| 271 |
+
aria-hidden="true">menu_book</span>
|
| 272 |
+
<span>API Docs</span>
|
| 273 |
+
</a>
|
| 274 |
+
</div>
|
| 275 |
+
</nav>
|
| 276 |
+
|
| 277 |
+
{# ── Main area (right of sidebar) ────────────────────────────────────────── #}
|
| 278 |
+
<div class="flex-1 md:ml-64 flex flex-col min-h-screen">
|
| 279 |
+
|
| 280 |
+
{# Top header bar #}
|
| 281 |
+
<header class="fixed top-0 right-0 left-0 md:left-64 h-16 z-30
|
| 282 |
+
bg-[#0b1326]/80 backdrop-blur-[12px]
|
| 283 |
+
border-b border-white/[0.06]
|
| 284 |
+
flex items-center justify-between px-xl gap-4">
|
| 285 |
+
|
| 286 |
+
<div class="flex items-center gap-3">
|
| 287 |
+
{# Mobile hamburger #}
|
| 288 |
+
<button @click="sidebarOpen = true"
|
| 289 |
+
class="md:hidden p-2 rounded-lg text-[#c7c4d7]
|
| 290 |
+
hover:text-[#dae2fd] hover:bg-white/[0.05]
|
| 291 |
+
transition-colors"
|
| 292 |
+
aria-label="Open navigation"
|
| 293 |
+
aria-expanded="false"
|
| 294 |
+
:aria-expanded="sidebarOpen">
|
| 295 |
+
<span class="material-symbols-outlined"
|
| 296 |
+
style="font-size:22px; font-variation-settings: 'FILL' 0, 'wght' 300;"
|
| 297 |
+
aria-hidden="true">menu</span>
|
| 298 |
+
</button>
|
| 299 |
+
|
| 300 |
+
{# Mobile brand (hidden on desktop) #}
|
| 301 |
+
<div class="md:hidden flex items-center gap-2">
|
| 302 |
+
<span class="material-symbols-outlined text-[#c0c1ff]"
|
| 303 |
+
style="font-size:22px; font-variation-settings: 'FILL' 1, 'wght' 400;"
|
| 304 |
+
aria-hidden="true">psychology</span>
|
| 305 |
+
<span class="font-semibold text-[#dae2fd]">SentimentAI</span>
|
| 306 |
+
</div>
|
| 307 |
+
|
| 308 |
+
{# Desktop page title (hidden on mobile) #}
|
| 309 |
+
<h2 class="hidden md:block text-body-md font-medium text-[#c7c4d7]">
|
| 310 |
+
{{ page_title }}
|
| 311 |
+
</h2>
|
| 312 |
+
</div>
|
| 313 |
+
|
| 314 |
+
{# Right action group #}
|
| 315 |
+
<div class="flex items-center gap-2">
|
| 316 |
+
<button class="p-2 rounded-lg text-[#c7c4d7]
|
| 317 |
+
hover:text-[#dae2fd] hover:bg-white/[0.05]
|
| 318 |
+
transition-colors"
|
| 319 |
+
aria-label="Notifications">
|
| 320 |
+
<span class="material-symbols-outlined"
|
| 321 |
+
style="font-size:20px; font-variation-settings: 'FILL' 0, 'wght' 300;"
|
| 322 |
+
aria-hidden="true">notifications</span>
|
| 323 |
+
</button>
|
| 324 |
+
|
| 325 |
+
<a href="/docs"
|
| 326 |
+
target="_blank"
|
| 327 |
+
rel="noopener noreferrer"
|
| 328 |
+
class="hidden sm:flex items-center gap-1.5 px-3 py-1.5
|
| 329 |
+
border border-white/[0.12] rounded-lg
|
| 330 |
+
font-mono text-[12px] text-[#c7c4d7]
|
| 331 |
+
hover:text-[#dae2fd] hover:border-white/25
|
| 332 |
+
transition-colors duration-150">
|
| 333 |
+
<span class="material-symbols-outlined"
|
| 334 |
+
style="font-size:14px; font-variation-settings: 'FILL' 0, 'wght' 300;"
|
| 335 |
+
aria-hidden="true">api</span>
|
| 336 |
+
API Docs
|
| 337 |
+
</a>
|
| 338 |
+
|
| 339 |
+
{# Avatar placeholder #}
|
| 340 |
+
<div class="w-8 h-8 rounded-full bg-[#222a3d]
|
| 341 |
+
border border-white/[0.12]
|
| 342 |
+
flex items-center justify-center
|
| 343 |
+
text-[#c7c4d7] select-none"
|
| 344 |
+
role="img"
|
| 345 |
+
aria-label="User avatar">
|
| 346 |
+
<span class="material-symbols-outlined"
|
| 347 |
+
style="font-size:18px; font-variation-settings: 'FILL' 0, 'wght' 300;"
|
| 348 |
+
aria-hidden="true">person</span>
|
| 349 |
+
</div>
|
| 350 |
+
</div>
|
| 351 |
+
</header>
|
| 352 |
+
|
| 353 |
+
{# Page content area #}
|
| 354 |
+
<main class="flex-1 pt-16 overflow-y-auto" id="main-content">
|
| 355 |
+
<div class="max-w-[1280px] mx-auto px-lg md:px-3xl py-xl md:py-2xl">
|
| 356 |
+
{% block content %}{% endblock %}
|
| 357 |
+
</div>
|
| 358 |
+
</main>
|
| 359 |
+
|
| 360 |
+
{# Footer #}
|
| 361 |
+
<footer class="border-t border-white/[0.05] py-4 text-center font-mono text-label-sm text-[#c7c4d7]/50">
|
| 362 |
+
SentimentAI — Multilingual ABSA Dashboard
|
| 363 |
+
</footer>
|
| 364 |
+
|
| 365 |
+
</div>{# /main area #}
|
| 366 |
+
</div>{# /page wrapper #}
|
| 367 |
+
|
| 368 |
+
{# ── JavaScript ────────────────────────────────────────────────────────────── #}
|
| 369 |
+
|
| 370 |
+
{#
|
| 371 |
+
Alpine.js state — defined BEFORE the defer script so appState() is in scope
|
| 372 |
+
when Alpine.js initialises after DOMContentLoaded.
|
| 373 |
+
#}
|
| 374 |
+
<script>
|
| 375 |
+
/**
|
| 376 |
+
* appState()
|
| 377 |
+
* ----------
|
| 378 |
+
* Root Alpine.js component mounted on <body>.
|
| 379 |
+
*
|
| 380 |
+
* Responsibilities:
|
| 381 |
+
* - sidebarOpen : mobile sidebar toggle state
|
| 382 |
+
* - toasts : notification queue for HTMX errors and success messages
|
| 383 |
+
* - addToast() : push a message (called via @notify.window event)
|
| 384 |
+
*
|
| 385 |
+
* HTMX events are converted to CustomEvents ("notify") which Alpine.js
|
| 386 |
+
* listens for via @notify.window. This keeps HTMX and Alpine.js decoupled.
|
| 387 |
+
*/
|
| 388 |
+
function appState() {
|
| 389 |
+
return {
|
| 390 |
+
sidebarOpen: false,
|
| 391 |
+
toasts: [],
|
| 392 |
+
|
| 393 |
+
addToast(msg, type = 'info') {
|
| 394 |
+
const toast = { id: Date.now() + Math.random(), msg, type, visible: true };
|
| 395 |
+
this.toasts.push(toast);
|
| 396 |
+
// Auto-dismiss after 3.5 s
|
| 397 |
+
setTimeout(() => {
|
| 398 |
+
toast.visible = false;
|
| 399 |
+
// Remove from array after fade-out completes
|
| 400 |
+
setTimeout(() => {
|
| 401 |
+
this.toasts = this.toasts.filter(t => t.id !== toast.id);
|
| 402 |
+
}, 200);
|
| 403 |
+
}, 3500);
|
| 404 |
+
},
|
| 405 |
+
};
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
// ── HTMX → notify bridge ─────────────────────────────────────────────────
|
| 409 |
+
// Translate HTMX lifecycle events into the "notify" CustomEvent so Alpine.js
|
| 410 |
+
// can display them without tightly coupling HTMX handlers to Alpine components.
|
| 411 |
+
|
| 412 |
+
document.addEventListener('htmx:responseError', function (e) {
|
| 413 |
+
let msg = 'Request failed. Please try again.';
|
| 414 |
+
try {
|
| 415 |
+
const body = JSON.parse(e.detail.xhr.responseText);
|
| 416 |
+
msg = body.detail || msg;
|
| 417 |
+
} catch (_) { /* not JSON — use default */ }
|
| 418 |
+
window.dispatchEvent(new CustomEvent('notify', { detail: { msg, type: 'error' } }));
|
| 419 |
+
});
|
| 420 |
+
|
| 421 |
+
document.addEventListener('htmx:sendError', function () {
|
| 422 |
+
window.dispatchEvent(new CustomEvent('notify', {
|
| 423 |
+
detail: { msg: 'Network error — check your connection.', type: 'error' }
|
| 424 |
+
}));
|
| 425 |
+
});
|
| 426 |
+
</script>
|
| 427 |
+
|
| 428 |
+
{# HTMX — loaded deferred so it does not block rendering #}
|
| 429 |
+
<script src="https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js" defer></script>
|
| 430 |
+
|
| 431 |
+
{# Alpine.js v3 — loaded deferred; appState() above is already in global scope #}
|
| 432 |
+
<script defer src="https://unpkg.com/alpinejs@3.14.1/dist/cdn.min.js"></script>
|
| 433 |
+
|
| 434 |
+
{# ── Per-page extra scripts ───────────────────────────────────────────────── #}
|
| 435 |
+
{% block scripts %}{% endblock %}
|
| 436 |
+
|
| 437 |
+
</body>
|
| 438 |
+
</html>
|
api/app/templates/macros/ui.html
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{#
|
| 2 |
+
macros/ui.html — Reusable Jinja2 macros for the SentimentAI dashboard.
|
| 3 |
+
|
| 4 |
+
WHY THIS FILE EXISTS
|
| 5 |
+
--------------------
|
| 6 |
+
The React components (Sidebar.jsx, Monitor.jsx, Analytics.jsx) defined small
|
| 7 |
+
helper components (MSIcon, StatusBadge, HealthChip, InfoRow) that were reused
|
| 8 |
+
across pages. Jinja2 macros provide the equivalent pattern: define once,
|
| 9 |
+
import anywhere.
|
| 10 |
+
|
| 11 |
+
USAGE IN TEMPLATES
|
| 12 |
+
------------------
|
| 13 |
+
{% from "macros/ui.html" import ms_icon, status_badge, health_chip, sentiment_badge %}
|
| 14 |
+
|
| 15 |
+
Each macro produces self-contained HTML with no JavaScript dependencies.
|
| 16 |
+
CSS classes reference app.css component classes (badge-*, highlight-*) and
|
| 17 |
+
Tailwind utilities from the CDN.
|
| 18 |
+
#}
|
| 19 |
+
|
| 20 |
+
{# ── Material Symbols Outlined icon ───────────────────────────────────────── #}
|
| 21 |
+
{#
|
| 22 |
+
ms_icon(name, filled, size, cls)
|
| 23 |
+
---------------------------------
|
| 24 |
+
Renders a Material Symbols Outlined icon span.
|
| 25 |
+
|
| 26 |
+
Parameters
|
| 27 |
+
----------
|
| 28 |
+
name : str — icon name e.g. "psychology", "cloud_upload", "bolt"
|
| 29 |
+
filled : bool — whether to use FILL=1 (solid) or FILL=0 (outlined)
|
| 30 |
+
size : int — font-size in px (default 20)
|
| 31 |
+
cls : str — extra CSS classes appended to the span
|
| 32 |
+
#}
|
| 33 |
+
{% macro ms_icon(name, filled=False, size=20, cls='') %}
|
| 34 |
+
<span class="material-symbols-outlined {{ cls }}"
|
| 35 |
+
style="font-size: {{ size }}px; font-variation-settings: 'FILL' {{ 1 if filled else 0 }}, 'wght' {{ 400 if filled else 300 }};"
|
| 36 |
+
aria-hidden="true">{{ name }}</span>
|
| 37 |
+
{% endmacro %}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
{# ── Status badge ─────────────────────────────────────────────────────────── #}
|
| 41 |
+
{#
|
| 42 |
+
status_badge(status)
|
| 43 |
+
---------------------
|
| 44 |
+
Renders a coloured badge for a batch job status.
|
| 45 |
+
|
| 46 |
+
status values: "completed" | "processing" | "queued" | "failed"
|
| 47 |
+
#}
|
| 48 |
+
{% macro status_badge(status) %}
|
| 49 |
+
{% if status == 'completed' %}
|
| 50 |
+
<span class="badge-positive">
|
| 51 |
+
<span class="badge-dot badge-dot--positive"></span>
|
| 52 |
+
Completed
|
| 53 |
+
</span>
|
| 54 |
+
{% elif status == 'processing' %}
|
| 55 |
+
<span class="badge-processing">
|
| 56 |
+
<span class="badge-dot badge-dot--primary animate-pulse-slow"></span>
|
| 57 |
+
Processing
|
| 58 |
+
</span>
|
| 59 |
+
{% elif status == 'queued' %}
|
| 60 |
+
<span class="badge-neutral">
|
| 61 |
+
<span class="badge-dot badge-dot--neutral"></span>
|
| 62 |
+
Queued
|
| 63 |
+
</span>
|
| 64 |
+
{% elif status == 'failed' %}
|
| 65 |
+
<span class="badge-error">
|
| 66 |
+
<span class="badge-dot badge-dot--error"></span>
|
| 67 |
+
Failed
|
| 68 |
+
</span>
|
| 69 |
+
{% else %}
|
| 70 |
+
<span class="badge-neutral">{{ status }}</span>
|
| 71 |
+
{% endif %}
|
| 72 |
+
{% endmacro %}
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
{# ── Health chip ──────────────────────────────────────────────────────────── #}
|
| 76 |
+
{#
|
| 77 |
+
health_chip(ok)
|
| 78 |
+
----------------
|
| 79 |
+
Renders a "Healthy" or "Degraded" status chip.
|
| 80 |
+
|
| 81 |
+
ok : bool — True if the API health check returned status=="ok"
|
| 82 |
+
#}
|
| 83 |
+
{% macro health_chip(ok) %}
|
| 84 |
+
{% if ok %}
|
| 85 |
+
<span class="badge-positive">
|
| 86 |
+
<span class="badge-dot badge-dot--positive animate-pulse-slow"></span>
|
| 87 |
+
Healthy
|
| 88 |
+
</span>
|
| 89 |
+
{% else %}
|
| 90 |
+
<span class="badge-error">
|
| 91 |
+
<span class="badge-dot badge-dot--error animate-pulse-slow"></span>
|
| 92 |
+
Degraded
|
| 93 |
+
</span>
|
| 94 |
+
{% endif %}
|
| 95 |
+
{% endmacro %}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
{# ── Sentiment badge ──────────────────────────────────────────────────────── #}
|
| 99 |
+
{#
|
| 100 |
+
sentiment_badge(sentiment)
|
| 101 |
+
---------------------------
|
| 102 |
+
Renders the sentiment label for an aspect card.
|
| 103 |
+
|
| 104 |
+
sentiment values: "positive" | "negative" | "neutral" | "conflict"
|
| 105 |
+
#}
|
| 106 |
+
{% macro sentiment_badge(sentiment) %}
|
| 107 |
+
{% if sentiment == 'positive' %}
|
| 108 |
+
<span class="badge-positive">
|
| 109 |
+
<span class="badge-dot badge-dot--positive"></span>
|
| 110 |
+
{{ sentiment }}
|
| 111 |
+
</span>
|
| 112 |
+
{% elif sentiment == 'negative' %}
|
| 113 |
+
<span class="badge-negative">
|
| 114 |
+
<span class="badge-dot badge-dot--negative"></span>
|
| 115 |
+
{{ sentiment }}
|
| 116 |
+
</span>
|
| 117 |
+
{% elif sentiment == 'conflict' %}
|
| 118 |
+
<span class="badge-error">
|
| 119 |
+
<span class="badge-dot badge-dot--error"></span>
|
| 120 |
+
{{ sentiment }}
|
| 121 |
+
</span>
|
| 122 |
+
{% else %}
|
| 123 |
+
<span class="badge-neutral">
|
| 124 |
+
<span class="badge-dot badge-dot--neutral"></span>
|
| 125 |
+
{{ sentiment }}
|
| 126 |
+
</span>
|
| 127 |
+
{% endif %}
|
| 128 |
+
{% endmacro %}
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
{# ── Info row (used on Monitor page) ─────────────────────────────────────── #}
|
| 132 |
+
{#
|
| 133 |
+
info_row(label, value, value_cls)
|
| 134 |
+
-----------------------------------
|
| 135 |
+
Renders a labelled key-value cell inside the Model Configuration card.
|
| 136 |
+
#}
|
| 137 |
+
{% macro info_row(label, value, value_cls='') %}
|
| 138 |
+
<div class="bg-[#0b1326] rounded-lg p-3">
|
| 139 |
+
<p class="font-mono text-[11px] text-[#c7c4d7] mb-1 uppercase tracking-wide">{{ label }}</p>
|
| 140 |
+
<p class="text-sm text-[#dae2fd] font-medium {{ value_cls }}">{{ value }}</p>
|
| 141 |
+
</div>
|
| 142 |
+
{% endmacro %}
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
{# ── Loaded badge (aspect/sentiment model status) ────────────────────────── #}
|
| 146 |
+
{% macro loaded_badge() %}
|
| 147 |
+
<div class="flex items-center gap-1.5 text-sm text-[#dae2fd] font-medium">
|
| 148 |
+
{{ ms_icon('check_circle', filled=False, size=16, cls='text-[#4edea3]') }}
|
| 149 |
+
Loaded
|
| 150 |
+
</div>
|
| 151 |
+
{% endmacro %}
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
{# ── Skeleton placeholder (used during HTMX loading states) ─────────────── #}
|
| 155 |
+
{#
|
| 156 |
+
skeleton(height, width_cls)
|
| 157 |
+
----------------------------
|
| 158 |
+
Renders a pulsing skeleton placeholder matching the React Skeleton component.
|
| 159 |
+
#}
|
| 160 |
+
{% macro skeleton(height='h-4', width_cls='w-full') %}
|
| 161 |
+
<div class="animate-pulse bg-[#222a3d] rounded {{ height }} {{ width_cls }}"></div>
|
| 162 |
+
{% endmacro %}
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
{# ── Empty-state panel ────────────────────────────────────────────────────── #}
|
| 166 |
+
{#
|
| 167 |
+
empty_state(icon, message)
|
| 168 |
+
---------------------------
|
| 169 |
+
Centred icon + message for panels with no data yet.
|
| 170 |
+
#}
|
| 171 |
+
{% macro empty_state(icon='psychology', message='No data yet') %}
|
| 172 |
+
<div class="flex flex-col items-center justify-center gap-4 py-16 text-[#c7c4d7]">
|
| 173 |
+
<span class="material-symbols-outlined opacity-30"
|
| 174 |
+
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 200;"
|
| 175 |
+
aria-hidden="true">{{ icon }}</span>
|
| 176 |
+
<p class="text-sm opacity-60">{{ message }}</p>
|
| 177 |
+
</div>
|
| 178 |
+
{% endmacro %}
|
api/app/templates/pages/batch.html
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% from "macros/ui.html" import ms_icon, status_badge, empty_state %}
|
| 3 |
+
|
| 4 |
+
{% block title %}Batch Analytics{% endblock %}
|
| 5 |
+
{% block description %}Upload a CSV of reviews for bulk aspect-based sentiment analysis.{% endblock %}
|
| 6 |
+
|
| 7 |
+
{% block content %}
|
| 8 |
+
{#
|
| 9 |
+
Phase 2: Placeholder layout for the Batch Analytics page.
|
| 10 |
+
|
| 11 |
+
What this page will contain after Phase 4:
|
| 12 |
+
- Upload zone : drag-and-drop CSV input (HTML5 + Alpine.js drag events)
|
| 13 |
+
- Progress card: job status + progress bar polling via hx-trigger="every 2s"
|
| 14 |
+
- Recent table : DB-queried list of past batch jobs
|
| 15 |
+
- Charts : post-completion AspectHeatmap, LanguagePie, SentimentChart
|
| 16 |
+
(Chart.js, rendered with server-provided JSON data)
|
| 17 |
+
|
| 18 |
+
The page structure below mirrors Analytics.jsx exactly so Phase 4 only needs to
|
| 19 |
+
replace placeholder content with functional forms and HTMX targets.
|
| 20 |
+
#}
|
| 21 |
+
<div class="space-y-xl">
|
| 22 |
+
|
| 23 |
+
{# ── Page header ─────────────────────────────────────────────────────────── #}
|
| 24 |
+
<div>
|
| 25 |
+
<h1 class="text-headline-md text-[#dae2fd]">Batch Analytics</h1>
|
| 26 |
+
<p class="mt-1 text-body-md text-[#c7c4d7]">
|
| 27 |
+
Upload a CSV of reviews for bulk aspect-based sentiment analysis.
|
| 28 |
+
</p>
|
| 29 |
+
</div>
|
| 30 |
+
|
| 31 |
+
{# ── Upload zone placeholder ──────────────────────────────────────────────── #}
|
| 32 |
+
<section class="card" aria-label="File upload">
|
| 33 |
+
<div class="border-2 border-dashed border-white/[0.14] rounded-xl p-12
|
| 34 |
+
flex flex-col items-center justify-center text-center">
|
| 35 |
+
{{ ms_icon('cloud_upload', size=48, cls='text-[#c7c4d7] mb-4') }}
|
| 36 |
+
<h2 class="text-headline-sm text-[#dae2fd] mb-2">
|
| 37 |
+
Drag & drop a CSV, or click to select
|
| 38 |
+
</h2>
|
| 39 |
+
<p class="text-body-md text-[#c7c4d7] max-w-sm">
|
| 40 |
+
Must contain a <code class="font-mono text-[#c0c1ff] px-1">text</code> column.
|
| 41 |
+
Maximum 10,000 rows. Files are deleted after analysis.
|
| 42 |
+
</p>
|
| 43 |
+
</div>
|
| 44 |
+
|
| 45 |
+
{# Phase 2 notice #}
|
| 46 |
+
<div class="mt-lg flex items-start gap-3 p-md rounded-lg bg-[#c0c1ff]/5 border border-[#c0c1ff]/20">
|
| 47 |
+
{{ ms_icon('info', size=18, cls='text-[#c0c1ff] mt-0.5 flex-shrink-0') }}
|
| 48 |
+
<div>
|
| 49 |
+
<p class="text-body-sm text-[#c7c4d7]">
|
| 50 |
+
File upload and batch processing are fully operational via
|
| 51 |
+
<code class="font-mono text-[#c0c1ff] text-xs">POST /batch</code>.
|
| 52 |
+
HTMX drag-and-drop UI and progress polling will be added in Phase 4.
|
| 53 |
+
<a href="/docs#/Predict/predict_batch_batch_post"
|
| 54 |
+
target="_blank"
|
| 55 |
+
class="text-[#c0c1ff] hover:underline ml-1">Test the API →</a>
|
| 56 |
+
</p>
|
| 57 |
+
</div>
|
| 58 |
+
</div>
|
| 59 |
+
</section>
|
| 60 |
+
|
| 61 |
+
{# ── Recent batches table placeholder ─────────────────────────────────────── #}
|
| 62 |
+
<section aria-label="Recent batch jobs">
|
| 63 |
+
<h2 class="text-headline-sm text-[#dae2fd] mb-lg">Recent Batches</h2>
|
| 64 |
+
<div class="card overflow-hidden p-0">
|
| 65 |
+
<div class="overflow-x-auto">
|
| 66 |
+
<table class="w-full text-left border-collapse" aria-label="Recent batch jobs">
|
| 67 |
+
<thead>
|
| 68 |
+
<tr class="border-b border-white/[0.08] bg-[#222a3d]/50">
|
| 69 |
+
<th scope="col" class="font-mono text-label-sm text-[#c7c4d7] py-3 px-xl">Filename</th>
|
| 70 |
+
<th scope="col" class="font-mono text-label-sm text-[#c7c4d7] py-3 px-lg">Rows</th>
|
| 71 |
+
<th scope="col" class="font-mono text-label-sm text-[#c7c4d7] py-3 px-lg">Status</th>
|
| 72 |
+
<th scope="col" class="font-mono text-label-sm text-[#c7c4d7] py-3 px-xl text-right">Date</th>
|
| 73 |
+
</tr>
|
| 74 |
+
</thead>
|
| 75 |
+
<tbody class="divide-y divide-white/[0.05]">
|
| 76 |
+
{# Phase 2: static mock rows matching React's Analytics.jsx mock data #}
|
| 77 |
+
{% for row in [
|
| 78 |
+
{'name': 'q3_customer_feedback.csv', 'rows': '4,250', 'status': 'completed', 'date': 'Today, 14:32'},
|
| 79 |
+
{'name': 'product_launch_tweets.csv', 'rows': '8,912', 'status': 'processing', 'date': 'Today, 14:15'},
|
| 80 |
+
{'name': 'corrupted_export_09.csv', 'rows': '—', 'status': 'failed', 'date': 'Yesterday'},
|
| 81 |
+
] %}
|
| 82 |
+
<tr class="hover:bg-white/[0.03] transition-colors">
|
| 83 |
+
<td class="py-3 px-xl">
|
| 84 |
+
<div class="flex items-center gap-2 text-body-md text-[#dae2fd]">
|
| 85 |
+
{{ ms_icon('description', size=16, cls='text-[#c7c4d7]') }}
|
| 86 |
+
{{ row.name }}
|
| 87 |
+
</div>
|
| 88 |
+
</td>
|
| 89 |
+
<td class="py-3 px-lg font-mono text-body-sm text-[#c7c4d7]">{{ row.rows }}</td>
|
| 90 |
+
<td class="py-3 px-lg">{{ status_badge(row.status) }}</td>
|
| 91 |
+
<td class="py-3 px-xl text-right font-mono text-body-sm text-[#c7c4d7]">{{ row.date }}</td>
|
| 92 |
+
</tr>
|
| 93 |
+
{% endfor %}
|
| 94 |
+
</tbody>
|
| 95 |
+
</table>
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
<p class="mt-2 font-mono text-label-sm text-[#c7c4d7]/50 text-center">
|
| 99 |
+
Phase 4 will populate this table from the database.
|
| 100 |
+
</p>
|
| 101 |
+
</section>
|
| 102 |
+
|
| 103 |
+
</div>
|
| 104 |
+
{% endblock %}
|
api/app/templates/pages/monitor.html
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% from "macros/ui.html" import ms_icon, health_chip, info_row, loaded_badge %}
|
| 3 |
+
|
| 4 |
+
{% block title %}System Monitor{% endblock %}
|
| 5 |
+
{% block description %}Real-time API health, model metadata, and request statistics.{% endblock %}
|
| 6 |
+
|
| 7 |
+
{% block content %}
|
| 8 |
+
{#
|
| 9 |
+
Phase 2: Placeholder layout for the System Monitor page.
|
| 10 |
+
|
| 11 |
+
What this page will contain after Phase 5:
|
| 12 |
+
- API Status card : live health chip polled via hx-trigger="every 30s"
|
| 13 |
+
- Model Configuration : architecture, languages, model status
|
| 14 |
+
- Performance Metrics : stat cards with SVG sparklines
|
| 15 |
+
- Recent Activity table : last N API requests
|
| 16 |
+
|
| 17 |
+
All section structures below match Monitor.jsx exactly so Phase 5 only needs to
|
| 18 |
+
add live data and HTMX polling attributes.
|
| 19 |
+
#}
|
| 20 |
+
<div class="space-y-xl">
|
| 21 |
+
|
| 22 |
+
{# ── Page header ─────────────────────────────────────────────────────────── #}
|
| 23 |
+
<div class="flex flex-wrap justify-between items-end gap-4">
|
| 24 |
+
<div>
|
| 25 |
+
<h1 class="text-headline-md text-[#dae2fd]">System Monitor</h1>
|
| 26 |
+
<p class="mt-1 text-body-md text-[#c7c4d7]">
|
| 27 |
+
Real-time API health, model metadata, and request statistics.
|
| 28 |
+
</p>
|
| 29 |
+
</div>
|
| 30 |
+
<div class="flex items-center gap-2">
|
| 31 |
+
<label for="refresh-select" class="font-mono text-label-sm text-[#c7c4d7]">
|
| 32 |
+
Auto-refresh
|
| 33 |
+
</label>
|
| 34 |
+
<select id="refresh-select" class="input-base py-1 text-body-sm" style="width:auto;">
|
| 35 |
+
<option value="10000">10s</option>
|
| 36 |
+
<option value="30000" selected>30s</option>
|
| 37 |
+
<option value="60000">1m</option>
|
| 38 |
+
<option value="0">Off</option>
|
| 39 |
+
</select>
|
| 40 |
+
</div>
|
| 41 |
+
</div>
|
| 42 |
+
|
| 43 |
+
{# ── Health + Model config ────────────────────────────────────────────────── #}
|
| 44 |
+
<div class="grid grid-cols-1 lg:grid-cols-12 gap-xl">
|
| 45 |
+
|
| 46 |
+
{# API Status card #}
|
| 47 |
+
<div class="lg:col-span-4 card flex flex-col gap-lg">
|
| 48 |
+
<div class="flex items-center gap-3">
|
| 49 |
+
<div class="p-2.5 rounded-lg bg-[#4edea3]/10">
|
| 50 |
+
{{ ms_icon('monitor_heart', size=24, cls='text-[#4edea3]') }}
|
| 51 |
+
</div>
|
| 52 |
+
<div>
|
| 53 |
+
<h2 class="text-title-lg text-[#dae2fd]">API Status</h2>
|
| 54 |
+
<p class="font-mono text-label-sm text-[#c7c4d7]">Core Inference Engine</p>
|
| 55 |
+
</div>
|
| 56 |
+
</div>
|
| 57 |
+
|
| 58 |
+
{# Health status — HTMX polling target in Phase 5 #}
|
| 59 |
+
<div id="health-status"
|
| 60 |
+
class="flex items-center gap-3 pt-lg border-t border-white/[0.06]">
|
| 61 |
+
<span class="text-body-md text-[#c7c4d7]">Current state:</span>
|
| 62 |
+
{# Static placeholder — replaced by live fragment in Phase 5 #}
|
| 63 |
+
{{ health_chip(True) }}
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
{# Model Configuration card #}
|
| 68 |
+
<div class="lg:col-span-8 card">
|
| 69 |
+
<div class="flex items-center gap-3 mb-xl">
|
| 70 |
+
<div class="p-2.5 rounded-lg bg-[#c0c1ff]/10">
|
| 71 |
+
{{ ms_icon('memory', size=24, cls='text-[#c0c1ff]') }}
|
| 72 |
+
</div>
|
| 73 |
+
<div>
|
| 74 |
+
<h2 class="text-title-lg text-[#dae2fd]">Model Configuration</h2>
|
| 75 |
+
<p class="font-mono text-label-sm text-[#c7c4d7]">Loaded ONNX Graphs</p>
|
| 76 |
+
</div>
|
| 77 |
+
</div>
|
| 78 |
+
<div class="grid grid-cols-2 gap-md">
|
| 79 |
+
{{ info_row('Architecture', 'XLM-RoBERTa (INT8)') }}
|
| 80 |
+
{{ info_row('Supported Languages', 'English, Hindi, Hinglish') }}
|
| 81 |
+
<div class="bg-[#0b1326] rounded-lg p-3">
|
| 82 |
+
<p class="font-mono text-[11px] text-[#c7c4d7] mb-1 uppercase tracking-wide">Aspect Extraction</p>
|
| 83 |
+
{{ loaded_badge() }}
|
| 84 |
+
</div>
|
| 85 |
+
<div class="bg-[#0b1326] rounded-lg p-3">
|
| 86 |
+
<p class="font-mono text-[11px] text-[#c7c4d7] mb-1 uppercase tracking-wide">Sentiment Classification</p>
|
| 87 |
+
{{ loaded_badge() }}
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
|
| 93 |
+
{# ── Performance metrics (stat cards) ─────────────────────────────────────── #}
|
| 94 |
+
<div>
|
| 95 |
+
<h2 class="text-headline-sm text-[#dae2fd] mb-lg">Performance Metrics</h2>
|
| 96 |
+
<div class="grid grid-cols-1 sm:grid-cols-3 gap-xl">
|
| 97 |
+
|
| 98 |
+
{# Macro-style stat card — defined inline since it's used only here #}
|
| 99 |
+
{% for stat in [
|
| 100 |
+
{'icon': 'database', 'label': 'Total Requests Today', 'value': '12.4k', 'sub': '↑ 8% vs yesterday', 'color': '#c0c1ff', 'positive': true},
|
| 101 |
+
{'icon': 'bolt', 'label': 'Avg Latency (P95)', 'value': '145ms', 'sub': 'Well within SLA', 'color': '#4edea3', 'positive': true},
|
| 102 |
+
{'icon': 'warning', 'label': 'Error Rate', 'value': '0.2%', 'sub': 'Last 24 hours', 'color': '#ffb4ab', 'positive': false},
|
| 103 |
+
] %}
|
| 104 |
+
<div class="stat-card group">
|
| 105 |
+
{# Sparkline gradient background #}
|
| 106 |
+
<div class="absolute bottom-0 left-0 w-full h-16 opacity-40 group-hover:opacity-70
|
| 107 |
+
transition-opacity pointer-events-none">
|
| 108 |
+
<svg viewBox="0 0 100 40" class="w-full h-full" preserveAspectRatio="none" aria-hidden="true">
|
| 109 |
+
<path d="{{ 'M0 40 L0 28 Q25 24 50 20 T100 14 L100 40 Z' if stat.positive else 'M0 40 L0 32 Q25 30 50 26 T100 22 L100 40 Z' }}"
|
| 110 |
+
fill="{{ stat.color }}22"
|
| 111 |
+
stroke="{{ stat.color }}"
|
| 112 |
+
stroke-width="1.5"
|
| 113 |
+
vector-effect="non-scaling-stroke" />
|
| 114 |
+
</svg>
|
| 115 |
+
</div>
|
| 116 |
+
<div class="relative z-10">
|
| 117 |
+
<div class="flex items-center gap-2 mb-lg">
|
| 118 |
+
<span class="material-symbols-outlined text-2xl"
|
| 119 |
+
style="color: {{ stat.color }}; font-variation-settings: 'FILL' 1, 'wght' 400;"
|
| 120 |
+
aria-hidden="true">{{ stat.icon }}</span>
|
| 121 |
+
<span class="font-mono text-label-md text-[#c7c4d7] uppercase tracking-wider">{{ stat.label }}</span>
|
| 122 |
+
</div>
|
| 123 |
+
<p class="text-display text-[#dae2fd] font-semibold">{{ stat.value }}</p>
|
| 124 |
+
<p class="font-mono text-label-sm text-[#c7c4d7] mt-1">{{ stat.sub }}</p>
|
| 125 |
+
</div>
|
| 126 |
+
</div>
|
| 127 |
+
{% endfor %}
|
| 128 |
+
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
|
| 132 |
+
{# ── Recent endpoint activity ──────────────────────────────────────────────── #}
|
| 133 |
+
<div class="card">
|
| 134 |
+
<h2 class="text-title-lg text-[#dae2fd] mb-lg">Recent Endpoint Activity</h2>
|
| 135 |
+
<div class="space-y-2" role="list" aria-label="Recent API requests">
|
| 136 |
+
{% for req in [
|
| 137 |
+
{'method': 'POST', 'path': '/predict', 'status': 200, 'time': '3.5ms', 'ago': '2s ago'},
|
| 138 |
+
{'method': 'GET', 'path': '/health', 'status': 200, 'time': '0.8ms', 'ago': '5s ago'},
|
| 139 |
+
{'method': 'POST', 'path': '/batch', 'status': 202, 'time': '12.1ms', 'ago': '1m ago'},
|
| 140 |
+
{'method': 'GET', 'path': '/status/abc12', 'status': 200, 'time': '1.2ms', 'ago': '1m ago'},
|
| 141 |
+
{'method': 'POST', 'path': '/predict', 'status': 500, 'time': '23ms', 'ago': '3m ago'},
|
| 142 |
+
] %}
|
| 143 |
+
<div class="flex items-center gap-4 py-2 px-md rounded-lg hover:bg-white/[0.03] transition-colors"
|
| 144 |
+
role="listitem">
|
| 145 |
+
<span class="font-mono text-label-sm w-10 flex-shrink-0
|
| 146 |
+
{{ 'text-[#c0c1ff]' if req.method == 'POST' else 'text-[#4edea3]' }}">
|
| 147 |
+
{{ req.method }}
|
| 148 |
+
</span>
|
| 149 |
+
<span class="font-mono text-body-sm text-[#dae2fd] flex-1 truncate">{{ req.path }}</span>
|
| 150 |
+
<span class="font-mono text-label-sm w-10 text-right flex-shrink-0
|
| 151 |
+
{{ 'text-[#ffb4ab]' if req.status >= 500 else ('text-[#f5c542]' if req.status >= 400 else 'text-[#4edea3]') }}">
|
| 152 |
+
{{ req.status }}
|
| 153 |
+
</span>
|
| 154 |
+
<span class="font-mono text-label-sm text-[#c7c4d7] w-14 text-right flex-shrink-0">{{ req.time }}</span>
|
| 155 |
+
<span class="hidden sm:block font-mono text-label-sm text-[#c7c4d7]/50 w-16 text-right flex-shrink-0">
|
| 156 |
+
{{ req.ago }}
|
| 157 |
+
</span>
|
| 158 |
+
</div>
|
| 159 |
+
{% endfor %}
|
| 160 |
+
</div>
|
| 161 |
+
|
| 162 |
+
<p class="mt-4 font-mono text-label-sm text-[#c7c4d7]/50 text-center">
|
| 163 |
+
Phase 5 will add live polling from <code class="text-[#c0c1ff]">GET /health</code>.
|
| 164 |
+
</p>
|
| 165 |
+
</div>
|
| 166 |
+
|
| 167 |
+
</div>
|
| 168 |
+
{% endblock %}
|
api/app/templates/pages/predict.html
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
{% from "macros/ui.html" import ms_icon, empty_state %}
|
| 3 |
+
|
| 4 |
+
{% block title %}Live Predictor{% endblock %}
|
| 5 |
+
{% block description %}Analyze aspect-based sentiment in English and Hindi text in real time.{% endblock %}
|
| 6 |
+
|
| 7 |
+
{% block content %}
|
| 8 |
+
{#
|
| 9 |
+
Phase 2: Placeholder layout for the Predict page.
|
| 10 |
+
|
| 11 |
+
What this page will contain after Phase 3:
|
| 12 |
+
- Left panel : textarea input + language selector + Analyze button
|
| 13 |
+
- Right panel : annotated result text + aspect cards (HTMX swap target)
|
| 14 |
+
- POST /predict/fragment → returns partials/predict_result.html
|
| 15 |
+
|
| 16 |
+
The outer grid, headings, and card shells are already correct here so Phase 3
|
| 17 |
+
only needs to fill in the form and wire up HTMX — no structural changes.
|
| 18 |
+
#}
|
| 19 |
+
<div class="space-y-xl">
|
| 20 |
+
|
| 21 |
+
{# ── Page header ─────────────────────────────────────────────────────────── #}
|
| 22 |
+
<div>
|
| 23 |
+
<h1 class="text-headline-md text-[#dae2fd]">Live Sentiment Predictor</h1>
|
| 24 |
+
<p class="mt-1 text-body-md text-[#c7c4d7] max-w-2xl">
|
| 25 |
+
Enter text to analyze its aspects and sentiments in real-time.
|
| 26 |
+
The model automatically identifies the language and extracts key phrases.
|
| 27 |
+
</p>
|
| 28 |
+
</div>
|
| 29 |
+
|
| 30 |
+
{# ── Two-column layout (mirrors LivePredictor.jsx structure) ─────────────── #}
|
| 31 |
+
<div class="grid grid-cols-1 lg:grid-cols-12 gap-xl">
|
| 32 |
+
|
| 33 |
+
{# ── Left: Input panel ───────────────────────────────────────────────── #}
|
| 34 |
+
<div class="lg:col-span-7 flex flex-col">
|
| 35 |
+
<div class="card-low flex flex-col h-full min-h-[420px]">
|
| 36 |
+
|
| 37 |
+
<div class="flex justify-between items-center mb-lg pb-md border-b border-white/[0.06]">
|
| 38 |
+
<h3 class="font-mono text-label-md text-[#c7c4d7] uppercase tracking-wider">
|
| 39 |
+
Analyze Input
|
| 40 |
+
</h3>
|
| 41 |
+
{# Language selector placeholder — wired up in Phase 3 #}
|
| 42 |
+
<div class="flex items-center gap-2">
|
| 43 |
+
<label for="lang-select-preview"
|
| 44 |
+
class="font-mono text-label-sm text-[#c7c4d7]">Language</label>
|
| 45 |
+
<select id="lang-select-preview" class="input-base py-1 text-body-sm" style="width:auto;" disabled>
|
| 46 |
+
<option>Auto-detect</option>
|
| 47 |
+
</select>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
+
{# Textarea placeholder #}
|
| 52 |
+
<div class="flex-1 flex flex-col mb-lg">
|
| 53 |
+
<label for="review-text-preview"
|
| 54 |
+
class="font-mono text-label-sm text-[#c7c4d7] mb-2">Source Text</label>
|
| 55 |
+
<textarea id="review-text-preview"
|
| 56 |
+
class="input-base flex-1 min-h-[260px] resize-none leading-relaxed"
|
| 57 |
+
placeholder="Paste your review, article, or social media post here…"
|
| 58 |
+
disabled>
|
| 59 |
+
</textarea>
|
| 60 |
+
<div class="flex justify-between mt-2">
|
| 61 |
+
<span class="font-mono text-label-sm text-[#c7c4d7]/50">⌘ Enter to analyze</span>
|
| 62 |
+
<span class="font-mono text-label-sm text-[#c7c4d7]/50">0 / 512</span>
|
| 63 |
+
</div>
|
| 64 |
+
</div>
|
| 65 |
+
|
| 66 |
+
{# Analyze button placeholder #}
|
| 67 |
+
<button class="btn-primary" disabled>
|
| 68 |
+
{{ ms_icon('bolt', size=16) }}
|
| 69 |
+
Analyze
|
| 70 |
+
</button>
|
| 71 |
+
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
|
| 75 |
+
{# ── Right: Results panel ─────────────────────────────────────────────── #}
|
| 76 |
+
<div class="lg:col-span-5 flex flex-col">
|
| 77 |
+
<div class="glass-panel rounded-xl flex flex-col h-full min-h-[420px] p-xl">
|
| 78 |
+
|
| 79 |
+
<div class="flex justify-between items-center mb-lg pb-md border-b border-white/[0.06]">
|
| 80 |
+
<h3 class="font-mono text-label-md text-[#c7c4d7] uppercase tracking-wider">
|
| 81 |
+
Analysis Results
|
| 82 |
+
</h3>
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
{# Phase 2 placeholder — replaced by HTMX partial in Phase 3 #}
|
| 86 |
+
<div id="result-panel" class="flex-1 flex">
|
| 87 |
+
{{ empty_state('psychology', 'Enter a review and click Analyze to see results') }}
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
|
| 93 |
+
</div>{# /grid #}
|
| 94 |
+
|
| 95 |
+
{# Phase 2 notice (removed in Phase 3) #}
|
| 96 |
+
<div class="card border-[#c0c1ff]/20 bg-[#c0c1ff]/5">
|
| 97 |
+
<div class="flex items-start gap-3">
|
| 98 |
+
{{ ms_icon('info', size=18, cls='text-[#c0c1ff] mt-0.5 flex-shrink-0') }}
|
| 99 |
+
<div>
|
| 100 |
+
<p class="text-body-md text-[#dae2fd] font-medium mb-1">Phase 2 Infrastructure</p>
|
| 101 |
+
<p class="text-body-sm text-[#c7c4d7]">
|
| 102 |
+
The layout shell is in place. The
|
| 103 |
+
<code class="font-mono text-[#c0c1ff] text-xs">POST /predict</code>
|
| 104 |
+
API endpoint is fully operational — HTMX interactions will be wired in Phase 3.
|
| 105 |
+
<a href="/docs#/Predict/predict_predict_post"
|
| 106 |
+
target="_blank"
|
| 107 |
+
class="text-[#c0c1ff] hover:underline ml-1">Test the API directly →</a>
|
| 108 |
+
</p>
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
</div>
|
| 114 |
+
{% endblock %}
|
requirements.txt
CHANGED
|
@@ -15,6 +15,7 @@ mlflow==2.13.0
|
|
| 15 |
dvc==3.51.1
|
| 16 |
evidently==0.4.30
|
| 17 |
fastapi==0.111.0
|
|
|
|
| 18 |
uvicorn==0.29.0
|
| 19 |
celery==5.4.0
|
| 20 |
redis==5.0.4
|
|
|
|
| 15 |
dvc==3.51.1
|
| 16 |
evidently==0.4.30
|
| 17 |
fastapi==0.111.0
|
| 18 |
+
jinja2>=3.1.4
|
| 19 |
uvicorn==0.29.0
|
| 20 |
celery==5.4.0
|
| 21 |
redis==5.0.4
|
tests/api/test_api.py
CHANGED
|
@@ -52,3 +52,20 @@ def test_batch_upload():
|
|
| 52 |
assert data["status"] == "queued"
|
| 53 |
assert data["total_reviews"] == 2
|
| 54 |
mock_delay.assert_called_once()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
assert data["status"] == "queued"
|
| 53 |
assert data["total_reviews"] == 2
|
| 54 |
mock_delay.assert_called_once()
|
| 55 |
+
|
| 56 |
+
def test_info_endpoint():
|
| 57 |
+
with TestClient(app) as client:
|
| 58 |
+
response = client.get("/info")
|
| 59 |
+
assert response.status_code == 200
|
| 60 |
+
data = response.json()
|
| 61 |
+
assert "model_name" in data
|
| 62 |
+
assert "supported_languages" in data
|
| 63 |
+
assert isinstance(data["supported_languages"], str)
|
| 64 |
+
|
| 65 |
+
def test_metrics_endpoint():
|
| 66 |
+
with TestClient(app) as client:
|
| 67 |
+
response = client.get("/metrics")
|
| 68 |
+
assert response.status_code == 200
|
| 69 |
+
# metrics returns plain text Prometheus data
|
| 70 |
+
assert "text/plain" in response.headers["content-type"]
|
| 71 |
+
assert "http_requests_total" in response.text
|
tests/web/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Phase 2 test package
|
tests/web/test_pages.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
tests/web/test_pages.py — Phase 2 smoke tests.
|
| 3 |
+
|
| 4 |
+
PURPOSE
|
| 5 |
+
-------
|
| 6 |
+
Verify that the new Jinja2 page infrastructure:
|
| 7 |
+
1. Renders all three pages without raising an exception (HTTP 200).
|
| 8 |
+
2. Returns HTML content (not JSON).
|
| 9 |
+
3. Contains the expected page titles / identifiers.
|
| 10 |
+
4. Does NOT break any existing JSON API endpoint.
|
| 11 |
+
5. Serves static CSS from the /static mount.
|
| 12 |
+
|
| 13 |
+
These tests are strictly additive — they do not modify or replace
|
| 14 |
+
any tests in tests/api/test_api.py.
|
| 15 |
+
|
| 16 |
+
IMPORTANT: DATABASE_URL must be set before importing the app because
|
| 17 |
+
api/app/middleware/dependencies.py reads it at module-import time.
|
| 18 |
+
"""
|
| 19 |
+
import os
|
| 20 |
+
|
| 21 |
+
# Must be set before any app import (same pattern as tests/api/test_api.py)
|
| 22 |
+
os.environ.setdefault("DATABASE_URL", "sqlite:///./tests/fixtures/test.db")
|
| 23 |
+
|
| 24 |
+
import pytest
|
| 25 |
+
from fastapi.testclient import TestClient
|
| 26 |
+
|
| 27 |
+
from api.app.main import app
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# ---------------------------------------------------------------------------
|
| 31 |
+
# Helpers
|
| 32 |
+
# ---------------------------------------------------------------------------
|
| 33 |
+
|
| 34 |
+
def _html_client() -> TestClient:
|
| 35 |
+
"""Return a TestClient that triggers the lifespan (model loading)."""
|
| 36 |
+
return TestClient(app)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ---------------------------------------------------------------------------
|
| 40 |
+
# Page rendering tests
|
| 41 |
+
# ---------------------------------------------------------------------------
|
| 42 |
+
|
| 43 |
+
class TestPageRoutes:
|
| 44 |
+
"""Smoke tests: every GET page route returns 200 HTML."""
|
| 45 |
+
|
| 46 |
+
def test_root_redirects_to_predict(self):
|
| 47 |
+
"""GET / should return the Predict page (200, HTML)."""
|
| 48 |
+
with _html_client() as client:
|
| 49 |
+
response = client.get("/", follow_redirects=True)
|
| 50 |
+
assert response.status_code == 200
|
| 51 |
+
assert "text/html" in response.headers["content-type"]
|
| 52 |
+
|
| 53 |
+
def test_predict_page_renders(self):
|
| 54 |
+
with _html_client() as client:
|
| 55 |
+
response = client.get("/predict")
|
| 56 |
+
assert response.status_code == 200
|
| 57 |
+
assert "text/html" in response.headers["content-type"]
|
| 58 |
+
assert "SentimentAI" in response.text
|
| 59 |
+
assert "Live Predictor" in response.text
|
| 60 |
+
|
| 61 |
+
def test_batch_page_renders(self):
|
| 62 |
+
with _html_client() as client:
|
| 63 |
+
response = client.get("/batch")
|
| 64 |
+
assert response.status_code == 200
|
| 65 |
+
assert "text/html" in response.headers["content-type"]
|
| 66 |
+
assert "SentimentAI" in response.text
|
| 67 |
+
assert "Batch Analytics" in response.text
|
| 68 |
+
|
| 69 |
+
def test_monitor_page_renders(self):
|
| 70 |
+
with _html_client() as client:
|
| 71 |
+
response = client.get("/monitor")
|
| 72 |
+
assert response.status_code == 200
|
| 73 |
+
assert "text/html" in response.headers["content-type"]
|
| 74 |
+
assert "SentimentAI" in response.text
|
| 75 |
+
assert "System Monitor" in response.text
|
| 76 |
+
|
| 77 |
+
def test_sidebar_nav_items_present(self):
|
| 78 |
+
"""All three nav links must appear in every page."""
|
| 79 |
+
with _html_client() as client:
|
| 80 |
+
for path in ("/predict", "/batch", "/monitor"):
|
| 81 |
+
response = client.get(path)
|
| 82 |
+
assert response.status_code == 200
|
| 83 |
+
# Check all nav labels are present
|
| 84 |
+
assert "Predictor" in response.text
|
| 85 |
+
assert "Batch Analytics" in response.text
|
| 86 |
+
assert "System Health" in response.text
|
| 87 |
+
|
| 88 |
+
def test_predict_active_state(self):
|
| 89 |
+
"""/predict page must mark Predictor nav item as active."""
|
| 90 |
+
with _html_client() as client:
|
| 91 |
+
response = client.get("/predict")
|
| 92 |
+
assert "nav-item-active" in response.text
|
| 93 |
+
# The active item must contain the predictor icon
|
| 94 |
+
assert "psychology" in response.text
|
| 95 |
+
|
| 96 |
+
def test_base_template_includes_htmx(self):
|
| 97 |
+
"""HTMX CDN script must be present in every page."""
|
| 98 |
+
with _html_client() as client:
|
| 99 |
+
response = client.get("/predict")
|
| 100 |
+
assert "htmx.org" in response.text
|
| 101 |
+
|
| 102 |
+
def test_base_template_includes_alpinejs(self):
|
| 103 |
+
"""Alpine.js CDN script must be present in every page."""
|
| 104 |
+
with _html_client() as client:
|
| 105 |
+
response = client.get("/predict")
|
| 106 |
+
assert "alpinejs" in response.text
|
| 107 |
+
|
| 108 |
+
def test_base_template_includes_tailwind(self):
|
| 109 |
+
"""Tailwind CDN script must be present in every page."""
|
| 110 |
+
with _html_client() as client:
|
| 111 |
+
response = client.get("/predict")
|
| 112 |
+
assert "cdn.tailwindcss.com" in response.text
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# ---------------------------------------------------------------------------
|
| 116 |
+
# Static file tests
|
| 117 |
+
# ---------------------------------------------------------------------------
|
| 118 |
+
|
| 119 |
+
class TestStaticFiles:
|
| 120 |
+
"""Verify the /static mount serves files correctly."""
|
| 121 |
+
|
| 122 |
+
def test_css_file_served(self):
|
| 123 |
+
with _html_client() as client:
|
| 124 |
+
response = client.get("/static/css/app.css")
|
| 125 |
+
assert response.status_code == 200
|
| 126 |
+
assert "text/css" in response.headers["content-type"]
|
| 127 |
+
# Spot-check for key design system classes
|
| 128 |
+
assert "badge-positive" in response.text
|
| 129 |
+
assert "btn-primary" in response.text
|
| 130 |
+
|
| 131 |
+
def test_static_missing_file_returns_404(self):
|
| 132 |
+
with _html_client() as client:
|
| 133 |
+
response = client.get("/static/does-not-exist.css")
|
| 134 |
+
assert response.status_code == 404
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
# ---------------------------------------------------------------------------
|
| 138 |
+
# Regression tests — existing API must be unaffected
|
| 139 |
+
# ---------------------------------------------------------------------------
|
| 140 |
+
|
| 141 |
+
class TestExistingAPIUnchanged:
|
| 142 |
+
"""
|
| 143 |
+
Re-run the core API assertions to prove Phase 2 changes introduced
|
| 144 |
+
zero regressions. These mirror tests/api/test_api.py in spirit.
|
| 145 |
+
"""
|
| 146 |
+
|
| 147 |
+
def test_health_endpoint_still_returns_json(self):
|
| 148 |
+
with _html_client() as client:
|
| 149 |
+
response = client.get("/health")
|
| 150 |
+
assert response.status_code == 200
|
| 151 |
+
data = response.json()
|
| 152 |
+
assert data["status"] == "ok"
|
| 153 |
+
# Verify content-type is JSON (not HTML)
|
| 154 |
+
assert "application/json" in response.headers["content-type"]
|
| 155 |
+
|
| 156 |
+
def test_info_endpoint_still_returns_json(self):
|
| 157 |
+
with _html_client() as client:
|
| 158 |
+
response = client.get("/info")
|
| 159 |
+
assert response.status_code == 200
|
| 160 |
+
data = response.json()
|
| 161 |
+
assert "model_name" in data
|
| 162 |
+
assert "supported_languages" in data
|
| 163 |
+
|
| 164 |
+
def test_predict_endpoint_still_returns_json(self):
|
| 165 |
+
"""POST /predict must still return PredictionResponse JSON."""
|
| 166 |
+
with _html_client() as client:
|
| 167 |
+
response = client.post(
|
| 168 |
+
"/predict",
|
| 169 |
+
json={"text": "The sound quality is excellent.", "language": "en"},
|
| 170 |
+
)
|
| 171 |
+
assert response.status_code == 200
|
| 172 |
+
data = response.json()
|
| 173 |
+
# Schema check — all required fields must be present
|
| 174 |
+
assert "text" in data
|
| 175 |
+
assert "language" in data
|
| 176 |
+
assert "detected_language" in data
|
| 177 |
+
assert "aspects" in data
|
| 178 |
+
assert "processing_time_ms" in data
|
| 179 |
+
# Content-type must be JSON
|
| 180 |
+
assert "application/json" in response.headers["content-type"]
|
| 181 |
+
|
| 182 |
+
def test_openapi_schema_page_routes_hidden(self):
|
| 183 |
+
"""Page GET routes must NOT appear in the OpenAPI schema."""
|
| 184 |
+
with _html_client() as client:
|
| 185 |
+
response = client.get("/openapi.json")
|
| 186 |
+
assert response.status_code == 200
|
| 187 |
+
schema = response.json()
|
| 188 |
+
paths = schema.get("paths", {})
|
| 189 |
+
# None of the page routes should be in the schema
|
| 190 |
+
assert "/predict" not in paths or all(
|
| 191 |
+
method == "post" for method in paths.get("/predict", {})
|
| 192 |
+
), "GET /predict should not appear in OpenAPI schema"
|
| 193 |
+
assert "/" not in paths, "GET / should not appear in OpenAPI schema"
|
| 194 |
+
assert "/batch" not in paths or all(
|
| 195 |
+
method == "post" for method in paths.get("/batch", {})
|
| 196 |
+
), "GET /batch should not appear in OpenAPI schema"
|
| 197 |
+
assert "/monitor" not in paths, "GET /monitor should not appear in OpenAPI schema"
|