Feature/streamline #4
83 changed files with 6497 additions and 864 deletions
|
|
@ -14,4 +14,5 @@ __pycache__
|
||||||
htmlcov
|
htmlcov
|
||||||
frontend/node_modules
|
frontend/node_modules
|
||||||
frontend/dist
|
frontend/dist
|
||||||
|
frontend/.vite
|
||||||
planning
|
planning
|
||||||
|
|
|
||||||
20
.env.example
20
.env.example
|
|
@ -21,6 +21,21 @@ MIGRATE_ON_START=1
|
||||||
|
|
||||||
SESSION_SECRET_KEY=replace-with-a-long-random-secret-at-least-32-characters
|
SESSION_SECRET_KEY=replace-with-a-long-random-secret-at-least-32-characters
|
||||||
SESSION_COOKIE_SECURE=1
|
SESSION_COOKIE_SECURE=1
|
||||||
|
SECURITY_HEADERS_ENABLED=1
|
||||||
|
SECURITY_X_CONTENT_TYPE_OPTIONS=nosniff
|
||||||
|
SECURITY_X_FRAME_OPTIONS=DENY
|
||||||
|
SECURITY_REFERRER_POLICY=strict-origin-when-cross-origin
|
||||||
|
SECURITY_PERMISSIONS_POLICY=accelerometer=(), autoplay=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()
|
||||||
|
SECURITY_CROSS_ORIGIN_OPENER_POLICY=same-origin
|
||||||
|
SECURITY_CROSS_ORIGIN_RESOURCE_POLICY=same-origin
|
||||||
|
SECURITY_CSP_ENABLED=1
|
||||||
|
SECURITY_CSP_POLICY=default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data: https:; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self'; object-src 'none'
|
||||||
|
SECURITY_CSP_DOCS_POLICY=default-src 'self'; img-src 'self' data: https:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self' https:; object-src 'none'; frame-ancestors 'none'
|
||||||
|
SECURITY_CSP_REPORT_ONLY=0
|
||||||
|
SECURITY_STRICT_TRANSPORT_SECURITY_ENABLED=0
|
||||||
|
SECURITY_STRICT_TRANSPORT_SECURITY_MAX_AGE=31536000
|
||||||
|
SECURITY_STRICT_TRANSPORT_SECURITY_INCLUDE_SUBDOMAINS=1
|
||||||
|
SECURITY_STRICT_TRANSPORT_SECURITY_PRELOAD=0
|
||||||
LOGIN_RATE_LIMIT_ATTEMPTS=5
|
LOGIN_RATE_LIMIT_ATTEMPTS=5
|
||||||
LOGIN_RATE_LIMIT_WINDOW_SECONDS=60
|
LOGIN_RATE_LIMIT_WINDOW_SECONDS=60
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
@ -35,6 +50,9 @@ INGESTION_NETWORK_ERROR_RETRIES=1
|
||||||
INGESTION_RETRY_BACKOFF_SECONDS=1.0
|
INGESTION_RETRY_BACKOFF_SECONDS=1.0
|
||||||
INGESTION_MAX_PAGES_PER_SCHOLAR=30
|
INGESTION_MAX_PAGES_PER_SCHOLAR=30
|
||||||
INGESTION_PAGE_SIZE=100
|
INGESTION_PAGE_SIZE=100
|
||||||
|
INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD=1
|
||||||
|
INGESTION_ALERT_NETWORK_FAILURE_THRESHOLD=2
|
||||||
|
INGESTION_ALERT_RETRY_SCHEDULED_THRESHOLD=3
|
||||||
INGESTION_CONTINUATION_QUEUE_ENABLED=1
|
INGESTION_CONTINUATION_QUEUE_ENABLED=1
|
||||||
INGESTION_CONTINUATION_BASE_DELAY_SECONDS=120
|
INGESTION_CONTINUATION_BASE_DELAY_SECONDS=120
|
||||||
INGESTION_CONTINUATION_MAX_DELAY_SECONDS=3600
|
INGESTION_CONTINUATION_MAX_DELAY_SECONDS=3600
|
||||||
|
|
@ -50,6 +68,8 @@ SCHOLAR_NAME_SEARCH_MIN_INTERVAL_SECONDS=8.0
|
||||||
SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS=2.0
|
SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS=2.0
|
||||||
SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD=1
|
SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD=1
|
||||||
SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS=1800
|
SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS=1800
|
||||||
|
SCHOLAR_NAME_SEARCH_ALERT_RETRY_COUNT_THRESHOLD=2
|
||||||
|
SCHOLAR_NAME_SEARCH_ALERT_COOLDOWN_REJECTIONS_THRESHOLD=3
|
||||||
|
|
||||||
BOOTSTRAP_ADMIN_ON_START=0
|
BOOTSTRAP_ADMIN_ON_START=0
|
||||||
BOOTSTRAP_ADMIN_EMAIL=
|
BOOTSTRAP_ADMIN_EMAIL=
|
||||||
|
|
|
||||||
19
.github/workflows/ci.yml
vendored
19
.github/workflows/ci.yml
vendored
|
|
@ -7,8 +7,20 @@ on:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
repo-hygiene:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Enforce no generated artifacts
|
||||||
|
run: ./scripts/check_no_generated_artifacts.sh
|
||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- repo-hygiene
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15
|
image: postgres:15
|
||||||
|
|
@ -54,6 +66,8 @@ jobs:
|
||||||
|
|
||||||
frontend-quality:
|
frontend-quality:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- repo-hygiene
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
|
@ -76,6 +90,10 @@ jobs:
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
|
- name: Frontend theme token policy
|
||||||
|
working-directory: frontend
|
||||||
|
run: npm run check:theme-tokens
|
||||||
|
|
||||||
- name: Frontend typecheck
|
- name: Frontend typecheck
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
run: npm run typecheck
|
run: npm run typecheck
|
||||||
|
|
@ -91,6 +109,7 @@ jobs:
|
||||||
docker-publish:
|
docker-publish:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
|
- repo-hygiene
|
||||||
- test
|
- test
|
||||||
- frontend-quality
|
- frontend-quality
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
|
|
||||||
54
.github/workflows/scheduled-probes.yml
vendored
Normal file
54
.github/workflows/scheduled-probes.yml
vendored
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
name: Scheduled Probes
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: "20 6 * * *"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
fixture-probes:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: scholar
|
||||||
|
POSTGRES_USER: scholar
|
||||||
|
POSTGRES_PASSWORD: scholar
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U scholar -d scholar"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 20
|
||||||
|
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://scholar:scholar@localhost:5432/scholar
|
||||||
|
SCHOLAR_IMAGE_UPLOAD_DIR: /tmp/scholarr_uploads/scholar_images
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Setup uv
|
||||||
|
uses: astral-sh/setup-uv@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --extra dev
|
||||||
|
|
||||||
|
- name: Fixture-backed parser unit probes
|
||||||
|
run: >-
|
||||||
|
uv run pytest
|
||||||
|
tests/unit/test_scholar_parser.py
|
||||||
|
tests/unit/test_scholar_search_safety.py
|
||||||
|
|
||||||
|
- name: Fixture-backed integration probe
|
||||||
|
run: >-
|
||||||
|
uv run pytest -m integration
|
||||||
|
tests/integration/test_fixture_probe_runs.py
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -10,6 +10,8 @@ htmlcov/
|
||||||
.uv-cache/
|
.uv-cache/
|
||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
|
.DS_Store
|
||||||
planning/
|
planning/
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
|
frontend/.vite/
|
||||||
|
|
|
||||||
24
CONTRIBUTING.md
Normal file
24
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Contributing
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
This project favors small, reviewable pull requests that keep runtime behavior clear and operationally safe.
|
||||||
|
|
||||||
|
## Essential-File Policy
|
||||||
|
Commit only source-of-truth files required to build, run, test, or document the app.
|
||||||
|
|
||||||
|
Do not commit generated or local-only artifacts, including:
|
||||||
|
- `__pycache__/`, `*.pyc`, `.pytest_cache/`, `.mypy_cache/`, `.ruff_cache/`
|
||||||
|
- frontend build/install outputs like `frontend/dist/`, `frontend/node_modules/`, `frontend/.vite/`
|
||||||
|
- coverage outputs (`.coverage`, `htmlcov/`)
|
||||||
|
- packaging/build leftovers (`*.egg-info/`, `build/`, `dist/`)
|
||||||
|
- local probe/scratch material (`planning/`)
|
||||||
|
|
||||||
|
CI enforces this with `scripts/check_no_generated_artifacts.sh`.
|
||||||
|
|
||||||
|
## Merge Checklist
|
||||||
|
- [ ] Changes are minimal, purposeful, and remove obsolete/dead code in touched areas.
|
||||||
|
- [ ] Backend tests pass (`uv run pytest tests/unit` and integration scope as needed).
|
||||||
|
- [ ] Frontend checks pass (`npm run typecheck`, `npm run test:run`, `npm run build`).
|
||||||
|
- [ ] API/behavior docs are updated when env vars, endpoints, or payloads change.
|
||||||
|
- [ ] `README.md`, `.env.example`, and deployment notes stay aligned.
|
||||||
|
- [ ] `scripts/check_no_generated_artifacts.sh` passes locally.
|
||||||
40
README.md
40
README.md
|
|
@ -69,12 +69,15 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml down
|
||||||
- `.env.example`: full env variable template.
|
- `.env.example`: full env variable template.
|
||||||
- `Dockerfile`: multi-stage build (frontend + backend runtime).
|
- `Dockerfile`: multi-stage build (frontend + backend runtime).
|
||||||
- `README.md`: deployment and operations reference.
|
- `README.md`: deployment and operations reference.
|
||||||
|
- `CONTRIBUTING.md`: contribution policy and merge checklist.
|
||||||
|
- `scripts/check_no_generated_artifacts.sh`: tracked-artifact guard used by CI.
|
||||||
|
|
||||||
## Environment Variables (Complete Reference)
|
## Environment Variables (Complete Reference)
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Boolean envs accept: `1/0`, `true/false`, `yes/no`, `on/off`.
|
- Boolean envs accept: `1/0`, `true/false`, `yes/no`, `on/off`.
|
||||||
- Values shown are defaults from `.env.example`.
|
- Values shown are deployment defaults from `.env.example` and `docker-compose.yml`.
|
||||||
|
- Some internal app fallbacks may differ for local test/dev safety when env vars are omitted.
|
||||||
- `deploy` means used in regular deployment.
|
- `deploy` means used in regular deployment.
|
||||||
- `dev` means used in local dev workflow.
|
- `dev` means used in local dev workflow.
|
||||||
|
|
||||||
|
|
@ -119,6 +122,26 @@ Notes:
|
||||||
| `LOGIN_RATE_LIMIT_ATTEMPTS` | `5` | integer >= 1 | deploy, dev | Login attempts allowed per window. |
|
| `LOGIN_RATE_LIMIT_ATTEMPTS` | `5` | integer >= 1 | deploy, dev | Login attempts allowed per window. |
|
||||||
| `LOGIN_RATE_LIMIT_WINDOW_SECONDS` | `60` | integer >= 1 | deploy, dev | Login rate limit window in seconds. |
|
| `LOGIN_RATE_LIMIT_WINDOW_SECONDS` | `60` | integer >= 1 | deploy, dev | Login rate limit window in seconds. |
|
||||||
|
|
||||||
|
### HTTP Security Headers and CSP
|
||||||
|
|
||||||
|
| Variable | Default | Options | Scope | Description |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `SECURITY_HEADERS_ENABLED` | `1` | boolean | deploy, dev | Global switch for response security headers middleware. |
|
||||||
|
| `SECURITY_X_CONTENT_TYPE_OPTIONS` | `nosniff` | header value | deploy, dev | `X-Content-Type-Options` response header value. |
|
||||||
|
| `SECURITY_X_FRAME_OPTIONS` | `DENY` | header value | deploy, dev | `X-Frame-Options` response header value. |
|
||||||
|
| `SECURITY_REFERRER_POLICY` | `strict-origin-when-cross-origin` | header value | deploy, dev | `Referrer-Policy` response header value. |
|
||||||
|
| `SECURITY_PERMISSIONS_POLICY` | `accelerometer=(), autoplay=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()` | permissions policy string | deploy, dev | `Permissions-Policy` response header value. |
|
||||||
|
| `SECURITY_CROSS_ORIGIN_OPENER_POLICY` | `same-origin` | header value | deploy, dev | `Cross-Origin-Opener-Policy` response header value. |
|
||||||
|
| `SECURITY_CROSS_ORIGIN_RESOURCE_POLICY` | `same-origin` | header value | deploy, dev | `Cross-Origin-Resource-Policy` response header value. |
|
||||||
|
| `SECURITY_CSP_ENABLED` | `1` | boolean | deploy, dev | Enable Content Security Policy headers. |
|
||||||
|
| `SECURITY_CSP_POLICY` | strict SPA/API default | CSP policy string | deploy, dev | CSP applied to app/API routes (excluding docs paths). |
|
||||||
|
| `SECURITY_CSP_DOCS_POLICY` | relaxed docs default | CSP policy string | deploy, dev | CSP override for `/docs` and `/redoc` to keep Swagger/ReDoc usable. |
|
||||||
|
| `SECURITY_CSP_REPORT_ONLY` | `0` | boolean | deploy, dev | Emit CSP in report-only mode instead of enforcement mode. |
|
||||||
|
| `SECURITY_STRICT_TRANSPORT_SECURITY_ENABLED` | `0` | boolean | deploy, dev | Enable `Strict-Transport-Security` header. |
|
||||||
|
| `SECURITY_STRICT_TRANSPORT_SECURITY_MAX_AGE` | `31536000` | integer >= 0 | deploy, dev | `max-age` for HSTS header. |
|
||||||
|
| `SECURITY_STRICT_TRANSPORT_SECURITY_INCLUDE_SUBDOMAINS` | `1` | boolean | deploy, dev | Add `includeSubDomains` directive to HSTS header. |
|
||||||
|
| `SECURITY_STRICT_TRANSPORT_SECURITY_PRELOAD` | `0` | boolean | deploy, dev | Add `preload` directive to HSTS header. |
|
||||||
|
|
||||||
### Logging
|
### Logging
|
||||||
|
|
||||||
| Variable | Default | Options | Scope | Description |
|
| Variable | Default | Options | Scope | Description |
|
||||||
|
|
@ -141,6 +164,9 @@ Notes:
|
||||||
| `INGESTION_RETRY_BACKOFF_SECONDS` | `1.0` | float >= 0 | deploy, dev | Backoff delay for retry attempts. |
|
| `INGESTION_RETRY_BACKOFF_SECONDS` | `1.0` | float >= 0 | deploy, dev | Backoff delay for retry attempts. |
|
||||||
| `INGESTION_MAX_PAGES_PER_SCHOLAR` | `30` | integer >= 1 | deploy, dev | Upper bound of pages fetched per scholar run. |
|
| `INGESTION_MAX_PAGES_PER_SCHOLAR` | `30` | integer >= 1 | deploy, dev | Upper bound of pages fetched per scholar run. |
|
||||||
| `INGESTION_PAGE_SIZE` | `100` | integer >= 1 | deploy, dev | Requested Scholar page size. |
|
| `INGESTION_PAGE_SIZE` | `100` | integer >= 1 | deploy, dev | Requested Scholar page size. |
|
||||||
|
| `INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD` | `1` | integer >= 1 | deploy, dev | Trigger blocked/captcha scrape alert flag when this many blocked failures occur in a run. |
|
||||||
|
| `INGESTION_ALERT_NETWORK_FAILURE_THRESHOLD` | `2` | integer >= 1 | deploy, dev | Trigger network scrape alert flag when this many network failures occur in a run. |
|
||||||
|
| `INGESTION_ALERT_RETRY_SCHEDULED_THRESHOLD` | `3` | integer >= 1 | deploy, dev | Trigger retry alert flag when scheduled retry count reaches this threshold in a run. |
|
||||||
| `INGESTION_CONTINUATION_QUEUE_ENABLED` | `1` | boolean | deploy, dev | Enable continuation queue for long runs. |
|
| `INGESTION_CONTINUATION_QUEUE_ENABLED` | `1` | boolean | deploy, dev | Enable continuation queue for long runs. |
|
||||||
| `INGESTION_CONTINUATION_BASE_DELAY_SECONDS` | `120` | integer >= 0 | deploy, dev | Initial delay before retrying continuation queue items. |
|
| `INGESTION_CONTINUATION_BASE_DELAY_SECONDS` | `120` | integer >= 0 | deploy, dev | Initial delay before retrying continuation queue items. |
|
||||||
| `INGESTION_CONTINUATION_MAX_DELAY_SECONDS` | `3600` | integer >= 0 | deploy, dev | Maximum continuation retry delay. |
|
| `INGESTION_CONTINUATION_MAX_DELAY_SECONDS` | `3600` | integer >= 0 | deploy, dev | Maximum continuation retry delay. |
|
||||||
|
|
@ -150,7 +176,7 @@ Notes:
|
||||||
|
|
||||||
| Variable | Default | Options | Scope | Description |
|
| Variable | Default | Options | Scope | Description |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| `SCHOLAR_IMAGE_UPLOAD_DIR` | `/var/lib/scholarr/uploads` | writable absolute path | deploy, dev | Storage path for uploaded scholar images. |
|
| `SCHOLAR_IMAGE_UPLOAD_DIR` | `/var/lib/scholarr/uploads` | writable absolute path | deploy, dev | Storage path for uploaded scholar images in compose deployments. App fallback without env is `/tmp/scholarr_uploads/scholar_images`. |
|
||||||
| `SCHOLAR_IMAGE_UPLOAD_MAX_BYTES` | `2000000` | integer >= 1 | deploy, dev | Max uploaded image size in bytes. |
|
| `SCHOLAR_IMAGE_UPLOAD_MAX_BYTES` | `2000000` | integer >= 1 | deploy, dev | Max uploaded image size in bytes. |
|
||||||
| `SCHOLAR_NAME_SEARCH_ENABLED` | `1` | boolean | deploy, dev | Enable name-search helper endpoint. |
|
| `SCHOLAR_NAME_SEARCH_ENABLED` | `1` | boolean | deploy, dev | Enable name-search helper endpoint. |
|
||||||
| `SCHOLAR_NAME_SEARCH_CACHE_TTL_SECONDS` | `21600` | integer >= 1 | deploy, dev | Cache TTL for successful name-search responses. |
|
| `SCHOLAR_NAME_SEARCH_CACHE_TTL_SECONDS` | `21600` | integer >= 1 | deploy, dev | Cache TTL for successful name-search responses. |
|
||||||
|
|
@ -160,6 +186,8 @@ Notes:
|
||||||
| `SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS` | `2.0` | float >= 0 | deploy, dev | Added jitter to reduce request burst patterns. |
|
| `SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS` | `2.0` | float >= 0 | deploy, dev | Added jitter to reduce request burst patterns. |
|
||||||
| `SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD` | `1` | integer >= 1 | deploy, dev | Consecutive blocked responses before cooldown starts. |
|
| `SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD` | `1` | integer >= 1 | deploy, dev | Consecutive blocked responses before cooldown starts. |
|
||||||
| `SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS` | `1800` | integer >= 1 | deploy, dev | Cooldown duration after repeated blocked responses. |
|
| `SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS` | `1800` | integer >= 1 | deploy, dev | Cooldown duration after repeated blocked responses. |
|
||||||
|
| `SCHOLAR_NAME_SEARCH_ALERT_RETRY_COUNT_THRESHOLD` | `2` | integer >= 1 | deploy, dev | Emit retry-threshold observability warning when name-search retry count reaches this value. |
|
||||||
|
| `SCHOLAR_NAME_SEARCH_ALERT_COOLDOWN_REJECTIONS_THRESHOLD` | `3` | integer >= 1 | deploy, dev | Emit cooldown-threshold observability alert after this many requests are rejected during active cooldown. |
|
||||||
|
|
||||||
### Startup Bootstrap and DB Wait
|
### Startup Bootstrap and DB Wait
|
||||||
|
|
||||||
|
|
@ -197,6 +225,14 @@ Contract drift:
|
||||||
python3 scripts/check_frontend_api_contract.py
|
python3 scripts/check_frontend_api_contract.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Repository hygiene:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/check_no_generated_artifacts.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Scheduled fixture probes run in GitHub Actions via `.github/workflows/scheduled-probes.yml`.
|
||||||
|
|
||||||
## API Contract
|
## API Contract
|
||||||
|
|
||||||
- Base path: `/api/v1`
|
- Base path: `/api/v1`
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
|
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""Add per-user nav visibility settings.
|
||||||
|
|
||||||
|
Revision ID: 20260219_0008
|
||||||
|
Revises: 20260217_0007
|
||||||
|
Create Date: 2026-02-19 14:58:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "20260219_0008"
|
||||||
|
down_revision: str | Sequence[str] | None = "20260217_0007"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
TABLE_NAME = "user_settings"
|
||||||
|
DEFAULT_NAV_VISIBLE_PAGES_SQL = (
|
||||||
|
"'[\"dashboard\",\"scholars\",\"publications\",\"settings\",\"style-guide\",\"runs\",\"users\"]'::jsonb"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
columns = {column["name"] for column in inspector.get_columns(TABLE_NAME)}
|
||||||
|
|
||||||
|
if "nav_visible_pages" not in columns:
|
||||||
|
op.add_column(
|
||||||
|
TABLE_NAME,
|
||||||
|
sa.Column(
|
||||||
|
"nav_visible_pages",
|
||||||
|
postgresql.JSONB(astext_type=sa.Text()),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text(DEFAULT_NAV_VISIBLE_PAGES_SQL),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
columns = {column["name"] for column in inspector.get_columns(TABLE_NAME)}
|
||||||
|
|
||||||
|
if "nav_visible_pages" in columns:
|
||||||
|
op.drop_column(TABLE_NAME, "nav_visible_pages")
|
||||||
|
|
@ -382,6 +382,9 @@ async def run_manual(
|
||||||
auto_queue_continuations=settings.ingestion_continuation_queue_enabled,
|
auto_queue_continuations=settings.ingestion_continuation_queue_enabled,
|
||||||
queue_delay_seconds=settings.ingestion_continuation_base_delay_seconds,
|
queue_delay_seconds=settings.ingestion_continuation_base_delay_seconds,
|
||||||
idempotency_key=idempotency_key,
|
idempotency_key=idempotency_key,
|
||||||
|
alert_blocked_failure_threshold=settings.ingestion_alert_blocked_failure_threshold,
|
||||||
|
alert_network_failure_threshold=settings.ingestion_alert_network_failure_threshold,
|
||||||
|
alert_retry_scheduled_threshold=settings.ingestion_alert_retry_scheduled_threshold,
|
||||||
)
|
)
|
||||||
except ingestion_service.RunAlreadyInProgressError as exc:
|
except ingestion_service.RunAlreadyInProgressError as exc:
|
||||||
await db_session.rollback()
|
await db_session.rollback()
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,10 @@ async def search_scholars(
|
||||||
interval_jitter_seconds=settings.scholar_name_search_interval_jitter_seconds,
|
interval_jitter_seconds=settings.scholar_name_search_interval_jitter_seconds,
|
||||||
cooldown_block_threshold=settings.scholar_name_search_cooldown_block_threshold,
|
cooldown_block_threshold=settings.scholar_name_search_cooldown_block_threshold,
|
||||||
cooldown_seconds=settings.scholar_name_search_cooldown_seconds,
|
cooldown_seconds=settings.scholar_name_search_cooldown_seconds,
|
||||||
|
retry_alert_threshold=settings.scholar_name_search_alert_retry_count_threshold,
|
||||||
|
cooldown_rejection_alert_threshold=(
|
||||||
|
settings.scholar_name_search_alert_cooldown_rejections_threshold
|
||||||
|
),
|
||||||
)
|
)
|
||||||
except scholar_service.ScholarServiceError as exc:
|
except scholar_service.ScholarServiceError as exc:
|
||||||
raise ApiException(
|
raise ApiException(
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ def _serialize_settings(settings) -> dict[str, object]:
|
||||||
"auto_run_enabled": bool(settings.auto_run_enabled),
|
"auto_run_enabled": bool(settings.auto_run_enabled),
|
||||||
"run_interval_minutes": int(settings.run_interval_minutes),
|
"run_interval_minutes": int(settings.run_interval_minutes),
|
||||||
"request_delay_seconds": int(settings.request_delay_seconds),
|
"request_delay_seconds": int(settings.request_delay_seconds),
|
||||||
|
"nav_visible_pages": list(settings.nav_visible_pages or []),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -55,6 +56,11 @@ async def update_settings(
|
||||||
db_session: AsyncSession = Depends(get_db_session),
|
db_session: AsyncSession = Depends(get_db_session),
|
||||||
current_user: User = Depends(get_api_current_user),
|
current_user: User = Depends(get_api_current_user),
|
||||||
):
|
):
|
||||||
|
settings = await user_settings_service.get_or_create_settings(
|
||||||
|
db_session,
|
||||||
|
user_id=current_user.id,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
parsed_interval = user_settings_service.parse_run_interval_minutes(
|
parsed_interval = user_settings_service.parse_run_interval_minutes(
|
||||||
str(payload.run_interval_minutes)
|
str(payload.run_interval_minutes)
|
||||||
|
|
@ -62,6 +68,11 @@ async def update_settings(
|
||||||
parsed_delay = user_settings_service.parse_request_delay_seconds(
|
parsed_delay = user_settings_service.parse_request_delay_seconds(
|
||||||
str(payload.request_delay_seconds)
|
str(payload.request_delay_seconds)
|
||||||
)
|
)
|
||||||
|
parsed_nav_visible_pages = user_settings_service.parse_nav_visible_pages(
|
||||||
|
payload.nav_visible_pages
|
||||||
|
if payload.nav_visible_pages is not None
|
||||||
|
else list(settings.nav_visible_pages or user_settings_service.DEFAULT_NAV_VISIBLE_PAGES)
|
||||||
|
)
|
||||||
except user_settings_service.UserSettingsServiceError as exc:
|
except user_settings_service.UserSettingsServiceError as exc:
|
||||||
raise ApiException(
|
raise ApiException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
|
|
@ -69,16 +80,13 @@ async def update_settings(
|
||||||
message=str(exc),
|
message=str(exc),
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
settings = await user_settings_service.get_or_create_settings(
|
|
||||||
db_session,
|
|
||||||
user_id=current_user.id,
|
|
||||||
)
|
|
||||||
updated = await user_settings_service.update_settings(
|
updated = await user_settings_service.update_settings(
|
||||||
db_session,
|
db_session,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
auto_run_enabled=bool(payload.auto_run_enabled),
|
auto_run_enabled=bool(payload.auto_run_enabled),
|
||||||
run_interval_minutes=parsed_interval,
|
run_interval_minutes=parsed_interval,
|
||||||
request_delay_seconds=parsed_delay,
|
request_delay_seconds=parsed_delay,
|
||||||
|
nav_visible_pages=parsed_nav_visible_pages,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"api.settings.updated",
|
"api.settings.updated",
|
||||||
|
|
@ -88,6 +96,7 @@ async def update_settings(
|
||||||
"auto_run_enabled": updated.auto_run_enabled,
|
"auto_run_enabled": updated.auto_run_enabled,
|
||||||
"run_interval_minutes": updated.run_interval_minutes,
|
"run_interval_minutes": updated.run_interval_minutes,
|
||||||
"request_delay_seconds": updated.request_delay_seconds,
|
"request_delay_seconds": updated.request_delay_seconds,
|
||||||
|
"nav_visible_pages": updated.nav_visible_pages,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return success_payload(
|
return success_payload(
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,10 @@ class RunSummaryData(BaseModel):
|
||||||
partial_count: int
|
partial_count: int
|
||||||
failed_state_counts: dict[str, int] = Field(default_factory=dict)
|
failed_state_counts: dict[str, int] = Field(default_factory=dict)
|
||||||
failed_reason_counts: dict[str, int] = Field(default_factory=dict)
|
failed_reason_counts: dict[str, int] = Field(default_factory=dict)
|
||||||
|
scrape_failure_counts: dict[str, int] = Field(default_factory=dict)
|
||||||
|
retry_counts: dict[str, int] = Field(default_factory=dict)
|
||||||
|
alert_thresholds: dict[str, int] = Field(default_factory=dict)
|
||||||
|
alert_flags: dict[str, bool] = Field(default_factory=dict)
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
@ -429,6 +433,7 @@ class SettingsData(BaseModel):
|
||||||
auto_run_enabled: bool
|
auto_run_enabled: bool
|
||||||
run_interval_minutes: int
|
run_interval_minutes: int
|
||||||
request_delay_seconds: int
|
request_delay_seconds: int
|
||||||
|
nav_visible_pages: list[str]
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
@ -444,6 +449,7 @@ class SettingsUpdateRequest(BaseModel):
|
||||||
auto_run_enabled: bool
|
auto_run_enabled: bool
|
||||||
run_interval_minutes: int
|
run_interval_minutes: int
|
||||||
request_delay_seconds: int
|
request_delay_seconds: int
|
||||||
|
nav_visible_pages: list[str] | None = None
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,13 @@ class UserSetting(Base):
|
||||||
request_delay_seconds: Mapped[int] = mapped_column(
|
request_delay_seconds: Mapped[int] = mapped_column(
|
||||||
Integer, nullable=False, server_default=text("10")
|
Integer, nullable=False, server_default=text("10")
|
||||||
)
|
)
|
||||||
|
nav_visible_pages: Mapped[list[str]] = mapped_column(
|
||||||
|
JSONB,
|
||||||
|
nullable=False,
|
||||||
|
server_default=text(
|
||||||
|
'\'["dashboard","scholars","publications","settings","style-guide","runs","users"]\'::jsonb'
|
||||||
|
),
|
||||||
|
)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,32 @@ from starlette.responses import Response
|
||||||
from app.logging_context import set_request_id
|
from app.logging_context import set_request_id
|
||||||
|
|
||||||
REQUEST_ID_HEADER = "X-Request-ID"
|
REQUEST_ID_HEADER = "X-Request-ID"
|
||||||
|
DEFAULT_PERMISSIONS_POLICY = (
|
||||||
|
"accelerometer=(), autoplay=(), camera=(), display-capture=(), "
|
||||||
|
"geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()"
|
||||||
|
)
|
||||||
|
DEFAULT_CSP_POLICY = (
|
||||||
|
"default-src 'self'; "
|
||||||
|
"base-uri 'self'; "
|
||||||
|
"form-action 'self'; "
|
||||||
|
"frame-ancestors 'none'; "
|
||||||
|
"img-src 'self' data: https:; "
|
||||||
|
"script-src 'self'; "
|
||||||
|
"style-src 'self' 'unsafe-inline'; "
|
||||||
|
"font-src 'self' data:; "
|
||||||
|
"connect-src 'self'; "
|
||||||
|
"object-src 'none'"
|
||||||
|
)
|
||||||
|
DEFAULT_CSP_DOCS_POLICY = (
|
||||||
|
"default-src 'self'; "
|
||||||
|
"img-src 'self' data: https:; "
|
||||||
|
"script-src 'self' 'unsafe-inline'; "
|
||||||
|
"style-src 'self' 'unsafe-inline'; "
|
||||||
|
"font-src 'self' data:; "
|
||||||
|
"connect-src 'self' https:; "
|
||||||
|
"object-src 'none'; "
|
||||||
|
"frame-ancestors 'none'"
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -80,6 +106,100 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||||
return any(path.startswith(prefix) for prefix in self._skip_paths)
|
return any(path.startswith(prefix) for prefix in self._skip_paths)
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
app,
|
||||||
|
*,
|
||||||
|
enabled: bool = True,
|
||||||
|
x_content_type_options: str = "nosniff",
|
||||||
|
x_frame_options: str = "DENY",
|
||||||
|
referrer_policy: str = "strict-origin-when-cross-origin",
|
||||||
|
permissions_policy: str = DEFAULT_PERMISSIONS_POLICY,
|
||||||
|
cross_origin_opener_policy: str = "same-origin",
|
||||||
|
cross_origin_resource_policy: str = "same-origin",
|
||||||
|
content_security_policy_enabled: bool = True,
|
||||||
|
content_security_policy: str = DEFAULT_CSP_POLICY,
|
||||||
|
content_security_policy_docs: str = DEFAULT_CSP_DOCS_POLICY,
|
||||||
|
content_security_policy_report_only: bool = False,
|
||||||
|
strict_transport_security_enabled: bool = False,
|
||||||
|
strict_transport_security_max_age: int = 31_536_000,
|
||||||
|
strict_transport_security_include_subdomains: bool = True,
|
||||||
|
strict_transport_security_preload: bool = False,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(app)
|
||||||
|
self._enabled = enabled
|
||||||
|
self._x_content_type_options = x_content_type_options.strip()
|
||||||
|
self._x_frame_options = x_frame_options.strip()
|
||||||
|
self._referrer_policy = referrer_policy.strip()
|
||||||
|
self._permissions_policy = permissions_policy.strip()
|
||||||
|
self._cross_origin_opener_policy = cross_origin_opener_policy.strip()
|
||||||
|
self._cross_origin_resource_policy = cross_origin_resource_policy.strip()
|
||||||
|
self._csp_enabled = content_security_policy_enabled
|
||||||
|
self._csp_policy = content_security_policy.strip()
|
||||||
|
self._csp_docs_policy = content_security_policy_docs.strip()
|
||||||
|
self._csp_report_only = content_security_policy_report_only
|
||||||
|
self._hsts_enabled = strict_transport_security_enabled
|
||||||
|
self._hsts_max_age = max(0, strict_transport_security_max_age)
|
||||||
|
self._hsts_include_subdomains = strict_transport_security_include_subdomains
|
||||||
|
self._hsts_preload = strict_transport_security_preload
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next) -> Response:
|
||||||
|
response = await call_next(request)
|
||||||
|
if not self._enabled:
|
||||||
|
return response
|
||||||
|
|
||||||
|
if self._x_content_type_options:
|
||||||
|
response.headers.setdefault("X-Content-Type-Options", self._x_content_type_options)
|
||||||
|
if self._x_frame_options:
|
||||||
|
response.headers.setdefault("X-Frame-Options", self._x_frame_options)
|
||||||
|
if self._referrer_policy:
|
||||||
|
response.headers.setdefault("Referrer-Policy", self._referrer_policy)
|
||||||
|
if self._permissions_policy:
|
||||||
|
response.headers.setdefault("Permissions-Policy", self._permissions_policy)
|
||||||
|
if self._cross_origin_opener_policy:
|
||||||
|
response.headers.setdefault(
|
||||||
|
"Cross-Origin-Opener-Policy",
|
||||||
|
self._cross_origin_opener_policy,
|
||||||
|
)
|
||||||
|
if self._cross_origin_resource_policy:
|
||||||
|
response.headers.setdefault(
|
||||||
|
"Cross-Origin-Resource-Policy",
|
||||||
|
self._cross_origin_resource_policy,
|
||||||
|
)
|
||||||
|
|
||||||
|
csp_policy = self._csp_policy_for_path(request.url.path)
|
||||||
|
if self._csp_enabled and csp_policy:
|
||||||
|
csp_header = (
|
||||||
|
"Content-Security-Policy-Report-Only"
|
||||||
|
if self._csp_report_only
|
||||||
|
else "Content-Security-Policy"
|
||||||
|
)
|
||||||
|
response.headers.setdefault(csp_header, csp_policy)
|
||||||
|
|
||||||
|
hsts = self._strict_transport_security_value()
|
||||||
|
if hsts:
|
||||||
|
response.headers.setdefault("Strict-Transport-Security", hsts)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _csp_policy_for_path(self, path: str) -> str:
|
||||||
|
if path.startswith("/docs") or path.startswith("/redoc"):
|
||||||
|
return self._csp_docs_policy or self._csp_policy
|
||||||
|
return self._csp_policy
|
||||||
|
|
||||||
|
def _strict_transport_security_value(self) -> str:
|
||||||
|
if not self._hsts_enabled:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
directives = [f"max-age={self._hsts_max_age}"]
|
||||||
|
if self._hsts_include_subdomains:
|
||||||
|
directives.append("includeSubDomains")
|
||||||
|
if self._hsts_preload:
|
||||||
|
directives.append("preload")
|
||||||
|
return "; ".join(directives)
|
||||||
|
|
||||||
|
|
||||||
def parse_skip_paths(raw_value: str) -> tuple[str, ...]:
|
def parse_skip_paths(raw_value: str) -> tuple[str, ...]:
|
||||||
parts = [part.strip() for part in raw_value.split(",")]
|
parts = [part.strip() for part in raw_value.split(",")]
|
||||||
return tuple(part for part in parts if part)
|
return tuple(part for part in parts if part)
|
||||||
|
|
|
||||||
26
app/main.py
26
app/main.py
|
|
@ -13,7 +13,11 @@ from app.api.router import router as api_router
|
||||||
from app.api.runtime_deps import get_ingestion_service, get_scholar_source
|
from app.api.runtime_deps import get_ingestion_service, get_scholar_source
|
||||||
from app.db.session import check_database
|
from app.db.session import check_database
|
||||||
from app.db.session import close_engine
|
from app.db.session import close_engine
|
||||||
from app.http.middleware import RequestLoggingMiddleware, parse_skip_paths
|
from app.http.middleware import (
|
||||||
|
RequestLoggingMiddleware,
|
||||||
|
SecurityHeadersMiddleware,
|
||||||
|
parse_skip_paths,
|
||||||
|
)
|
||||||
from app.logging_config import configure_logging, parse_redact_fields
|
from app.logging_config import configure_logging, parse_redact_fields
|
||||||
from app.security.csrf import CSRFMiddleware
|
from app.security.csrf import CSRFMiddleware
|
||||||
from app.services.scheduler import SchedulerService
|
from app.services.scheduler import SchedulerService
|
||||||
|
|
@ -63,6 +67,26 @@ app.add_middleware(
|
||||||
log_requests=settings.log_requests,
|
log_requests=settings.log_requests,
|
||||||
skip_paths=parse_skip_paths(settings.log_request_skip_paths),
|
skip_paths=parse_skip_paths(settings.log_request_skip_paths),
|
||||||
)
|
)
|
||||||
|
app.add_middleware(
|
||||||
|
SecurityHeadersMiddleware,
|
||||||
|
enabled=settings.security_headers_enabled,
|
||||||
|
x_content_type_options=settings.security_x_content_type_options,
|
||||||
|
x_frame_options=settings.security_x_frame_options,
|
||||||
|
referrer_policy=settings.security_referrer_policy,
|
||||||
|
permissions_policy=settings.security_permissions_policy,
|
||||||
|
cross_origin_opener_policy=settings.security_cross_origin_opener_policy,
|
||||||
|
cross_origin_resource_policy=settings.security_cross_origin_resource_policy,
|
||||||
|
content_security_policy_enabled=settings.security_csp_enabled,
|
||||||
|
content_security_policy=settings.security_csp_policy,
|
||||||
|
content_security_policy_docs=settings.security_csp_docs_policy,
|
||||||
|
content_security_policy_report_only=settings.security_csp_report_only,
|
||||||
|
strict_transport_security_enabled=settings.security_strict_transport_security_enabled,
|
||||||
|
strict_transport_security_max_age=settings.security_strict_transport_security_max_age,
|
||||||
|
strict_transport_security_include_subdomains=(
|
||||||
|
settings.security_strict_transport_security_include_subdomains
|
||||||
|
),
|
||||||
|
strict_transport_security_preload=settings.security_strict_transport_security_preload,
|
||||||
|
)
|
||||||
app.include_router(api_router)
|
app.include_router(api_router)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,11 @@ FAILED_STATES = {
|
||||||
ParseState.NETWORK_ERROR.value,
|
ParseState.NETWORK_ERROR.value,
|
||||||
"ingestion_error",
|
"ingestion_error",
|
||||||
}
|
}
|
||||||
|
FAILURE_BUCKET_BLOCKED = "blocked_or_captcha"
|
||||||
|
FAILURE_BUCKET_NETWORK = "network_error"
|
||||||
|
FAILURE_BUCKET_LAYOUT = "layout_changed"
|
||||||
|
FAILURE_BUCKET_INGESTION = "ingestion_error"
|
||||||
|
FAILURE_BUCKET_OTHER = "other_failure"
|
||||||
RUN_LOCK_NAMESPACE = 8217
|
RUN_LOCK_NAMESPACE = 8217
|
||||||
RESUMABLE_PARTIAL_REASONS = {
|
RESUMABLE_PARTIAL_REASONS = {
|
||||||
"max_pages_reached",
|
"max_pages_reached",
|
||||||
|
|
@ -83,6 +88,28 @@ class RunAlreadyInProgressError(RuntimeError):
|
||||||
"""Raised when a run lock for a user is already held by another process."""
|
"""Raised when a run lock for a user is already held by another process."""
|
||||||
|
|
||||||
|
|
||||||
|
def _int_or_default(value: Any, default: int = 0) -> int:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_failure_bucket(*, state: str, state_reason: str) -> str:
|
||||||
|
reason = state_reason.strip().lower()
|
||||||
|
normalized_state = state.strip().lower()
|
||||||
|
|
||||||
|
if normalized_state == ParseState.BLOCKED_OR_CAPTCHA.value or reason.startswith("blocked_"):
|
||||||
|
return FAILURE_BUCKET_BLOCKED
|
||||||
|
if normalized_state == ParseState.NETWORK_ERROR.value or reason.startswith("network_"):
|
||||||
|
return FAILURE_BUCKET_NETWORK
|
||||||
|
if normalized_state == ParseState.LAYOUT_CHANGED.value:
|
||||||
|
return FAILURE_BUCKET_LAYOUT
|
||||||
|
if normalized_state == "ingestion_error":
|
||||||
|
return FAILURE_BUCKET_INGESTION
|
||||||
|
return FAILURE_BUCKET_OTHER
|
||||||
|
|
||||||
|
|
||||||
class ScholarIngestionService:
|
class ScholarIngestionService:
|
||||||
def __init__(self, *, source: ScholarSource) -> None:
|
def __init__(self, *, source: ScholarSource) -> None:
|
||||||
self._source = source
|
self._source = source
|
||||||
|
|
@ -103,6 +130,9 @@ class ScholarIngestionService:
|
||||||
auto_queue_continuations: bool = True,
|
auto_queue_continuations: bool = True,
|
||||||
queue_delay_seconds: int = 60,
|
queue_delay_seconds: int = 60,
|
||||||
idempotency_key: str | None = None,
|
idempotency_key: str | None = None,
|
||||||
|
alert_blocked_failure_threshold: int = 1,
|
||||||
|
alert_network_failure_threshold: int = 2,
|
||||||
|
alert_retry_scheduled_threshold: int = 3,
|
||||||
) -> RunExecutionSummary:
|
) -> RunExecutionSummary:
|
||||||
lock_acquired = await self._try_acquire_user_lock(
|
lock_acquired = await self._try_acquire_user_lock(
|
||||||
db_session,
|
db_session,
|
||||||
|
|
@ -161,6 +191,9 @@ class ScholarIngestionService:
|
||||||
"max_pages_per_scholar": max_pages_per_scholar,
|
"max_pages_per_scholar": max_pages_per_scholar,
|
||||||
"page_size": page_size,
|
"page_size": page_size,
|
||||||
"idempotency_key": idempotency_key,
|
"idempotency_key": idempotency_key,
|
||||||
|
"alert_blocked_failure_threshold": alert_blocked_failure_threshold,
|
||||||
|
"alert_network_failure_threshold": alert_network_failure_threshold,
|
||||||
|
"alert_retry_scheduled_threshold": alert_retry_scheduled_threshold,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -416,17 +449,87 @@ class ScholarIngestionService:
|
||||||
|
|
||||||
failed_state_counts: dict[str, int] = {}
|
failed_state_counts: dict[str, int] = {}
|
||||||
failed_reason_counts: dict[str, int] = {}
|
failed_reason_counts: dict[str, int] = {}
|
||||||
|
scrape_failure_counts: dict[str, int] = {}
|
||||||
|
retries_scheduled_count = 0
|
||||||
|
scholars_with_retries_count = 0
|
||||||
|
retry_exhausted_count = 0
|
||||||
for entry in scholar_results:
|
for entry in scholar_results:
|
||||||
if str(entry.get("outcome", "")) != "failed":
|
attempt_count = max(0, _int_or_default(entry.get("attempt_count"), 0))
|
||||||
|
retries_for_entry = max(0, attempt_count - 1)
|
||||||
|
if retries_for_entry > 0:
|
||||||
|
retries_scheduled_count += retries_for_entry
|
||||||
|
scholars_with_retries_count += 1
|
||||||
|
|
||||||
|
outcome = str(entry.get("outcome", ""))
|
||||||
|
if outcome != "failed":
|
||||||
continue
|
continue
|
||||||
state = str(entry.get("state", ""))
|
|
||||||
|
state = str(entry.get("state", "")).strip()
|
||||||
if state not in FAILED_STATES:
|
if state not in FAILED_STATES:
|
||||||
continue
|
continue
|
||||||
failed_state_counts[state] = failed_state_counts.get(state, 0) + 1
|
failed_state_counts[state] = failed_state_counts.get(state, 0) + 1
|
||||||
|
|
||||||
reason = str(entry.get("state_reason", "")).strip()
|
reason = str(entry.get("state_reason", "")).strip()
|
||||||
if reason:
|
if reason:
|
||||||
failed_reason_counts[reason] = failed_reason_counts.get(reason, 0) + 1
|
failed_reason_counts[reason] = failed_reason_counts.get(reason, 0) + 1
|
||||||
|
|
||||||
|
failure_bucket = _classify_failure_bucket(state=state, state_reason=reason)
|
||||||
|
scrape_failure_counts[failure_bucket] = (
|
||||||
|
scrape_failure_counts.get(failure_bucket, 0) + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
state == ParseState.NETWORK_ERROR.value
|
||||||
|
and retries_for_entry > 0
|
||||||
|
):
|
||||||
|
retry_exhausted_count += 1
|
||||||
|
|
||||||
|
blocked_failure_count = int(scrape_failure_counts.get(FAILURE_BUCKET_BLOCKED, 0))
|
||||||
|
network_failure_count = int(scrape_failure_counts.get(FAILURE_BUCKET_NETWORK, 0))
|
||||||
|
|
||||||
|
bounded_blocked_threshold = max(1, int(alert_blocked_failure_threshold))
|
||||||
|
bounded_network_threshold = max(1, int(alert_network_failure_threshold))
|
||||||
|
bounded_retry_threshold = max(1, int(alert_retry_scheduled_threshold))
|
||||||
|
alert_flags = {
|
||||||
|
"blocked_failure_threshold_exceeded": blocked_failure_count >= bounded_blocked_threshold,
|
||||||
|
"network_failure_threshold_exceeded": network_failure_count >= bounded_network_threshold,
|
||||||
|
"retry_scheduled_threshold_exceeded": retries_scheduled_count >= bounded_retry_threshold,
|
||||||
|
}
|
||||||
|
|
||||||
|
if alert_flags["blocked_failure_threshold_exceeded"]:
|
||||||
|
logger.warning(
|
||||||
|
"ingestion.alert_blocked_failure_threshold_exceeded",
|
||||||
|
extra={
|
||||||
|
"event": "ingestion.alert_blocked_failure_threshold_exceeded",
|
||||||
|
"user_id": user_id,
|
||||||
|
"crawl_run_id": run.id,
|
||||||
|
"blocked_failure_count": blocked_failure_count,
|
||||||
|
"threshold": bounded_blocked_threshold,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if alert_flags["network_failure_threshold_exceeded"]:
|
||||||
|
logger.warning(
|
||||||
|
"ingestion.alert_network_failure_threshold_exceeded",
|
||||||
|
extra={
|
||||||
|
"event": "ingestion.alert_network_failure_threshold_exceeded",
|
||||||
|
"user_id": user_id,
|
||||||
|
"crawl_run_id": run.id,
|
||||||
|
"network_failure_count": network_failure_count,
|
||||||
|
"threshold": bounded_network_threshold,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if alert_flags["retry_scheduled_threshold_exceeded"]:
|
||||||
|
logger.warning(
|
||||||
|
"ingestion.alert_retry_scheduled_threshold_exceeded",
|
||||||
|
extra={
|
||||||
|
"event": "ingestion.alert_retry_scheduled_threshold_exceeded",
|
||||||
|
"user_id": user_id,
|
||||||
|
"crawl_run_id": run.id,
|
||||||
|
"retries_scheduled_count": retries_scheduled_count,
|
||||||
|
"threshold": bounded_retry_threshold,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
run.end_dt = datetime.now(timezone.utc)
|
run.end_dt = datetime.now(timezone.utc)
|
||||||
run.status = self._resolve_run_status(
|
run.status = self._resolve_run_status(
|
||||||
scholar_count=len(scholars),
|
scholar_count=len(scholars),
|
||||||
|
|
@ -442,6 +545,18 @@ class ScholarIngestionService:
|
||||||
"partial_count": partial_count,
|
"partial_count": partial_count,
|
||||||
"failed_state_counts": failed_state_counts,
|
"failed_state_counts": failed_state_counts,
|
||||||
"failed_reason_counts": failed_reason_counts,
|
"failed_reason_counts": failed_reason_counts,
|
||||||
|
"scrape_failure_counts": scrape_failure_counts,
|
||||||
|
"retry_counts": {
|
||||||
|
"retries_scheduled_count": retries_scheduled_count,
|
||||||
|
"scholars_with_retries_count": scholars_with_retries_count,
|
||||||
|
"retry_exhausted_count": retry_exhausted_count,
|
||||||
|
},
|
||||||
|
"alert_thresholds": {
|
||||||
|
"blocked_failure_threshold": bounded_blocked_threshold,
|
||||||
|
"network_failure_threshold": bounded_network_threshold,
|
||||||
|
"retry_scheduled_threshold": bounded_retry_threshold,
|
||||||
|
},
|
||||||
|
"alert_flags": alert_flags,
|
||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
"idempotency_key": idempotency_key,
|
"idempotency_key": idempotency_key,
|
||||||
|
|
@ -463,6 +578,10 @@ class ScholarIngestionService:
|
||||||
"failed_count": failed_count,
|
"failed_count": failed_count,
|
||||||
"partial_count": partial_count,
|
"partial_count": partial_count,
|
||||||
"new_publication_count": run.new_pub_count,
|
"new_publication_count": run.new_pub_count,
|
||||||
|
"blocked_failure_count": blocked_failure_count,
|
||||||
|
"network_failure_count": network_failure_count,
|
||||||
|
"retries_scheduled_count": retries_scheduled_count,
|
||||||
|
"alert_flags": alert_flags,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,26 +67,65 @@ def _summary_dict(error_log: object) -> dict[str, Any]:
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_int_dict(summary: dict[str, Any], key: str) -> dict[str, int]:
|
||||||
|
value = summary.get(key)
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
str(item_key): _safe_int(item_value, 0)
|
||||||
|
for item_key, item_value in value.items()
|
||||||
|
if isinstance(item_key, str)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_bool_dict(summary: dict[str, Any], key: str) -> dict[str, bool]:
|
||||||
|
value = summary.get(key)
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
str(item_key): bool(item_value)
|
||||||
|
for item_key, item_value in value.items()
|
||||||
|
if isinstance(item_key, str)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def extract_run_summary(error_log: object) -> dict[str, Any]:
|
def extract_run_summary(error_log: object) -> dict[str, Any]:
|
||||||
summary = _summary_dict(error_log)
|
summary = _summary_dict(error_log)
|
||||||
return {
|
return {
|
||||||
"succeeded_count": _safe_int(summary.get("succeeded_count", 0)),
|
"succeeded_count": _safe_int(summary.get("succeeded_count", 0)),
|
||||||
"failed_count": _safe_int(summary.get("failed_count", 0)),
|
"failed_count": _safe_int(summary.get("failed_count", 0)),
|
||||||
"partial_count": _safe_int(summary.get("partial_count", 0)),
|
"partial_count": _safe_int(summary.get("partial_count", 0)),
|
||||||
"failed_state_counts": {
|
"failed_state_counts": _summary_int_dict(summary, "failed_state_counts"),
|
||||||
str(key): _safe_int(value, 0)
|
"failed_reason_counts": _summary_int_dict(summary, "failed_reason_counts"),
|
||||||
for key, value in summary.get("failed_state_counts", {}).items()
|
"scrape_failure_counts": _summary_int_dict(summary, "scrape_failure_counts"),
|
||||||
if isinstance(key, str)
|
"retry_counts": {
|
||||||
}
|
"retries_scheduled_count": _safe_int(
|
||||||
if isinstance(summary.get("failed_state_counts"), dict)
|
(
|
||||||
else {},
|
summary.get("retry_counts", {}).get("retries_scheduled_count")
|
||||||
"failed_reason_counts": {
|
if isinstance(summary.get("retry_counts"), dict)
|
||||||
str(key): _safe_int(value, 0)
|
else 0
|
||||||
for key, value in summary.get("failed_reason_counts", {}).items()
|
),
|
||||||
if isinstance(key, str)
|
0,
|
||||||
}
|
),
|
||||||
if isinstance(summary.get("failed_reason_counts"), dict)
|
"scholars_with_retries_count": _safe_int(
|
||||||
else {},
|
(
|
||||||
|
summary.get("retry_counts", {}).get("scholars_with_retries_count")
|
||||||
|
if isinstance(summary.get("retry_counts"), dict)
|
||||||
|
else 0
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
"retry_exhausted_count": _safe_int(
|
||||||
|
(
|
||||||
|
summary.get("retry_counts", {}).get("retry_exhausted_count")
|
||||||
|
if isinstance(summary.get("retry_counts"), dict)
|
||||||
|
else 0
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"alert_thresholds": _summary_int_dict(summary, "alert_thresholds"),
|
||||||
|
"alert_flags": _summary_bool_dict(summary, "alert_flags"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ from app.db.session import get_session_factory
|
||||||
from app.services import continuation_queue as queue_service
|
from app.services import continuation_queue as queue_service
|
||||||
from app.services.ingestion import RunAlreadyInProgressError, ScholarIngestionService
|
from app.services.ingestion import RunAlreadyInProgressError, ScholarIngestionService
|
||||||
from app.services.scholar_source import LiveScholarSource
|
from app.services.scholar_source import LiveScholarSource
|
||||||
|
from app.settings import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -195,6 +196,9 @@ class SchedulerService:
|
||||||
page_size=self._page_size,
|
page_size=self._page_size,
|
||||||
auto_queue_continuations=self._continuation_queue_enabled,
|
auto_queue_continuations=self._continuation_queue_enabled,
|
||||||
queue_delay_seconds=self._continuation_base_delay_seconds,
|
queue_delay_seconds=self._continuation_base_delay_seconds,
|
||||||
|
alert_blocked_failure_threshold=settings.ingestion_alert_blocked_failure_threshold,
|
||||||
|
alert_network_failure_threshold=settings.ingestion_alert_network_failure_threshold,
|
||||||
|
alert_retry_scheduled_threshold=settings.ingestion_alert_retry_scheduled_threshold,
|
||||||
)
|
)
|
||||||
except RunAlreadyInProgressError:
|
except RunAlreadyInProgressError:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
|
@ -326,6 +330,9 @@ class SchedulerService:
|
||||||
},
|
},
|
||||||
auto_queue_continuations=self._continuation_queue_enabled,
|
auto_queue_continuations=self._continuation_queue_enabled,
|
||||||
queue_delay_seconds=self._continuation_base_delay_seconds,
|
queue_delay_seconds=self._continuation_base_delay_seconds,
|
||||||
|
alert_blocked_failure_threshold=settings.ingestion_alert_blocked_failure_threshold,
|
||||||
|
alert_network_failure_threshold=settings.ingestion_alert_network_failure_threshold,
|
||||||
|
alert_retry_scheduled_threshold=settings.ingestion_alert_retry_scheduled_threshold,
|
||||||
)
|
)
|
||||||
except RunAlreadyInProgressError:
|
except RunAlreadyInProgressError:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ DEFAULT_AUTHOR_SEARCH_COOLDOWN_BLOCK_THRESHOLD = 1
|
||||||
DEFAULT_AUTHOR_SEARCH_COOLDOWN_SECONDS = 1800
|
DEFAULT_AUTHOR_SEARCH_COOLDOWN_SECONDS = 1800
|
||||||
DEFAULT_AUTHOR_SEARCH_MIN_INTERVAL_SECONDS = 3.0
|
DEFAULT_AUTHOR_SEARCH_MIN_INTERVAL_SECONDS = 3.0
|
||||||
DEFAULT_AUTHOR_SEARCH_INTERVAL_JITTER_SECONDS = 1.0
|
DEFAULT_AUTHOR_SEARCH_INTERVAL_JITTER_SECONDS = 1.0
|
||||||
|
DEFAULT_AUTHOR_SEARCH_RETRY_ALERT_THRESHOLD = 2
|
||||||
|
DEFAULT_AUTHOR_SEARCH_COOLDOWN_REJECTION_ALERT_THRESHOLD = 3
|
||||||
ALLOWED_IMAGE_UPLOAD_CONTENT_TYPES = {
|
ALLOWED_IMAGE_UPLOAD_CONTENT_TYPES = {
|
||||||
"image/jpeg": ".jpg",
|
"image/jpeg": ".jpg",
|
||||||
"image/png": ".png",
|
"image/png": ".png",
|
||||||
|
|
@ -101,6 +103,8 @@ _AUTHOR_SEARCH_CACHE: OrderedDict[str, _AuthorSearchCacheEntry] = OrderedDict()
|
||||||
_AUTHOR_SEARCH_LAST_LIVE_REQUEST_MONOTONIC = 0.0
|
_AUTHOR_SEARCH_LAST_LIVE_REQUEST_MONOTONIC = 0.0
|
||||||
_AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC: datetime | None = None
|
_AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC: datetime | None = None
|
||||||
_AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT = 0
|
_AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT = 0
|
||||||
|
_AUTHOR_SEARCH_COOLDOWN_REJECTION_COUNT = 0
|
||||||
|
_AUTHOR_SEARCH_COOLDOWN_ALERT_EMITTED = False
|
||||||
|
|
||||||
|
|
||||||
class ScholarServiceError(ValueError):
|
class ScholarServiceError(ValueError):
|
||||||
|
|
@ -281,10 +285,14 @@ def _reset_author_search_runtime_state_for_tests() -> None:
|
||||||
global _AUTHOR_SEARCH_LAST_LIVE_REQUEST_MONOTONIC
|
global _AUTHOR_SEARCH_LAST_LIVE_REQUEST_MONOTONIC
|
||||||
global _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC
|
global _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC
|
||||||
global _AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT
|
global _AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT
|
||||||
|
global _AUTHOR_SEARCH_COOLDOWN_REJECTION_COUNT
|
||||||
|
global _AUTHOR_SEARCH_COOLDOWN_ALERT_EMITTED
|
||||||
_AUTHOR_SEARCH_CACHE.clear()
|
_AUTHOR_SEARCH_CACHE.clear()
|
||||||
_AUTHOR_SEARCH_LAST_LIVE_REQUEST_MONOTONIC = 0.0
|
_AUTHOR_SEARCH_LAST_LIVE_REQUEST_MONOTONIC = 0.0
|
||||||
_AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC = None
|
_AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC = None
|
||||||
_AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT = 0
|
_AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT = 0
|
||||||
|
_AUTHOR_SEARCH_COOLDOWN_REJECTION_COUNT = 0
|
||||||
|
_AUTHOR_SEARCH_COOLDOWN_ALERT_EMITTED = False
|
||||||
|
|
||||||
|
|
||||||
async def list_scholars_for_user(
|
async def list_scholars_for_user(
|
||||||
|
|
@ -379,10 +387,14 @@ async def search_author_candidates(
|
||||||
interval_jitter_seconds: float = DEFAULT_AUTHOR_SEARCH_INTERVAL_JITTER_SECONDS,
|
interval_jitter_seconds: float = DEFAULT_AUTHOR_SEARCH_INTERVAL_JITTER_SECONDS,
|
||||||
cooldown_block_threshold: int = DEFAULT_AUTHOR_SEARCH_COOLDOWN_BLOCK_THRESHOLD,
|
cooldown_block_threshold: int = DEFAULT_AUTHOR_SEARCH_COOLDOWN_BLOCK_THRESHOLD,
|
||||||
cooldown_seconds: int = DEFAULT_AUTHOR_SEARCH_COOLDOWN_SECONDS,
|
cooldown_seconds: int = DEFAULT_AUTHOR_SEARCH_COOLDOWN_SECONDS,
|
||||||
|
retry_alert_threshold: int = DEFAULT_AUTHOR_SEARCH_RETRY_ALERT_THRESHOLD,
|
||||||
|
cooldown_rejection_alert_threshold: int = DEFAULT_AUTHOR_SEARCH_COOLDOWN_REJECTION_ALERT_THRESHOLD,
|
||||||
) -> ParsedAuthorSearchPage:
|
) -> ParsedAuthorSearchPage:
|
||||||
global _AUTHOR_SEARCH_LAST_LIVE_REQUEST_MONOTONIC
|
global _AUTHOR_SEARCH_LAST_LIVE_REQUEST_MONOTONIC
|
||||||
global _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC
|
global _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC
|
||||||
global _AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT
|
global _AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT
|
||||||
|
global _AUTHOR_SEARCH_COOLDOWN_REJECTION_COUNT
|
||||||
|
global _AUTHOR_SEARCH_COOLDOWN_ALERT_EMITTED
|
||||||
|
|
||||||
normalized_query = query.strip()
|
normalized_query = query.strip()
|
||||||
if len(normalized_query) < 2:
|
if len(normalized_query) < 2:
|
||||||
|
|
@ -408,8 +420,47 @@ async def search_author_candidates(
|
||||||
now_utc = datetime.now(timezone.utc)
|
now_utc = datetime.now(timezone.utc)
|
||||||
now_monotonic = time.monotonic()
|
now_monotonic = time.monotonic()
|
||||||
|
|
||||||
|
if (
|
||||||
|
_AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC is not None
|
||||||
|
and now_utc >= _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
"scholar_search.cooldown_expired",
|
||||||
|
extra={
|
||||||
|
"event": "scholar_search.cooldown_expired",
|
||||||
|
"cooldown_until_utc": _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC.isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
_AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC = None
|
||||||
|
_AUTHOR_SEARCH_COOLDOWN_REJECTION_COUNT = 0
|
||||||
|
_AUTHOR_SEARCH_COOLDOWN_ALERT_EMITTED = False
|
||||||
|
|
||||||
cooldown_remaining_seconds = _author_search_cooldown_remaining_seconds(now_utc)
|
cooldown_remaining_seconds = _author_search_cooldown_remaining_seconds(now_utc)
|
||||||
if cooldown_remaining_seconds > 0:
|
if cooldown_remaining_seconds > 0:
|
||||||
|
_AUTHOR_SEARCH_COOLDOWN_REJECTION_COUNT += 1
|
||||||
|
bounded_cooldown_rejection_alert_threshold = max(
|
||||||
|
1,
|
||||||
|
int(cooldown_rejection_alert_threshold),
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
_AUTHOR_SEARCH_COOLDOWN_REJECTION_COUNT
|
||||||
|
>= bounded_cooldown_rejection_alert_threshold
|
||||||
|
and not _AUTHOR_SEARCH_COOLDOWN_ALERT_EMITTED
|
||||||
|
):
|
||||||
|
logger.error(
|
||||||
|
"scholar_search.cooldown_rejection_threshold_exceeded",
|
||||||
|
extra={
|
||||||
|
"event": "scholar_search.cooldown_rejection_threshold_exceeded",
|
||||||
|
"query": normalized_query,
|
||||||
|
"cooldown_rejection_count": _AUTHOR_SEARCH_COOLDOWN_REJECTION_COUNT,
|
||||||
|
"threshold": bounded_cooldown_rejection_alert_threshold,
|
||||||
|
"cooldown_until_utc": _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC.isoformat()
|
||||||
|
if _AUTHOR_SEARCH_COOLDOWN_UNTIL_UTC
|
||||||
|
else None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
_AUTHOR_SEARCH_COOLDOWN_ALERT_EMITTED = True
|
||||||
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"scholar_search.cooldown_active",
|
"scholar_search.cooldown_active",
|
||||||
extra={
|
extra={
|
||||||
|
|
@ -421,12 +472,15 @@ async def search_author_candidates(
|
||||||
else None,
|
else None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return _policy_blocked_author_search_result(
|
|
||||||
reason=SEARCH_COOLDOWN_REASON,
|
|
||||||
warning_codes = [
|
warning_codes = [
|
||||||
"author_search_cooldown_active",
|
"author_search_cooldown_active",
|
||||||
f"author_search_cooldown_remaining_{cooldown_remaining_seconds}s",
|
f"author_search_cooldown_remaining_{cooldown_remaining_seconds}s",
|
||||||
],
|
]
|
||||||
|
if _AUTHOR_SEARCH_COOLDOWN_ALERT_EMITTED:
|
||||||
|
warning_codes.append("author_search_cooldown_alert_threshold_exceeded")
|
||||||
|
return _policy_blocked_author_search_result(
|
||||||
|
reason=SEARCH_COOLDOWN_REASON,
|
||||||
|
warning_codes=warning_codes,
|
||||||
limit=bounded_limit,
|
limit=bounded_limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -472,6 +526,7 @@ async def search_author_candidates(
|
||||||
max_attempts = max(1, int(network_error_retries) + 1)
|
max_attempts = max(1, int(network_error_retries) + 1)
|
||||||
parsed: ParsedAuthorSearchPage | None = None
|
parsed: ParsedAuthorSearchPage | None = None
|
||||||
retry_warnings: list[str] = []
|
retry_warnings: list[str] = []
|
||||||
|
retry_scheduled_count = 0
|
||||||
|
|
||||||
for attempt_index in range(max_attempts):
|
for attempt_index in range(max_attempts):
|
||||||
fetch_result = await source.fetch_author_search_html(normalized_query, start=0)
|
fetch_result = await source.fetch_author_search_html(normalized_query, start=0)
|
||||||
|
|
@ -480,6 +535,7 @@ async def search_author_candidates(
|
||||||
break
|
break
|
||||||
|
|
||||||
retry_warnings.append("network_retry_scheduled_for_author_search")
|
retry_warnings.append("network_retry_scheduled_for_author_search")
|
||||||
|
retry_scheduled_count += 1
|
||||||
sleep_seconds = max(float(retry_backoff_seconds), 0.0) * (2**attempt_index)
|
sleep_seconds = max(float(retry_backoff_seconds), 0.0) * (2**attempt_index)
|
||||||
if sleep_seconds > 0:
|
if sleep_seconds > 0:
|
||||||
await asyncio.sleep(sleep_seconds)
|
await asyncio.sleep(sleep_seconds)
|
||||||
|
|
@ -494,6 +550,27 @@ async def search_author_candidates(
|
||||||
warnings=_merge_warnings(parsed.warnings, retry_warnings),
|
warnings=_merge_warnings(parsed.warnings, retry_warnings),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
bounded_retry_alert_threshold = max(1, int(retry_alert_threshold))
|
||||||
|
if retry_scheduled_count >= bounded_retry_alert_threshold:
|
||||||
|
logger.warning(
|
||||||
|
"scholar_search.retry_threshold_exceeded",
|
||||||
|
extra={
|
||||||
|
"event": "scholar_search.retry_threshold_exceeded",
|
||||||
|
"query": normalized_query,
|
||||||
|
"retry_scheduled_count": retry_scheduled_count,
|
||||||
|
"threshold": bounded_retry_alert_threshold,
|
||||||
|
"final_state": merged_parsed.state.value,
|
||||||
|
"final_state_reason": merged_parsed.state_reason,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
merged_parsed = replace(
|
||||||
|
merged_parsed,
|
||||||
|
warnings=_merge_warnings(
|
||||||
|
merged_parsed.warnings,
|
||||||
|
[f"author_search_retry_threshold_exceeded_{retry_scheduled_count}"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
if _is_author_search_block_state(merged_parsed):
|
if _is_author_search_block_state(merged_parsed):
|
||||||
_AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT += 1
|
_AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT += 1
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
@ -510,6 +587,8 @@ async def search_author_candidates(
|
||||||
seconds=max(60, int(cooldown_seconds))
|
seconds=max(60, int(cooldown_seconds))
|
||||||
)
|
)
|
||||||
_AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT = 0
|
_AUTHOR_SEARCH_CONSECUTIVE_BLOCKED_COUNT = 0
|
||||||
|
_AUTHOR_SEARCH_COOLDOWN_REJECTION_COUNT = 0
|
||||||
|
_AUTHOR_SEARCH_COOLDOWN_ALERT_EMITTED = False
|
||||||
merged_parsed = replace(
|
merged_parsed = replace(
|
||||||
merged_parsed,
|
merged_parsed,
|
||||||
warnings=_merge_warnings(
|
warnings=_merge_warnings(
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,38 @@ class UserSettingsServiceError(ValueError):
|
||||||
"""Raised for expected settings-validation failures."""
|
"""Raised for expected settings-validation failures."""
|
||||||
|
|
||||||
|
|
||||||
|
NAV_PAGE_DASHBOARD = "dashboard"
|
||||||
|
NAV_PAGE_SCHOLARS = "scholars"
|
||||||
|
NAV_PAGE_PUBLICATIONS = "publications"
|
||||||
|
NAV_PAGE_SETTINGS = "settings"
|
||||||
|
NAV_PAGE_STYLE_GUIDE = "style-guide"
|
||||||
|
NAV_PAGE_RUNS = "runs"
|
||||||
|
NAV_PAGE_USERS = "users"
|
||||||
|
|
||||||
|
ALLOWED_NAV_PAGES = (
|
||||||
|
NAV_PAGE_DASHBOARD,
|
||||||
|
NAV_PAGE_SCHOLARS,
|
||||||
|
NAV_PAGE_PUBLICATIONS,
|
||||||
|
NAV_PAGE_SETTINGS,
|
||||||
|
NAV_PAGE_STYLE_GUIDE,
|
||||||
|
NAV_PAGE_RUNS,
|
||||||
|
NAV_PAGE_USERS,
|
||||||
|
)
|
||||||
|
REQUIRED_NAV_PAGES = (
|
||||||
|
NAV_PAGE_DASHBOARD,
|
||||||
|
NAV_PAGE_SCHOLARS,
|
||||||
|
NAV_PAGE_SETTINGS,
|
||||||
|
)
|
||||||
|
DEFAULT_NAV_VISIBLE_PAGES = list(ALLOWED_NAV_PAGES)
|
||||||
|
|
||||||
|
|
||||||
def parse_run_interval_minutes(value: str) -> int:
|
def parse_run_interval_minutes(value: str) -> int:
|
||||||
try:
|
try:
|
||||||
parsed = int(value)
|
parsed = int(value)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise UserSettingsServiceError("Run interval must be a whole number.") from exc
|
raise UserSettingsServiceError("Check interval must be a whole number.") from exc
|
||||||
if parsed < 15:
|
if parsed < 15:
|
||||||
raise UserSettingsServiceError("Run interval must be at least 15 minutes.")
|
raise UserSettingsServiceError("Check interval must be at least 15 minutes.")
|
||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -25,11 +50,41 @@ def parse_request_delay_seconds(value: str) -> int:
|
||||||
parsed = int(value)
|
parsed = int(value)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise UserSettingsServiceError("Request delay must be a whole number.") from exc
|
raise UserSettingsServiceError("Request delay must be a whole number.") from exc
|
||||||
if parsed < 1:
|
if parsed < 2:
|
||||||
raise UserSettingsServiceError("Request delay must be at least 1 second.")
|
raise UserSettingsServiceError("Request delay must be at least 2 seconds.")
|
||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def parse_nav_visible_pages(value: object) -> list[str]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
raise UserSettingsServiceError("Navigation visibility must be a list of page ids.")
|
||||||
|
|
||||||
|
deduped: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
for raw_page in value:
|
||||||
|
if not isinstance(raw_page, str):
|
||||||
|
raise UserSettingsServiceError("Navigation visibility entries must be strings.")
|
||||||
|
|
||||||
|
page_id = raw_page.strip()
|
||||||
|
if page_id not in ALLOWED_NAV_PAGES:
|
||||||
|
raise UserSettingsServiceError(f"Unsupported navigation page id: {page_id}")
|
||||||
|
|
||||||
|
if page_id in seen:
|
||||||
|
continue
|
||||||
|
|
||||||
|
seen.add(page_id)
|
||||||
|
deduped.append(page_id)
|
||||||
|
|
||||||
|
missing_required = [page for page in REQUIRED_NAV_PAGES if page not in seen]
|
||||||
|
if missing_required:
|
||||||
|
raise UserSettingsServiceError(
|
||||||
|
"Dashboard, Scholars, and Settings must remain visible."
|
||||||
|
)
|
||||||
|
|
||||||
|
return deduped
|
||||||
|
|
||||||
|
|
||||||
async def get_or_create_settings(
|
async def get_or_create_settings(
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
*,
|
*,
|
||||||
|
|
@ -56,11 +111,12 @@ async def update_settings(
|
||||||
auto_run_enabled: bool,
|
auto_run_enabled: bool,
|
||||||
run_interval_minutes: int,
|
run_interval_minutes: int,
|
||||||
request_delay_seconds: int,
|
request_delay_seconds: int,
|
||||||
|
nav_visible_pages: list[str],
|
||||||
) -> UserSetting:
|
) -> UserSetting:
|
||||||
settings.auto_run_enabled = auto_run_enabled
|
settings.auto_run_enabled = auto_run_enabled
|
||||||
settings.run_interval_minutes = run_interval_minutes
|
settings.run_interval_minutes = run_interval_minutes
|
||||||
settings.request_delay_seconds = request_delay_seconds
|
settings.request_delay_seconds = request_delay_seconds
|
||||||
|
settings.nav_visible_pages = nav_visible_pages
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
await db_session.refresh(settings)
|
await db_session.refresh(settings)
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,33 @@
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
DEFAULT_SECURITY_PERMISSIONS_POLICY = (
|
||||||
|
"accelerometer=(), autoplay=(), camera=(), display-capture=(), "
|
||||||
|
"geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()"
|
||||||
|
)
|
||||||
|
DEFAULT_SECURITY_CSP_POLICY = (
|
||||||
|
"default-src 'self'; "
|
||||||
|
"base-uri 'self'; "
|
||||||
|
"form-action 'self'; "
|
||||||
|
"frame-ancestors 'none'; "
|
||||||
|
"img-src 'self' data: https:; "
|
||||||
|
"script-src 'self'; "
|
||||||
|
"style-src 'self' 'unsafe-inline'; "
|
||||||
|
"font-src 'self' data:; "
|
||||||
|
"connect-src 'self'; "
|
||||||
|
"object-src 'none'"
|
||||||
|
)
|
||||||
|
DEFAULT_SECURITY_CSP_DOCS_POLICY = (
|
||||||
|
"default-src 'self'; "
|
||||||
|
"img-src 'self' data: https:; "
|
||||||
|
"script-src 'self' 'unsafe-inline'; "
|
||||||
|
"style-src 'self' 'unsafe-inline'; "
|
||||||
|
"font-src 'self' data:; "
|
||||||
|
"connect-src 'self' https:; "
|
||||||
|
"object-src 'none'; "
|
||||||
|
"frame-ancestors 'none'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _env_bool(name: str, default: bool) -> bool:
|
def _env_bool(name: str, default: bool) -> bool:
|
||||||
value = os.getenv(name)
|
value = os.getenv(name)
|
||||||
|
|
@ -39,6 +66,42 @@ class Settings:
|
||||||
)
|
)
|
||||||
session_secret_key: str = os.getenv("SESSION_SECRET_KEY", "dev-insecure-session-key")
|
session_secret_key: str = os.getenv("SESSION_SECRET_KEY", "dev-insecure-session-key")
|
||||||
session_cookie_secure: bool = _env_bool("SESSION_COOKIE_SECURE", False)
|
session_cookie_secure: bool = _env_bool("SESSION_COOKIE_SECURE", False)
|
||||||
|
security_headers_enabled: bool = _env_bool("SECURITY_HEADERS_ENABLED", True)
|
||||||
|
security_x_content_type_options: str = _env_str("SECURITY_X_CONTENT_TYPE_OPTIONS", "nosniff")
|
||||||
|
security_x_frame_options: str = _env_str("SECURITY_X_FRAME_OPTIONS", "DENY")
|
||||||
|
security_referrer_policy: str = _env_str("SECURITY_REFERRER_POLICY", "strict-origin-when-cross-origin")
|
||||||
|
security_permissions_policy: str = _env_str(
|
||||||
|
"SECURITY_PERMISSIONS_POLICY",
|
||||||
|
DEFAULT_SECURITY_PERMISSIONS_POLICY,
|
||||||
|
)
|
||||||
|
security_cross_origin_opener_policy: str = _env_str(
|
||||||
|
"SECURITY_CROSS_ORIGIN_OPENER_POLICY",
|
||||||
|
"same-origin",
|
||||||
|
)
|
||||||
|
security_cross_origin_resource_policy: str = _env_str(
|
||||||
|
"SECURITY_CROSS_ORIGIN_RESOURCE_POLICY",
|
||||||
|
"same-origin",
|
||||||
|
)
|
||||||
|
security_csp_enabled: bool = _env_bool("SECURITY_CSP_ENABLED", True)
|
||||||
|
security_csp_policy: str = _env_str("SECURITY_CSP_POLICY", DEFAULT_SECURITY_CSP_POLICY)
|
||||||
|
security_csp_docs_policy: str = _env_str("SECURITY_CSP_DOCS_POLICY", DEFAULT_SECURITY_CSP_DOCS_POLICY)
|
||||||
|
security_csp_report_only: bool = _env_bool("SECURITY_CSP_REPORT_ONLY", False)
|
||||||
|
security_strict_transport_security_enabled: bool = _env_bool(
|
||||||
|
"SECURITY_STRICT_TRANSPORT_SECURITY_ENABLED",
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
security_strict_transport_security_max_age: int = _env_int(
|
||||||
|
"SECURITY_STRICT_TRANSPORT_SECURITY_MAX_AGE",
|
||||||
|
31_536_000,
|
||||||
|
)
|
||||||
|
security_strict_transport_security_include_subdomains: bool = _env_bool(
|
||||||
|
"SECURITY_STRICT_TRANSPORT_SECURITY_INCLUDE_SUBDOMAINS",
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
security_strict_transport_security_preload: bool = _env_bool(
|
||||||
|
"SECURITY_STRICT_TRANSPORT_SECURITY_PRELOAD",
|
||||||
|
False,
|
||||||
|
)
|
||||||
login_rate_limit_attempts: int = _env_int("LOGIN_RATE_LIMIT_ATTEMPTS", 5)
|
login_rate_limit_attempts: int = _env_int("LOGIN_RATE_LIMIT_ATTEMPTS", 5)
|
||||||
login_rate_limit_window_seconds: int = _env_int(
|
login_rate_limit_window_seconds: int = _env_int(
|
||||||
"LOGIN_RATE_LIMIT_WINDOW_SECONDS",
|
"LOGIN_RATE_LIMIT_WINDOW_SECONDS",
|
||||||
|
|
@ -62,6 +125,18 @@ class Settings:
|
||||||
30,
|
30,
|
||||||
)
|
)
|
||||||
ingestion_page_size: int = _env_int("INGESTION_PAGE_SIZE", 100)
|
ingestion_page_size: int = _env_int("INGESTION_PAGE_SIZE", 100)
|
||||||
|
ingestion_alert_blocked_failure_threshold: int = _env_int(
|
||||||
|
"INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
ingestion_alert_network_failure_threshold: int = _env_int(
|
||||||
|
"INGESTION_ALERT_NETWORK_FAILURE_THRESHOLD",
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
ingestion_alert_retry_scheduled_threshold: int = _env_int(
|
||||||
|
"INGESTION_ALERT_RETRY_SCHEDULED_THRESHOLD",
|
||||||
|
3,
|
||||||
|
)
|
||||||
ingestion_continuation_queue_enabled: bool = _env_bool(
|
ingestion_continuation_queue_enabled: bool = _env_bool(
|
||||||
"INGESTION_CONTINUATION_QUEUE_ENABLED",
|
"INGESTION_CONTINUATION_QUEUE_ENABLED",
|
||||||
True,
|
True,
|
||||||
|
|
@ -104,11 +179,11 @@ class Settings:
|
||||||
)
|
)
|
||||||
scholar_name_search_min_interval_seconds: float = _env_float(
|
scholar_name_search_min_interval_seconds: float = _env_float(
|
||||||
"SCHOLAR_NAME_SEARCH_MIN_INTERVAL_SECONDS",
|
"SCHOLAR_NAME_SEARCH_MIN_INTERVAL_SECONDS",
|
||||||
3.0,
|
8.0,
|
||||||
)
|
)
|
||||||
scholar_name_search_interval_jitter_seconds: float = _env_float(
|
scholar_name_search_interval_jitter_seconds: float = _env_float(
|
||||||
"SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS",
|
"SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS",
|
||||||
1.0,
|
2.0,
|
||||||
)
|
)
|
||||||
scholar_name_search_cooldown_block_threshold: int = _env_int(
|
scholar_name_search_cooldown_block_threshold: int = _env_int(
|
||||||
"SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD",
|
"SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD",
|
||||||
|
|
@ -118,6 +193,14 @@ class Settings:
|
||||||
"SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS",
|
"SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS",
|
||||||
1800,
|
1800,
|
||||||
)
|
)
|
||||||
|
scholar_name_search_alert_retry_count_threshold: int = _env_int(
|
||||||
|
"SCHOLAR_NAME_SEARCH_ALERT_RETRY_COUNT_THRESHOLD",
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
scholar_name_search_alert_cooldown_rejections_threshold: int = _env_int(
|
||||||
|
"SCHOLAR_NAME_SEARCH_ALERT_COOLDOWN_REJECTIONS_THRESHOLD",
|
||||||
|
3,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,9 @@ services:
|
||||||
INGESTION_RETRY_BACKOFF_SECONDS: ${INGESTION_RETRY_BACKOFF_SECONDS:-1.0}
|
INGESTION_RETRY_BACKOFF_SECONDS: ${INGESTION_RETRY_BACKOFF_SECONDS:-1.0}
|
||||||
INGESTION_MAX_PAGES_PER_SCHOLAR: ${INGESTION_MAX_PAGES_PER_SCHOLAR:-30}
|
INGESTION_MAX_PAGES_PER_SCHOLAR: ${INGESTION_MAX_PAGES_PER_SCHOLAR:-30}
|
||||||
INGESTION_PAGE_SIZE: ${INGESTION_PAGE_SIZE:-100}
|
INGESTION_PAGE_SIZE: ${INGESTION_PAGE_SIZE:-100}
|
||||||
|
INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD: ${INGESTION_ALERT_BLOCKED_FAILURE_THRESHOLD:-1}
|
||||||
|
INGESTION_ALERT_NETWORK_FAILURE_THRESHOLD: ${INGESTION_ALERT_NETWORK_FAILURE_THRESHOLD:-2}
|
||||||
|
INGESTION_ALERT_RETRY_SCHEDULED_THRESHOLD: ${INGESTION_ALERT_RETRY_SCHEDULED_THRESHOLD:-3}
|
||||||
INGESTION_CONTINUATION_QUEUE_ENABLED: ${INGESTION_CONTINUATION_QUEUE_ENABLED:-1}
|
INGESTION_CONTINUATION_QUEUE_ENABLED: ${INGESTION_CONTINUATION_QUEUE_ENABLED:-1}
|
||||||
INGESTION_CONTINUATION_BASE_DELAY_SECONDS: ${INGESTION_CONTINUATION_BASE_DELAY_SECONDS:-120}
|
INGESTION_CONTINUATION_BASE_DELAY_SECONDS: ${INGESTION_CONTINUATION_BASE_DELAY_SECONDS:-120}
|
||||||
INGESTION_CONTINUATION_MAX_DELAY_SECONDS: ${INGESTION_CONTINUATION_MAX_DELAY_SECONDS:-3600}
|
INGESTION_CONTINUATION_MAX_DELAY_SECONDS: ${INGESTION_CONTINUATION_MAX_DELAY_SECONDS:-3600}
|
||||||
|
|
@ -59,6 +62,8 @@ services:
|
||||||
SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS: ${SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS:-2.0}
|
SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS: ${SCHOLAR_NAME_SEARCH_INTERVAL_JITTER_SECONDS:-2.0}
|
||||||
SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD: ${SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD:-1}
|
SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD: ${SCHOLAR_NAME_SEARCH_COOLDOWN_BLOCK_THRESHOLD:-1}
|
||||||
SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS: ${SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS:-1800}
|
SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS: ${SCHOLAR_NAME_SEARCH_COOLDOWN_SECONDS:-1800}
|
||||||
|
SCHOLAR_NAME_SEARCH_ALERT_RETRY_COUNT_THRESHOLD: ${SCHOLAR_NAME_SEARCH_ALERT_RETRY_COUNT_THRESHOLD:-2}
|
||||||
|
SCHOLAR_NAME_SEARCH_ALERT_COOLDOWN_REJECTIONS_THRESHOLD: ${SCHOLAR_NAME_SEARCH_ALERT_COOLDOWN_REJECTIONS_THRESHOLD:-3}
|
||||||
BOOTSTRAP_ADMIN_ON_START: ${BOOTSTRAP_ADMIN_ON_START:-0}
|
BOOTSTRAP_ADMIN_ON_START: ${BOOTSTRAP_ADMIN_ON_START:-0}
|
||||||
BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-}
|
BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-}
|
||||||
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-}
|
BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
"build": "vue-tsc --noEmit && vite build",
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
"preview": "vite preview --host 0.0.0.0 --port 4173",
|
"preview": "vite preview --host 0.0.0.0 --port 4173",
|
||||||
"typecheck": "vue-tsc --noEmit",
|
"typecheck": "vue-tsc --noEmit",
|
||||||
|
"check:theme-tokens": "node ./scripts/check_theme_tokens.mjs",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:run": "vitest run"
|
"test:run": "vitest run"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
84
frontend/scripts/check_theme_tokens.mjs
Normal file
84
frontend/scripts/check_theme_tokens.mjs
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const projectRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
|
||||||
|
const sourceRoot = path.join(projectRoot, "src");
|
||||||
|
const allowedExtensions = new Set([".vue", ".css"]);
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
{
|
||||||
|
name: "non-token palette utility",
|
||||||
|
regex: /\b(?:bg|text|border|ring|from|to|via)-(?:zinc|gray|slate|neutral|stone|white|black)(?:[-/][A-Za-z0-9_\-[\]./%]+)?\b/g,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dark mode utility variant",
|
||||||
|
regex: /\bdark:[^\s"'`<>}]*/g,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "inline hex color",
|
||||||
|
regex: /#[0-9a-fA-F]{3,8}\b/g,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function listFilesRecursively(dirPath) {
|
||||||
|
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||||
|
const files = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const nextPath = path.join(dirPath, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
files.push(...listFilesRecursively(nextPath));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowedExtensions.has(path.extname(nextPath))) {
|
||||||
|
files.push(nextPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativePath(filePath) {
|
||||||
|
return path.relative(projectRoot, filePath).replaceAll(path.sep, "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function run() {
|
||||||
|
const violations = [];
|
||||||
|
const files = listFilesRecursively(sourceRoot);
|
||||||
|
|
||||||
|
for (const filePath of files) {
|
||||||
|
const content = fs.readFileSync(filePath, "utf8");
|
||||||
|
const lines = content.split(/\r?\n/);
|
||||||
|
|
||||||
|
for (let index = 0; index < lines.length; index += 1) {
|
||||||
|
const line = lines[index];
|
||||||
|
|
||||||
|
for (const check of checks) {
|
||||||
|
check.regex.lastIndex = 0;
|
||||||
|
let match = check.regex.exec(line);
|
||||||
|
while (match) {
|
||||||
|
violations.push({
|
||||||
|
filePath: relativePath(filePath),
|
||||||
|
line: index + 1,
|
||||||
|
check: check.name,
|
||||||
|
value: match[0],
|
||||||
|
});
|
||||||
|
match = check.regex.exec(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (violations.length > 0) {
|
||||||
|
console.error("Theme token policy violations found:\n");
|
||||||
|
for (const violation of violations) {
|
||||||
|
console.error(`${violation.filePath}:${violation.line} [${violation.check}] ${violation.value}`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Theme token policy passed for ${files.length} files.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
|
|
@ -12,23 +12,27 @@ const showChrome = computed(() => auth.isAuthenticated);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen overflow-x-clip">
|
<div class="h-[100dvh] max-h-[100dvh] w-full max-w-full overflow-hidden">
|
||||||
<a href="#app-main" class="skip-link">Skip to main content</a>
|
<a href="#app-main" class="skip-link">Skip to main content</a>
|
||||||
<RequestErrorPanel />
|
<RequestErrorPanel />
|
||||||
|
|
||||||
<template v-if="showChrome">
|
<template v-if="showChrome">
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
<div
|
<div
|
||||||
class="grid min-h-[calc(100dvh-4.5rem)] grid-cols-1 lg:h-[calc(100dvh-4.5rem)] lg:min-h-[calc(100dvh-4.5rem)] lg:grid-cols-[17rem_minmax(0,1fr)]"
|
class="grid h-[calc(100dvh-4.5rem)] min-h-0 grid-cols-1 grid-rows-[auto_minmax(0,1fr)] lg:grid-cols-[17rem_minmax(0,1fr)] lg:grid-rows-1"
|
||||||
>
|
>
|
||||||
<AppNav class="lg:min-h-0 lg:overflow-y-auto" />
|
<AppNav class="min-h-0 overflow-x-hidden lg:overflow-y-auto" />
|
||||||
<main id="app-main" class="min-w-0 px-4 py-6 sm:px-6 lg:min-h-0 lg:overflow-y-auto lg:px-8">
|
<main id="app-main" class="min-h-0 min-w-0 overflow-y-auto overflow-x-hidden px-4 py-6 sm:px-6 lg:px-8">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<main id="app-main" v-else class="min-h-screen overflow-x-clip">
|
<main
|
||||||
|
id="app-main"
|
||||||
|
v-else
|
||||||
|
class="h-[100dvh] max-h-[100dvh] min-h-0 min-w-0 overflow-y-auto overflow-x-hidden"
|
||||||
|
>
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { setCsrfTokenProvider } from "@/lib/api/client";
|
import { setCsrfTokenProvider } from "@/lib/api/client";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { useThemeStore } from "@/stores/theme";
|
import { useThemeStore } from "@/stores/theme";
|
||||||
|
import { useUserSettingsStore } from "@/stores/user_settings";
|
||||||
|
|
||||||
export async function bootstrapAppProviders(): Promise<void> {
|
export async function bootstrapAppProviders(): Promise<void> {
|
||||||
const theme = useThemeStore();
|
const theme = useThemeStore();
|
||||||
|
|
@ -9,4 +10,11 @@ export async function bootstrapAppProviders(): Promise<void> {
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
setCsrfTokenProvider(() => auth.csrfToken);
|
setCsrfTokenProvider(() => auth.csrfToken);
|
||||||
await auth.bootstrapSession();
|
await auth.bootstrapSession();
|
||||||
|
|
||||||
|
const userSettings = useUserSettingsStore();
|
||||||
|
if (auth.isAuthenticated) {
|
||||||
|
await userSettings.bootstrap();
|
||||||
|
} else {
|
||||||
|
userSettings.reset();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
|
|
||||||
import AppButton from "@/components/ui/AppButton.vue";
|
import AppButton from "@/components/ui/AppButton.vue";
|
||||||
|
import AppSelect from "@/components/ui/AppSelect.vue";
|
||||||
import { useThemeStore } from "@/stores/theme";
|
import { useThemeStore } from "@/stores/theme";
|
||||||
|
import type { ThemePresetId } from "@/theme/presets";
|
||||||
|
|
||||||
const theme = useThemeStore();
|
const theme = useThemeStore();
|
||||||
|
|
||||||
|
|
@ -10,6 +12,12 @@ const isDarkTheme = computed(() => theme.active === "dark");
|
||||||
const toggleThemeLabel = computed(() =>
|
const toggleThemeLabel = computed(() =>
|
||||||
isDarkTheme.value ? "Switch to light theme" : "Switch to dark theme",
|
isDarkTheme.value ? "Switch to light theme" : "Switch to dark theme",
|
||||||
);
|
);
|
||||||
|
const selectedPreset = computed<ThemePresetId>({
|
||||||
|
get: () => theme.preset,
|
||||||
|
set: (value) => theme.setPreset(value),
|
||||||
|
});
|
||||||
|
const presetOptions = computed(() => theme.availablePresets);
|
||||||
|
const themePresetLabel = computed(() => theme.presetLabel);
|
||||||
|
|
||||||
function onToggleTheme(): void {
|
function onToggleTheme(): void {
|
||||||
theme.setPreference(isDarkTheme.value ? "light" : "dark");
|
theme.setPreference(isDarkTheme.value ? "light" : "dark");
|
||||||
|
|
@ -17,18 +25,32 @@ function onToggleTheme(): void {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header class="sticky top-0 z-30 border-b border-zinc-200/70 bg-white/90 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/90">
|
<header class="sticky top-0 z-30 border-b border-stroke-subtle bg-surface-nav/90 backdrop-blur">
|
||||||
<div class="flex min-h-[4.5rem] w-full items-center justify-between gap-4 px-4 sm:px-6 lg:px-8">
|
<div class="flex min-h-[4.5rem] w-full items-center justify-between gap-4 px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/dashboard"
|
to="/dashboard"
|
||||||
class="rounded-sm font-display text-lg tracking-tight text-zinc-900 transition hover:text-zinc-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:text-zinc-100 dark:hover:text-zinc-300 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950"
|
class="rounded-sm font-display text-lg tracking-tight text-ink-primary transition hover:text-ink-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
|
||||||
>
|
>
|
||||||
scholarr
|
scholarr
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-end">
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<div class="w-36 sm:w-44">
|
||||||
|
<label for="theme-preset-select" class="sr-only">Theme preset</label>
|
||||||
|
<AppSelect
|
||||||
|
id="theme-preset-select"
|
||||||
|
v-model="selectedPreset"
|
||||||
|
:disabled="presetOptions.length <= 1"
|
||||||
|
:title="`Theme preset: ${themePresetLabel}`"
|
||||||
|
class="py-1.5"
|
||||||
|
>
|
||||||
|
<option v-for="preset in presetOptions" :key="preset.id" :value="preset.id">
|
||||||
|
{{ preset.label }}
|
||||||
|
</option>
|
||||||
|
</AppSelect>
|
||||||
|
</div>
|
||||||
<AppButton
|
<AppButton
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="h-10 w-10 rounded-full p-0"
|
class="h-10 w-10 rounded-full p-0"
|
||||||
|
|
|
||||||
|
|
@ -4,24 +4,29 @@ import { useRouter } from "vue-router";
|
||||||
|
|
||||||
import AppButton from "@/components/ui/AppButton.vue";
|
import AppButton from "@/components/ui/AppButton.vue";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
import { useUserSettingsStore } from "@/stores/user_settings";
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
const userSettings = useUserSettingsStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const links = computed(() => {
|
const links = computed(() => {
|
||||||
const base = [
|
const base = [
|
||||||
{ to: "/dashboard", label: "Dashboard" },
|
{ id: "dashboard", to: "/dashboard", label: "Dashboard", adminOnly: false },
|
||||||
{ to: "/scholars", label: "Scholars" },
|
{ id: "scholars", to: "/scholars", label: "Scholars", adminOnly: false },
|
||||||
{ to: "/publications", label: "Publications" },
|
{ id: "publications", to: "/publications", label: "Publications", adminOnly: false },
|
||||||
{ to: "/settings", label: "Settings" },
|
{ id: "settings", to: "/settings", label: "Settings", adminOnly: false },
|
||||||
|
{ id: "style-guide", to: "/admin/style-guide", label: "Style Guide", adminOnly: true },
|
||||||
|
{ id: "runs", to: "/admin/runs", label: "Runs", adminOnly: true },
|
||||||
|
{ id: "users", to: "/admin/users", label: "Users", adminOnly: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
if (auth.isAdmin) {
|
return base.filter((item) => {
|
||||||
base.push({ to: "/admin/runs", label: "Runs" });
|
if (item.adminOnly && !auth.isAdmin) {
|
||||||
base.push({ to: "/admin/users", label: "Users" });
|
return false;
|
||||||
}
|
}
|
||||||
|
return userSettings.isPageVisible(item.id);
|
||||||
return base;
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onLogout(): Promise<void> {
|
async function onLogout(): Promise<void> {
|
||||||
|
|
@ -32,7 +37,7 @@ async function onLogout(): Promise<void> {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside
|
<aside
|
||||||
class="min-w-0 border-b border-zinc-200 bg-white/70 px-4 py-4 dark:border-zinc-800 dark:bg-zinc-900/70 lg:h-full lg:min-h-0 lg:overflow-y-auto lg:border-b-0 lg:border-r lg:px-5 lg:py-6"
|
class="min-h-0 min-w-0 overflow-x-hidden border-b border-stroke-subtle bg-surface-nav/70 px-4 py-4 lg:h-full lg:border-b-0 lg:border-r lg:px-5 lg:py-6"
|
||||||
>
|
>
|
||||||
<div class="flex h-full min-h-0 flex-col gap-4">
|
<div class="flex h-full min-h-0 flex-col gap-4">
|
||||||
<nav class="grid grid-cols-2 content-start gap-2 sm:grid-cols-3 lg:grid-cols-1" aria-label="Primary">
|
<nav class="grid grid-cols-2 content-start gap-2 sm:grid-cols-3 lg:grid-cols-1" aria-label="Primary">
|
||||||
|
|
@ -40,15 +45,15 @@ async function onLogout(): Promise<void> {
|
||||||
v-for="link in links"
|
v-for="link in links"
|
||||||
:key="link.to"
|
:key="link.to"
|
||||||
:to="link.to"
|
:to="link.to"
|
||||||
class="inline-flex min-h-10 min-w-0 items-center rounded-lg border border-transparent px-3 py-2 text-sm font-medium text-zinc-600 transition hover:border-zinc-300 hover:bg-zinc-50 hover:text-zinc-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800 dark:hover:text-zinc-100 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950"
|
class="inline-flex min-h-10 min-w-0 items-center rounded-lg border border-transparent px-3 py-2 text-sm font-medium text-ink-secondary transition hover:border-stroke-interactive hover:bg-surface-card-muted hover:text-ink-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
|
||||||
active-class="border-zinc-900 bg-zinc-900 text-white hover:border-zinc-900 hover:bg-zinc-900 hover:text-white dark:border-brand-400 dark:bg-brand-400 dark:text-zinc-950 dark:hover:border-brand-300 dark:hover:bg-brand-300"
|
active-class="border-stroke-interactive bg-surface-nav-active font-semibold text-ink-primary shadow-sm hover:border-stroke-interactive hover:bg-surface-nav-active hover:text-ink-primary"
|
||||||
>
|
>
|
||||||
{{ link.label }}
|
{{ link.label }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="mt-auto grid gap-2 border-t border-zinc-200 pt-3 dark:border-zinc-800">
|
<div class="mt-auto grid gap-2 border-t border-stroke-subtle pt-3">
|
||||||
<p class="truncate text-xs text-zinc-500 dark:text-zinc-400">{{ auth.user?.email }}</p>
|
<p class="truncate text-xs text-ink-muted">{{ auth.user?.email }}</p>
|
||||||
<AppButton variant="ghost" class="w-full justify-start" @click="onLogout">Logout</AppButton>
|
<AppButton variant="ghost" class="w-full justify-start" @click="onLogout">Logout</AppButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,31 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
}>();
|
fill?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
fill: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="page-stack min-w-0">
|
<section
|
||||||
|
:class="
|
||||||
|
props.fill
|
||||||
|
? 'min-w-0 flex min-h-0 flex-col gap-6 lg:h-full'
|
||||||
|
: 'page-stack min-w-0'
|
||||||
|
"
|
||||||
|
>
|
||||||
<header class="space-y-2">
|
<header class="space-y-2">
|
||||||
<h1 class="page-title">{{ title }}</h1>
|
<h1 class="page-title">{{ props.title }}</h1>
|
||||||
<p v-if="subtitle" class="page-subtitle">{{ subtitle }}</p>
|
<p v-if="props.subtitle" class="page-subtitle">{{ props.subtitle }}</p>
|
||||||
</header>
|
</header>
|
||||||
|
<div v-if="props.fill" class="min-h-0 flex-1">
|
||||||
<slot />
|
<slot />
|
||||||
|
</div>
|
||||||
|
<slot v-else />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
34
frontend/src/components/patterns/AsyncStateGate.vue
Normal file
34
frontend/src/components/patterns/AsyncStateGate.vue
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import AppEmptyState from "@/components/ui/AppEmptyState.vue";
|
||||||
|
import AppSkeleton from "@/components/ui/AppSkeleton.vue";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
loading: boolean;
|
||||||
|
loadingLines?: number;
|
||||||
|
empty?: boolean;
|
||||||
|
emptyTitle?: string;
|
||||||
|
emptyBody?: string;
|
||||||
|
showEmpty?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
loadingLines: 6,
|
||||||
|
empty: false,
|
||||||
|
emptyTitle: "No data available",
|
||||||
|
emptyBody: "No records matched this request.",
|
||||||
|
showEmpty: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppSkeleton v-if="props.loading" :lines="props.loadingLines" />
|
||||||
|
<AppEmptyState
|
||||||
|
v-else-if="props.showEmpty && props.empty"
|
||||||
|
:title="props.emptyTitle"
|
||||||
|
:body="props.emptyBody"
|
||||||
|
>
|
||||||
|
<slot name="empty" />
|
||||||
|
</AppEmptyState>
|
||||||
|
<slot v-else />
|
||||||
|
</template>
|
||||||
|
|
@ -8,14 +8,19 @@ const props = defineProps<{
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span
|
<span
|
||||||
class="inline-flex flex-wrap items-center gap-2 rounded-full border border-zinc-300 bg-zinc-100 px-3 py-1 text-xs text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"
|
class="inline-flex flex-wrap items-center gap-2 rounded-xl border border-stroke-default bg-surface-card-muted/90 px-3 py-1.5 text-xs text-ink-secondary"
|
||||||
>
|
>
|
||||||
<span class="font-medium">Queue</span>
|
<span class="font-semibold tracking-wide text-ink-primary">Queue</span>
|
||||||
<span class="rounded-full bg-zinc-200 px-2 py-0.5 dark:bg-zinc-700">{{ props.queued }} queued</span>
|
<span class="inline-flex items-center gap-1 rounded-full bg-surface-card px-2 py-0.5">
|
||||||
<span class="rounded-full bg-amber-100 px-2 py-0.5 text-amber-800 dark:bg-amber-950/50 dark:text-amber-300">
|
<span class="h-1.5 w-1.5 rounded-full bg-ink-muted" />
|
||||||
|
{{ props.queued }} queued
|
||||||
|
</span>
|
||||||
|
<span class="inline-flex items-center gap-1 rounded-full bg-state-warning-bg px-2 py-0.5 text-state-warning-text">
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full bg-warning-500" />
|
||||||
{{ props.retrying }} retrying
|
{{ props.retrying }} retrying
|
||||||
</span>
|
</span>
|
||||||
<span class="rounded-full bg-rose-100 px-2 py-0.5 text-rose-700 dark:bg-rose-950/50 dark:text-rose-300">
|
<span class="inline-flex items-center gap-1 rounded-full bg-state-danger-bg px-2 py-0.5 text-state-danger-text">
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full bg-danger-500" />
|
||||||
{{ props.dropped }} dropped
|
{{ props.dropped }} dropped
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
44
frontend/src/components/patterns/RequestStateAlerts.vue
Normal file
44
frontend/src/components/patterns/RequestStateAlerts.vue
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import AppAlert from "@/components/ui/AppAlert.vue";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
successMessage?: string | null;
|
||||||
|
successTitle?: string;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
errorRequestId?: string | null;
|
||||||
|
errorTitle?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
successMessage: null,
|
||||||
|
successTitle: "Operation complete",
|
||||||
|
errorMessage: null,
|
||||||
|
errorRequestId: null,
|
||||||
|
errorTitle: "Request failed",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
dismissSuccess: [];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<AppAlert
|
||||||
|
v-if="props.successMessage"
|
||||||
|
tone="success"
|
||||||
|
dismissible
|
||||||
|
@dismiss="emit('dismissSuccess')"
|
||||||
|
>
|
||||||
|
<template #title>{{ props.successTitle }}</template>
|
||||||
|
<p>{{ props.successMessage }}</p>
|
||||||
|
</AppAlert>
|
||||||
|
|
||||||
|
<AppAlert v-if="props.errorMessage" tone="danger">
|
||||||
|
<template #title>{{ props.errorTitle }}</template>
|
||||||
|
<p>{{ props.errorMessage }}</p>
|
||||||
|
<p class="text-secondary">Request ID: {{ props.errorRequestId || "n/a" }}</p>
|
||||||
|
</AppAlert>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -9,18 +9,18 @@ const label = computed(() => props.status.replaceAll("_", " "));
|
||||||
|
|
||||||
const toneClass = computed(() => {
|
const toneClass = computed(() => {
|
||||||
if (props.status === "success") {
|
if (props.status === "success") {
|
||||||
return "border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300";
|
return "border-state-success-border bg-state-success-bg text-state-success-text";
|
||||||
}
|
}
|
||||||
if (props.status === "partial_failure") {
|
if (props.status === "partial_failure") {
|
||||||
return "border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950/50 dark:text-amber-300";
|
return "border-state-warning-border bg-state-warning-bg text-state-warning-text";
|
||||||
}
|
}
|
||||||
if (props.status === "failed") {
|
if (props.status === "failed") {
|
||||||
return "border-rose-300 bg-rose-50 text-rose-700 dark:border-rose-800 dark:bg-rose-950/50 dark:text-rose-300";
|
return "border-state-danger-border bg-state-danger-bg text-state-danger-text";
|
||||||
}
|
}
|
||||||
if (props.status === "running") {
|
if (props.status === "running") {
|
||||||
return "border-brand-300 bg-brand-50 text-brand-700 dark:border-brand-800 dark:bg-brand-950/50 dark:text-brand-300";
|
return "border-state-info-border bg-state-info-bg text-state-info-text";
|
||||||
}
|
}
|
||||||
return "border-zinc-300 bg-zinc-100 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300";
|
return "border-stroke-default bg-surface-card-muted text-ink-secondary";
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,15 +18,15 @@ const emit = defineEmits<{ dismiss: [] }>();
|
||||||
|
|
||||||
const toneClass = computed(() => {
|
const toneClass = computed(() => {
|
||||||
if (props.tone === "danger") {
|
if (props.tone === "danger") {
|
||||||
return "border-rose-300 bg-rose-50 text-rose-900 dark:border-rose-800 dark:bg-rose-950/45 dark:text-rose-200";
|
return "border-state-danger-border bg-state-danger-bg text-state-danger-text";
|
||||||
}
|
}
|
||||||
if (props.tone === "warning") {
|
if (props.tone === "warning") {
|
||||||
return "border-amber-300 bg-amber-50 text-amber-900 dark:border-amber-800 dark:bg-amber-950/45 dark:text-amber-200";
|
return "border-state-warning-border bg-state-warning-bg text-state-warning-text";
|
||||||
}
|
}
|
||||||
if (props.tone === "success") {
|
if (props.tone === "success") {
|
||||||
return "border-emerald-300 bg-emerald-50 text-emerald-900 dark:border-emerald-800 dark:bg-emerald-950/45 dark:text-emerald-200";
|
return "border-state-success-border bg-state-success-bg text-state-success-text";
|
||||||
}
|
}
|
||||||
return "border-brand-300 bg-brand-50 text-brand-900 dark:border-brand-800 dark:bg-brand-950/45 dark:text-brand-200";
|
return "border-state-info-border bg-state-info-bg text-state-info-text";
|
||||||
});
|
});
|
||||||
|
|
||||||
const alertRole = computed(() => (props.tone === "danger" || props.tone === "warning" ? "alert" : "status"));
|
const alertRole = computed(() => (props.tone === "danger" || props.tone === "warning" ? "alert" : "status"));
|
||||||
|
|
|
||||||
|
|
@ -7,22 +7,22 @@ const props = defineProps<{
|
||||||
|
|
||||||
const toneClass = computed(() => {
|
const toneClass = computed(() => {
|
||||||
if (props.tone === "success") {
|
if (props.tone === "success") {
|
||||||
return "border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300";
|
return "border-state-success-border bg-state-success-bg text-state-success-text";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.tone === "warning") {
|
if (props.tone === "warning") {
|
||||||
return "border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950/50 dark:text-amber-300";
|
return "border-state-warning-border bg-state-warning-bg text-state-warning-text";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.tone === "danger") {
|
if (props.tone === "danger") {
|
||||||
return "border-rose-300 bg-rose-50 text-rose-700 dark:border-rose-800 dark:bg-rose-950/50 dark:text-rose-300";
|
return "border-state-danger-border bg-state-danger-bg text-state-danger-text";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.tone === "info") {
|
if (props.tone === "info") {
|
||||||
return "border-brand-300 bg-brand-50 text-brand-700 dark:border-brand-800 dark:bg-brand-950/50 dark:text-brand-300";
|
return "border-state-info-border bg-state-info-bg text-state-info-text";
|
||||||
}
|
}
|
||||||
|
|
||||||
return "border-zinc-300 bg-zinc-100 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300";
|
return "border-stroke-default bg-surface-card-muted text-ink-secondary";
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,24 +16,24 @@ const props = withDefaults(
|
||||||
|
|
||||||
const variantClass = computed(() => {
|
const variantClass = computed(() => {
|
||||||
if (props.variant === "secondary") {
|
if (props.variant === "secondary") {
|
||||||
return "border-zinc-300 bg-zinc-100 text-zinc-900 hover:bg-zinc-200 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700";
|
return "border-action-secondary-border bg-action-secondary-bg text-action-secondary-text shadow-sm hover:border-action-secondary-hover-border hover:bg-action-secondary-hover-bg hover:text-action-secondary-hover-text";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.variant === "ghost") {
|
if (props.variant === "ghost") {
|
||||||
return "border-zinc-300 bg-transparent text-zinc-700 hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800";
|
return "border-action-ghost-border bg-action-ghost-bg text-action-ghost-text hover:border-action-ghost-hover-border hover:bg-action-ghost-hover-bg hover:text-action-ghost-hover-text";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.variant === "danger") {
|
if (props.variant === "danger") {
|
||||||
return "border-rose-300 bg-rose-100 text-rose-800 hover:bg-rose-200 dark:border-rose-800 dark:bg-rose-950/60 dark:text-rose-200 dark:hover:bg-rose-900/70";
|
return "border-action-danger-border bg-action-danger-bg text-action-danger-text shadow-sm hover:border-action-danger-hover-border hover:bg-action-danger-hover-bg hover:text-action-danger-hover-text";
|
||||||
}
|
}
|
||||||
|
|
||||||
return "border-brand-700 bg-brand-700 text-white hover:bg-brand-600 dark:border-brand-400 dark:bg-brand-400 dark:text-zinc-950 dark:hover:bg-brand-300";
|
return "border-action-primary-border bg-action-primary-bg text-action-primary-text shadow-sm hover:border-action-primary-hover-border hover:bg-action-primary-hover-bg hover:text-action-primary-hover-text";
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button
|
<button
|
||||||
class="inline-flex min-h-10 items-center justify-center rounded-lg border px-3 py-2 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 disabled:cursor-not-allowed disabled:opacity-60 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950"
|
class="inline-flex min-h-10 items-center justify-center rounded-lg border px-3 py-2 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
:class="variantClass"
|
:class="variantClass"
|
||||||
:type="props.type"
|
:type="props.type"
|
||||||
:disabled="props.disabled"
|
:disabled="props.disabled"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<article class="min-w-0 rounded-2xl border border-zinc-200 bg-white p-5 shadow-panel dark:border-zinc-800 dark:bg-zinc-900">
|
<article class="min-w-0 rounded-2xl border border-stroke-default bg-surface-card p-5 shadow-panel">
|
||||||
<slot />
|
<slot />
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,11 @@ defineProps<{
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<label class="inline-flex min-h-10 items-center gap-2 text-sm text-zinc-800 dark:text-zinc-200" :for="id">
|
<label class="inline-flex min-h-10 items-center gap-2 text-sm text-ink-primary" :for="id">
|
||||||
<input
|
<input
|
||||||
:id="id"
|
:id="id"
|
||||||
v-model="model"
|
v-model="model"
|
||||||
class="h-4 w-4 rounded border-zinc-400 text-brand-600 focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:border-zinc-600 dark:bg-zinc-900 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950"
|
class="h-4 w-4 rounded border-stroke-interactive bg-surface-input text-brand-600 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@ defineProps<{
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="rounded-xl border border-dashed border-zinc-300 bg-zinc-50 p-5 dark:border-zinc-700 dark:bg-zinc-900/60">
|
<section class="rounded-xl border border-dashed border-stroke-interactive bg-surface-card-muted p-5">
|
||||||
<h3 class="font-display text-lg font-semibold text-zinc-900 dark:text-zinc-100">{{ title }}</h3>
|
<h3 class="font-display text-lg font-semibold text-ink-primary">{{ title }}</h3>
|
||||||
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{{ body }}</p>
|
<p class="mt-1 text-sm text-ink-secondary">{{ body }}</p>
|
||||||
<div v-if="$slots.default" class="mt-4">
|
<div v-if="$slots.default" class="mt-4">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
209
frontend/src/components/ui/AppHelpHint.vue
Normal file
209
frontend/src/components/ui/AppHelpHint.vue
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, ref, useSlots, watch } from "vue";
|
||||||
|
|
||||||
|
type TooltipSide = "right" | "left" | "top" | "bottom";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
text: string;
|
||||||
|
side?: TooltipSide | "auto";
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
side: "auto",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const slots = useSlots();
|
||||||
|
|
||||||
|
const GAP = 8;
|
||||||
|
const EDGE_PADDING = 8;
|
||||||
|
|
||||||
|
const triggerRef = ref<HTMLElement | null>(null);
|
||||||
|
const tooltipRef = ref<HTMLElement | null>(null);
|
||||||
|
const isOpen = ref(false);
|
||||||
|
const resolvedSide = ref<TooltipSide>("top");
|
||||||
|
const tooltipStyle = ref<Record<string, string>>({
|
||||||
|
left: "0px",
|
||||||
|
top: "0px",
|
||||||
|
});
|
||||||
|
|
||||||
|
const sideClass = computed(() => {
|
||||||
|
if (resolvedSide.value === "left") {
|
||||||
|
return "origin-right";
|
||||||
|
}
|
||||||
|
if (resolvedSide.value === "right") {
|
||||||
|
return "origin-left";
|
||||||
|
}
|
||||||
|
if (resolvedSide.value === "bottom") {
|
||||||
|
return "origin-top";
|
||||||
|
}
|
||||||
|
return "origin-bottom";
|
||||||
|
});
|
||||||
|
const hasTriggerSlot = computed(() => Boolean(slots.trigger));
|
||||||
|
|
||||||
|
function clamp(value: number, minimum: number, maximum: number): number {
|
||||||
|
return Math.min(Math.max(value, minimum), maximum);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spaceBySide(rect: DOMRect): Record<TooltipSide, number> {
|
||||||
|
return {
|
||||||
|
top: rect.top,
|
||||||
|
right: window.innerWidth - rect.right,
|
||||||
|
bottom: window.innerHeight - rect.bottom,
|
||||||
|
left: rect.left,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function preferredSides(): TooltipSide[] {
|
||||||
|
const allSides: TooltipSide[] = ["top", "right", "bottom", "left"];
|
||||||
|
if (!props.side || props.side === "auto") {
|
||||||
|
return allSides;
|
||||||
|
}
|
||||||
|
return [props.side, ...allSides.filter((item) => item !== props.side)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseSide(rect: DOMRect, tooltipRect: DOMRect): TooltipSide {
|
||||||
|
const spaces = spaceBySide(rect);
|
||||||
|
const order = preferredSides();
|
||||||
|
|
||||||
|
for (const side of order) {
|
||||||
|
if (side === "top" || side === "bottom") {
|
||||||
|
if (spaces[side] >= tooltipRect.height + GAP) {
|
||||||
|
return side;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (spaces[side] >= tooltipRect.width + GAP) {
|
||||||
|
return side;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return order.sort((left, right) => spaces[right] - spaces[left])[0] ?? "top";
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePosition(): void {
|
||||||
|
if (!isOpen.value || !triggerRef.value || !tooltipRef.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trigger = triggerRef.value.getBoundingClientRect();
|
||||||
|
const tooltip = tooltipRef.value.getBoundingClientRect();
|
||||||
|
const side = chooseSide(trigger, tooltip);
|
||||||
|
resolvedSide.value = side;
|
||||||
|
|
||||||
|
let left = 0;
|
||||||
|
let top = 0;
|
||||||
|
|
||||||
|
if (side === "top") {
|
||||||
|
left = trigger.left + trigger.width / 2 - tooltip.width / 2;
|
||||||
|
top = trigger.top - tooltip.height - GAP;
|
||||||
|
} else if (side === "bottom") {
|
||||||
|
left = trigger.left + trigger.width / 2 - tooltip.width / 2;
|
||||||
|
top = trigger.bottom + GAP;
|
||||||
|
} else if (side === "left") {
|
||||||
|
left = trigger.left - tooltip.width - GAP;
|
||||||
|
top = trigger.top + trigger.height / 2 - tooltip.height / 2;
|
||||||
|
} else {
|
||||||
|
left = trigger.right + GAP;
|
||||||
|
top = trigger.top + trigger.height / 2 - tooltip.height / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
left = clamp(left, EDGE_PADDING, window.innerWidth - tooltip.width - EDGE_PADDING);
|
||||||
|
top = clamp(top, EDGE_PADDING, window.innerHeight - tooltip.height - EDGE_PADDING);
|
||||||
|
|
||||||
|
tooltipStyle.value = {
|
||||||
|
left: `${Math.round(left)}px`,
|
||||||
|
top: `${Math.round(top)}px`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTooltip(): void {
|
||||||
|
isOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTooltip(): void {
|
||||||
|
isOpen.value = true;
|
||||||
|
void nextTick(() => {
|
||||||
|
updatePosition();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(event: KeyboardEvent): void {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
closeTooltip();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onViewportChange(): void {
|
||||||
|
if (!isOpen.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updatePosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(isOpen, (open) => {
|
||||||
|
if (open) {
|
||||||
|
window.addEventListener("resize", onViewportChange);
|
||||||
|
window.addEventListener("scroll", onViewportChange, true);
|
||||||
|
window.addEventListener("keydown", onKeydown);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.removeEventListener("resize", onViewportChange);
|
||||||
|
window.removeEventListener("scroll", onViewportChange, true);
|
||||||
|
window.removeEventListener("keydown", onKeydown);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.text,
|
||||||
|
() => {
|
||||||
|
if (isOpen.value) {
|
||||||
|
void nextTick(() => {
|
||||||
|
updatePosition();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener("resize", onViewportChange);
|
||||||
|
window.removeEventListener("scroll", onViewportChange, true);
|
||||||
|
window.removeEventListener("keydown", onKeydown);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="relative inline-flex items-center align-middle">
|
||||||
|
<component
|
||||||
|
:is="hasTriggerSlot ? 'span' : 'button'"
|
||||||
|
ref="triggerRef"
|
||||||
|
:type="hasTriggerSlot ? undefined : 'button'"
|
||||||
|
:tabindex="hasTriggerSlot ? 0 : undefined"
|
||||||
|
class="inline-flex items-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
|
||||||
|
:class="
|
||||||
|
hasTriggerSlot
|
||||||
|
? 'cursor-help rounded-sm'
|
||||||
|
: 'h-5 w-5 justify-center rounded-full border border-stroke-interactive/80 bg-surface-card-muted/80 text-[11px] font-semibold text-ink-secondary transition hover:bg-action-secondary-hover-bg'
|
||||||
|
"
|
||||||
|
:aria-label="text"
|
||||||
|
@mouseenter="openTooltip"
|
||||||
|
@mouseleave="closeTooltip"
|
||||||
|
@focus="openTooltip"
|
||||||
|
@blur="closeTooltip"
|
||||||
|
>
|
||||||
|
<slot v-if="hasTriggerSlot" name="trigger" />
|
||||||
|
<template v-else>?</template>
|
||||||
|
</component>
|
||||||
|
<Teleport to="body">
|
||||||
|
<span
|
||||||
|
v-if="isOpen"
|
||||||
|
ref="tooltipRef"
|
||||||
|
role="tooltip"
|
||||||
|
:style="tooltipStyle"
|
||||||
|
:class="sideClass"
|
||||||
|
class="pointer-events-none fixed z-[90] w-64 max-w-[calc(100vw-2rem)] rounded-lg border border-stroke-default bg-surface-card/95 px-2.5 py-2 text-xs leading-relaxed text-ink-secondary shadow-lg"
|
||||||
|
>
|
||||||
|
{{ text }}
|
||||||
|
</span>
|
||||||
|
</Teleport>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
@ -12,7 +12,7 @@ defineProps<{
|
||||||
<template>
|
<template>
|
||||||
<input
|
<input
|
||||||
v-model="model"
|
v-model="model"
|
||||||
class="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 outline-none ring-brand-300 transition placeholder:text-zinc-400 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:ring-brand-400 dark:focus-visible:ring-offset-zinc-950 dark:placeholder:text-zinc-500"
|
class="w-full rounded-lg border border-stroke-interactive bg-surface-input px-3 py-2 text-sm text-ink-primary outline-none transition placeholder:text-ink-muted focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
:id="id"
|
:id="id"
|
||||||
:type="type || 'text'"
|
:type="type || 'text'"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@ const emit = defineEmits<{ close: [] }>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="props.open" class="fixed inset-0 z-50 grid place-items-center bg-zinc-950/60 p-4" @click.self="emit('close')">
|
<div v-if="props.open" class="fixed inset-0 z-50 grid place-items-center bg-surface-overlay/60 p-4" @click.self="emit('close')">
|
||||||
<div class="w-full max-w-lg rounded-2xl border border-zinc-200 bg-white p-6 shadow-panel dark:border-zinc-800 dark:bg-zinc-900">
|
<div class="w-full max-w-lg rounded-2xl border border-stroke-default bg-surface-card p-6 shadow-panel">
|
||||||
<h3 class="mb-4 font-display text-xl font-semibold text-zinc-900 dark:text-zinc-100">{{ props.title }}</h3>
|
<h3 class="mb-4 font-display text-xl font-semibold text-ink-primary">{{ props.title }}</h3>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ defineProps<{
|
||||||
<template>
|
<template>
|
||||||
<select
|
<select
|
||||||
v-model="model"
|
v-model="model"
|
||||||
class="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 outline-none ring-brand-300 transition focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:ring-brand-400 dark:focus-visible:ring-offset-zinc-950"
|
class="w-full rounded-lg border border-stroke-interactive bg-surface-input px-3 py-2 text-sm text-ink-primary outline-none transition focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
:id="id"
|
:id="id"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ defineProps<{
|
||||||
<span
|
<span
|
||||||
v-for="line in lines || 3"
|
v-for="line in lines || 3"
|
||||||
:key="line"
|
:key="line"
|
||||||
class="block h-3 animate-pulse rounded-full bg-zinc-200 dark:bg-zinc-800"
|
class="block h-3 animate-pulse rounded-full bg-surface-card-muted"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,10 @@ defineProps<{
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="max-w-full overflow-x-auto rounded-xl border border-zinc-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:border-zinc-800 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950"
|
class="max-w-full overflow-x-auto rounded-xl border border-stroke-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<table class="min-w-full border-collapse bg-white text-sm dark:bg-zinc-900" :aria-label="label || 'Data table'">
|
<table class="min-w-full border-collapse bg-surface-table text-sm" :aria-label="label || 'Data table'">
|
||||||
<slot />
|
<slot />
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -18,14 +18,14 @@ defineProps<{
|
||||||
<style scoped>
|
<style scoped>
|
||||||
:deep(th),
|
:deep(th),
|
||||||
:deep(td) {
|
:deep(td) {
|
||||||
@apply border-b border-zinc-200 px-3 py-2 text-left align-top dark:border-zinc-800;
|
@apply border-b border-stroke-default px-3 py-2 text-left align-top;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(th) {
|
:deep(th) {
|
||||||
@apply sticky top-0 z-10 bg-zinc-100 font-semibold text-zinc-800 dark:bg-zinc-900 dark:text-zinc-200;
|
@apply sticky top-0 z-10 bg-surface-table-header font-semibold text-ink-primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(td) {
|
:deep(td) {
|
||||||
@apply text-zinc-700 dark:text-zinc-300;
|
@apply text-ink-secondary;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
5
frontend/src/env.d.ts
vendored
5
frontend/src/env.d.ts
vendored
|
|
@ -1 +1,6 @@
|
||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module "@/theme/presets/*.js" {
|
||||||
|
const value: Record<string, unknown>;
|
||||||
|
export default value;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,14 @@ export interface RunSummary {
|
||||||
partial_count: number;
|
partial_count: number;
|
||||||
failed_state_counts: Record<string, number>;
|
failed_state_counts: Record<string, number>;
|
||||||
failed_reason_counts: Record<string, number>;
|
failed_reason_counts: Record<string, number>;
|
||||||
|
scrape_failure_counts: Record<string, number>;
|
||||||
|
retry_counts: {
|
||||||
|
retries_scheduled_count: number;
|
||||||
|
scholars_with_retries_count: number;
|
||||||
|
retry_exhausted_count: number;
|
||||||
|
};
|
||||||
|
alert_thresholds: Record<string, number>;
|
||||||
|
alert_flags: Record<string, boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RunScholarResult {
|
export interface RunScholarResult {
|
||||||
|
|
|
||||||
67
frontend/src/features/scholars/components/ScholarAvatar.vue
Normal file
67
frontend/src/features/scholars/components/ScholarAvatar.vue
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
label?: string | null;
|
||||||
|
scholarId: string;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
size?: "sm" | "md";
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
label: null,
|
||||||
|
imageUrl: null,
|
||||||
|
size: "md",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const imageFailed = ref(false);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.imageUrl,
|
||||||
|
() => {
|
||||||
|
imageFailed.value = false;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const initials = computed(() => {
|
||||||
|
const source = (props.label || "").trim();
|
||||||
|
if (!source) {
|
||||||
|
return props.scholarId.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = source.split(/\s+/).filter(Boolean);
|
||||||
|
if (tokens.length === 1) {
|
||||||
|
return tokens[0].slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
return `${tokens[0].charAt(0)}${tokens[1].charAt(0)}`.toUpperCase();
|
||||||
|
});
|
||||||
|
|
||||||
|
const containerClass = computed(() =>
|
||||||
|
props.size === "sm" ? "h-10 w-10 text-[11px]" : "h-12 w-12 text-xs",
|
||||||
|
);
|
||||||
|
|
||||||
|
const canRenderImage = computed(() => Boolean(props.imageUrl) && !imageFailed.value);
|
||||||
|
|
||||||
|
function onImageError(): void {
|
||||||
|
imageFailed.value = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex shrink-0 items-center justify-center overflow-hidden rounded-full border border-stroke-default bg-surface-card-muted font-semibold text-ink-secondary"
|
||||||
|
:class="containerClass"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="canRenderImage"
|
||||||
|
:src="props.imageUrl || ''"
|
||||||
|
:alt="`${props.label || props.scholarId} profile image`"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
@error="onImageError"
|
||||||
|
/>
|
||||||
|
<span v-else>{{ initials }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -4,6 +4,7 @@ export interface UserSettings {
|
||||||
auto_run_enabled: boolean;
|
auto_run_enabled: boolean;
|
||||||
run_interval_minutes: number;
|
run_interval_minutes: number;
|
||||||
request_delay_seconds: number;
|
request_delay_seconds: number;
|
||||||
|
nav_visible_pages: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChangePasswordPayload {
|
export interface ChangePasswordPayload {
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,20 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
|
||||||
import AppPage from "@/components/layout/AppPage.vue";
|
import AppPage from "@/components/layout/AppPage.vue";
|
||||||
import AppAlert from "@/components/ui/AppAlert.vue";
|
import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
|
||||||
|
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
|
||||||
import AppButton from "@/components/ui/AppButton.vue";
|
import AppButton from "@/components/ui/AppButton.vue";
|
||||||
import AppCard from "@/components/ui/AppCard.vue";
|
import AppCard from "@/components/ui/AppCard.vue";
|
||||||
import AppCheckbox from "@/components/ui/AppCheckbox.vue";
|
import AppCheckbox from "@/components/ui/AppCheckbox.vue";
|
||||||
import AppEmptyState from "@/components/ui/AppEmptyState.vue";
|
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||||
import AppInput from "@/components/ui/AppInput.vue";
|
import AppInput from "@/components/ui/AppInput.vue";
|
||||||
import AppSkeleton from "@/components/ui/AppSkeleton.vue";
|
import AppModal from "@/components/ui/AppModal.vue";
|
||||||
import AppTable from "@/components/ui/AppTable.vue";
|
import AppTable from "@/components/ui/AppTable.vue";
|
||||||
import {
|
import {
|
||||||
createAdminUser,
|
createAdminUser,
|
||||||
listAdminUsers,
|
listAdminUsers,
|
||||||
|
resetAdminUserPassword,
|
||||||
setAdminUserActive,
|
setAdminUserActive,
|
||||||
type AdminUser,
|
type AdminUser,
|
||||||
} from "@/features/admin_users";
|
} from "@/features/admin_users";
|
||||||
|
|
@ -21,16 +23,21 @@ import { ApiRequestError } from "@/lib/api/errors";
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const creating = ref(false);
|
const creating = ref(false);
|
||||||
const togglingUserId = ref<number | null>(null);
|
const togglingUserId = ref<number | null>(null);
|
||||||
|
const resettingPassword = ref(false);
|
||||||
|
const activeUserId = ref<number | null>(null);
|
||||||
const users = ref<AdminUser[]>([]);
|
const users = ref<AdminUser[]>([]);
|
||||||
|
|
||||||
const email = ref("");
|
const email = ref("");
|
||||||
const password = ref("");
|
const password = ref("");
|
||||||
const createIsAdmin = ref(false);
|
const createIsAdmin = ref(false);
|
||||||
|
const resetPassword = ref("");
|
||||||
|
|
||||||
const errorMessage = ref<string | null>(null);
|
const errorMessage = ref<string | null>(null);
|
||||||
const errorRequestId = ref<string | null>(null);
|
const errorRequestId = ref<string | null>(null);
|
||||||
const successMessage = ref<string | null>(null);
|
const successMessage = ref<string | null>(null);
|
||||||
|
|
||||||
|
const activeUser = computed(() => users.value.find((item) => item.id === activeUserId.value) ?? null);
|
||||||
|
|
||||||
function formatDate(value: string): string {
|
function formatDate(value: string): string {
|
||||||
const asDate = new Date(value);
|
const asDate = new Date(value);
|
||||||
if (Number.isNaN(asDate.getTime())) {
|
if (Number.isNaN(asDate.getTime())) {
|
||||||
|
|
@ -39,6 +46,26 @@ function formatDate(value: string): string {
|
||||||
return asDate.toLocaleString();
|
return asDate.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function userRoleLabel(user: AdminUser): string {
|
||||||
|
return user.is_admin ? "Admin" : "User";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusDotClass(user: AdminUser): string {
|
||||||
|
return user.is_active
|
||||||
|
? "bg-success-500 ring-success-200"
|
||||||
|
: "bg-ink-muted/70 ring-stroke-default";
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUserModal(user: AdminUser): void {
|
||||||
|
activeUserId.value = user.id;
|
||||||
|
resetPassword.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeUserModal(): void {
|
||||||
|
activeUserId.value = null;
|
||||||
|
resetPassword.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
async function loadUsers(): Promise<void> {
|
async function loadUsers(): Promise<void> {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
errorMessage.value = null;
|
errorMessage.value = null;
|
||||||
|
|
@ -46,6 +73,9 @@ async function loadUsers(): Promise<void> {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
users.value = await listAdminUsers();
|
users.value = await listAdminUsers();
|
||||||
|
if (activeUserId.value !== null && !users.value.some((item) => item.id === activeUserId.value)) {
|
||||||
|
closeUserModal();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
users.value = [];
|
users.value = [];
|
||||||
if (error instanceof ApiRequestError) {
|
if (error instanceof ApiRequestError) {
|
||||||
|
|
@ -115,6 +145,40 @@ async function onToggleUser(user: AdminUser): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onResetPassword(): Promise<void> {
|
||||||
|
const user = activeUser.value;
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resettingPassword.value = true;
|
||||||
|
successMessage.value = null;
|
||||||
|
errorMessage.value = null;
|
||||||
|
errorRequestId.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const candidate = resetPassword.value.trim();
|
||||||
|
if (candidate.length < 12) {
|
||||||
|
throw new Error("New password must be at least 12 characters.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await resetAdminUserPassword(user.id, candidate);
|
||||||
|
successMessage.value = result.message || `Password reset for ${user.email}.`;
|
||||||
|
resetPassword.value = "";
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiRequestError) {
|
||||||
|
errorMessage.value = error.message;
|
||||||
|
errorRequestId.value = error.requestId;
|
||||||
|
} else if (error instanceof Error) {
|
||||||
|
errorMessage.value = error.message;
|
||||||
|
} else {
|
||||||
|
errorMessage.value = "Unable to reset password.";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
resettingPassword.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadUsers();
|
void loadUsers();
|
||||||
});
|
});
|
||||||
|
|
@ -122,27 +186,27 @@ onMounted(() => {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppPage title="Admin Users" subtitle="Create and manage user access for this instance.">
|
<AppPage title="Admin Users" subtitle="Create and manage user access for this instance.">
|
||||||
<AppAlert v-if="successMessage" tone="success" dismissible @dismiss="successMessage = null">
|
<RequestStateAlerts
|
||||||
<template #title>Operation complete</template>
|
:success-message="successMessage"
|
||||||
<p>{{ successMessage }}</p>
|
:error-message="errorMessage"
|
||||||
</AppAlert>
|
:error-request-id="errorRequestId"
|
||||||
|
error-title="User management request failed"
|
||||||
<AppAlert v-if="errorMessage" tone="danger">
|
@dismiss-success="successMessage = null"
|
||||||
<template #title>User management request failed</template>
|
/>
|
||||||
<p>{{ errorMessage }}</p>
|
|
||||||
<p class="text-secondary">Request ID: {{ errorRequestId || "n/a" }}</p>
|
|
||||||
</AppAlert>
|
|
||||||
|
|
||||||
<section class="grid gap-4 lg:grid-cols-2">
|
<section class="grid gap-4 lg:grid-cols-2">
|
||||||
<AppCard class="space-y-4">
|
<AppCard class="space-y-4">
|
||||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Create User</h2>
|
<div class="flex items-center gap-1">
|
||||||
|
<h2 class="text-lg font-semibold text-ink-primary">Create User</h2>
|
||||||
|
<AppHelpHint text="Create local accounts for this Scholarr instance and optionally grant admin rights." />
|
||||||
|
</div>
|
||||||
<form class="grid gap-3" @submit.prevent="onCreateUser">
|
<form class="grid gap-3" @submit.prevent="onCreateUser">
|
||||||
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
||||||
<span>Email</span>
|
<span>Email</span>
|
||||||
<AppInput id="admin-user-email" v-model="email" type="email" autocomplete="off" />
|
<AppInput id="admin-user-email" v-model="email" type="email" autocomplete="off" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
||||||
<span>Password</span>
|
<span>Password</span>
|
||||||
<AppInput id="admin-user-password" v-model="password" type="password" autocomplete="new-password" />
|
<AppInput id="admin-user-password" v-model="password" type="password" autocomplete="new-password" />
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -156,44 +220,97 @@ onMounted(() => {
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
|
||||||
<AppCard class="space-y-4">
|
<AppCard class="space-y-4">
|
||||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Users</h2>
|
<div class="flex items-center gap-1">
|
||||||
<AppSkeleton v-if="loading" :lines="5" />
|
<h2 class="text-lg font-semibold text-ink-primary">Users</h2>
|
||||||
|
<AppHelpHint text="Review account roles and active status. Inactive users cannot sign in." />
|
||||||
<AppEmptyState
|
</div>
|
||||||
v-else-if="users.length === 0"
|
<AsyncStateGate
|
||||||
title="No users available"
|
:loading="loading"
|
||||||
body="Create an account to begin assigning access."
|
:loading-lines="5"
|
||||||
/>
|
:empty="users.length === 0"
|
||||||
|
:show-empty="!errorMessage"
|
||||||
<AppTable v-else label="Admin users table">
|
empty-title="No users available"
|
||||||
|
empty-body="Create an account to begin assigning access."
|
||||||
|
>
|
||||||
|
<AppTable label="Admin users table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Email</th>
|
<th scope="col">Email</th>
|
||||||
<th scope="col">Role</th>
|
<th scope="col">Role</th>
|
||||||
<th scope="col">Active</th>
|
|
||||||
<th scope="col">Updated</th>
|
|
||||||
<th scope="col">Action</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="user in users" :key="user.id">
|
<tr v-for="user in users" :key="user.id">
|
||||||
<td>{{ user.email }}</td>
|
|
||||||
<td>{{ user.is_admin ? "Admin" : "User" }}</td>
|
|
||||||
<td>{{ user.is_active ? "Yes" : "No" }}</td>
|
|
||||||
<td>{{ formatDate(user.updated_at) }}</td>
|
|
||||||
<td>
|
<td>
|
||||||
<AppButton
|
<button
|
||||||
variant="secondary"
|
type="button"
|
||||||
:disabled="togglingUserId === user.id"
|
class="group inline-flex items-center gap-2 rounded-md px-1 py-0.5 text-left text-ink-primary transition hover:bg-surface-card-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
|
||||||
@click="onToggleUser(user)"
|
@click="openUserModal(user)"
|
||||||
>
|
>
|
||||||
{{ user.is_active ? "Deactivate" : "Activate" }}
|
<span
|
||||||
</AppButton>
|
:class="statusDotClass(user)"
|
||||||
|
class="h-2.5 w-2.5 rounded-full ring-2"
|
||||||
|
:aria-label="user.is_active ? 'Active user' : 'Inactive user'"
|
||||||
|
/>
|
||||||
|
<span class="underline-offset-2 group-hover:underline">{{ user.email }}</span>
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
|
<td>{{ userRoleLabel(user) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</AppTable>
|
</AppTable>
|
||||||
|
<p class="text-xs text-secondary">Status dot: green is active, gray is inactive. Click an email to manage.</p>
|
||||||
|
</AsyncStateGate>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<AppModal :open="activeUser !== null" title="User settings" @close="closeUserModal">
|
||||||
|
<div v-if="activeUser" class="grid gap-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
:class="statusDotClass(activeUser)"
|
||||||
|
class="h-2.5 w-2.5 rounded-full ring-2"
|
||||||
|
:aria-label="activeUser.is_active ? 'Active user' : 'Inactive user'"
|
||||||
|
/>
|
||||||
|
<p class="truncate text-sm font-semibold text-ink-primary">{{ activeUser.email }}</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-secondary">Role: {{ userRoleLabel(activeUser) }}</p>
|
||||||
|
<p class="text-xs text-secondary">Last updated: {{ formatDate(activeUser.updated_at) }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2 border-t border-stroke-default pt-3">
|
||||||
|
<label class="grid gap-2 text-sm font-medium text-ink-secondary" for="admin-user-reset-password">
|
||||||
|
<span>Reset password</span>
|
||||||
|
<AppInput
|
||||||
|
id="admin-user-reset-password"
|
||||||
|
v-model="resetPassword"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="Minimum 12 characters"
|
||||||
|
:disabled="resettingPassword"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div class="flex flex-wrap justify-end gap-2">
|
||||||
|
<AppButton variant="secondary" :disabled="resettingPassword" @click="onResetPassword">
|
||||||
|
{{ resettingPassword ? "Resetting..." : "Reset password" }}
|
||||||
|
</AppButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2 border-t border-stroke-default pt-3">
|
||||||
|
<AppButton
|
||||||
|
variant="secondary"
|
||||||
|
:disabled="togglingUserId === activeUser.id"
|
||||||
|
@click="onToggleUser(activeUser)"
|
||||||
|
>
|
||||||
|
{{ activeUser.is_active ? "Deactivate user" : "Activate user" }}
|
||||||
|
</AppButton>
|
||||||
|
<AppButton variant="ghost" :disabled="togglingUserId === activeUser.id || resettingPassword" @click="closeUserModal">
|
||||||
|
Close
|
||||||
|
</AppButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppModal>
|
||||||
</AppPage>
|
</AppPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,24 @@
|
||||||
import { onMounted, ref } from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
|
|
||||||
import { fetchDashboardSnapshot, type DashboardSnapshot } from "@/features/dashboard";
|
import { fetchDashboardSnapshot, type DashboardSnapshot } from "@/features/dashboard";
|
||||||
|
import { triggerManualRun } from "@/features/runs";
|
||||||
import { ApiRequestError } from "@/lib/api/errors";
|
import { ApiRequestError } from "@/lib/api/errors";
|
||||||
import AppPage from "@/components/layout/AppPage.vue";
|
import AppPage from "@/components/layout/AppPage.vue";
|
||||||
|
import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
|
||||||
import QueueHealthBadge from "@/components/patterns/QueueHealthBadge.vue";
|
import QueueHealthBadge from "@/components/patterns/QueueHealthBadge.vue";
|
||||||
|
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
|
||||||
import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue";
|
import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue";
|
||||||
import AppAlert from "@/components/ui/AppAlert.vue";
|
|
||||||
import AppButton from "@/components/ui/AppButton.vue";
|
import AppButton from "@/components/ui/AppButton.vue";
|
||||||
import AppCard from "@/components/ui/AppCard.vue";
|
import AppCard from "@/components/ui/AppCard.vue";
|
||||||
import AppEmptyState from "@/components/ui/AppEmptyState.vue";
|
import AppEmptyState from "@/components/ui/AppEmptyState.vue";
|
||||||
import AppSkeleton from "@/components/ui/AppSkeleton.vue";
|
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
const pendingRun = ref(false);
|
||||||
const errorMessage = ref<string | null>(null);
|
const errorMessage = ref<string | null>(null);
|
||||||
const errorRequestId = ref<string | null>(null);
|
const errorRequestId = ref<string | null>(null);
|
||||||
|
const successMessage = ref<string | null>(null);
|
||||||
const snapshot = ref<DashboardSnapshot | null>(null);
|
const snapshot = ref<DashboardSnapshot | null>(null);
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
|
||||||
|
|
@ -50,138 +54,223 @@ async function loadSnapshot(): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onTriggerRun(): Promise<void> {
|
||||||
|
pendingRun.value = true;
|
||||||
|
errorMessage.value = null;
|
||||||
|
errorRequestId.value = null;
|
||||||
|
successMessage.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await triggerManualRun();
|
||||||
|
successMessage.value = `Update check #${result.run_id} started successfully.`;
|
||||||
|
await loadSnapshot();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiRequestError) {
|
||||||
|
errorMessage.value = error.message;
|
||||||
|
errorRequestId.value = error.requestId;
|
||||||
|
} else {
|
||||||
|
errorMessage.value = "Unable to start an update check.";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
pendingRun.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadSnapshot();
|
void loadSnapshot();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppPage title="Dashboard" subtitle="What changed since the latest run.">
|
<AppPage
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
title="Dashboard"
|
||||||
<AppButton variant="secondary" @click="loadSnapshot" :disabled="loading">
|
subtitle="Track recent publication updates and monitor background profile checks."
|
||||||
{{ loading ? "Refreshing..." : "Refresh" }}
|
fill
|
||||||
</AppButton>
|
>
|
||||||
|
<div class="flex min-h-0 flex-1 flex-col gap-4">
|
||||||
|
<RequestStateAlerts
|
||||||
|
:success-message="successMessage"
|
||||||
|
:error-message="errorMessage"
|
||||||
|
:error-request-id="errorRequestId"
|
||||||
|
error-title="Dashboard request failed"
|
||||||
|
@dismiss-success="successMessage = null"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="min-h-0 flex-1">
|
||||||
|
<AsyncStateGate :loading="loading" :loading-lines="6" :show-empty="false">
|
||||||
|
<template v-if="snapshot">
|
||||||
|
<section class="grid min-h-0 gap-4 xl:h-full xl:grid-cols-2">
|
||||||
|
<AppCard class="flex min-h-0 flex-col gap-4 xl:h-full xl:overflow-hidden">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<h2 class="text-lg font-semibold text-ink-primary">Recent Publications</h2>
|
||||||
|
<AppHelpHint
|
||||||
|
text="Recent publications are paper records discovered from tracked scholar profile checks."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-secondary">Newest papers found while checking your tracked scholar profiles.</p>
|
||||||
|
</div>
|
||||||
|
<RouterLink to="/publications" class="link-inline text-sm">Open full publications view</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AppEmptyState
|
||||||
|
v-if="snapshot.recentPublications.length === 0"
|
||||||
|
title="No new publications"
|
||||||
|
body="When a completed update check discovers changes, they will appear here."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ul v-else class="grid min-h-0 flex-1 gap-3 overflow-y-auto pr-1">
|
||||||
|
<li
|
||||||
|
v-for="item in snapshot.recentPublications.slice(0, 20)"
|
||||||
|
:key="item.publication_id"
|
||||||
|
class="grid gap-1 rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
v-if="item.pub_url"
|
||||||
|
:href="item.pub_url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
class="link-inline text-sm font-semibold text-ink-primary"
|
||||||
|
>
|
||||||
|
{{ item.title }}
|
||||||
|
</a>
|
||||||
|
<span v-else class="text-sm font-semibold text-ink-primary">{{ item.title }}</span>
|
||||||
|
<div class="flex flex-wrap items-center gap-3 text-xs text-secondary">
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'publications', query: { scholar: String(item.scholar_profile_id) } }"
|
||||||
|
class="link-inline"
|
||||||
|
>
|
||||||
|
{{ item.scholar_label }}
|
||||||
|
</RouterLink>
|
||||||
|
<span>First seen: {{ formatDate(item.first_seen_at) }}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<div class="grid min-h-0 gap-4 xl:h-full xl:grid-rows-[auto_minmax(0,1fr)]">
|
||||||
|
<AppCard class="flex min-h-0 flex-col gap-4">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<h2 class="text-lg font-semibold text-ink-primary">Overview</h2>
|
||||||
|
<AppHelpHint text="Shared summary metrics for publication coverage and processing pressure." />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<article class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2">
|
||||||
|
<p class="text-xs uppercase tracking-wide text-ink-muted">Tracked publications</p>
|
||||||
|
<p class="mt-1 text-xl font-semibold text-ink-primary">{{ snapshot.totalCount }}</p>
|
||||||
|
</article>
|
||||||
|
<article class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2">
|
||||||
|
<p class="text-xs uppercase tracking-wide text-ink-muted">New from latest check</p>
|
||||||
|
<p class="mt-1 text-xl font-semibold text-ink-primary">{{ snapshot.newCount }}</p>
|
||||||
|
</article>
|
||||||
|
<article class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2">
|
||||||
|
<p class="text-xs uppercase tracking-wide text-ink-muted">Scholars processed</p>
|
||||||
|
<p class="mt-1 text-xl font-semibold text-ink-primary">{{ snapshot.latestRun?.scholar_count ?? 0 }}</p>
|
||||||
|
</article>
|
||||||
|
<article class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2">
|
||||||
|
<p class="text-xs uppercase tracking-wide text-ink-muted">Queue pressure</p>
|
||||||
|
<p class="mt-1 text-xl font-semibold text-ink-primary">
|
||||||
|
{{ snapshot.queue.queued + snapshot.queue.retrying + snapshot.queue.dropped }}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
<QueueHealthBadge
|
<QueueHealthBadge
|
||||||
v-if="snapshot"
|
|
||||||
:queued="snapshot.queue.queued"
|
:queued="snapshot.queue.queued"
|
||||||
:retrying="snapshot.queue.retrying"
|
:retrying="snapshot.queue.retrying"
|
||||||
:dropped="snapshot.queue.dropped"
|
:dropped="snapshot.queue.dropped"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<AppAlert v-if="errorMessage" tone="danger">
|
|
||||||
<template #title>Dashboard request failed</template>
|
|
||||||
<p>{{ errorMessage }}</p>
|
|
||||||
<p class="text-secondary">Request ID: {{ errorRequestId || "n/a" }}</p>
|
|
||||||
</AppAlert>
|
|
||||||
|
|
||||||
<AppSkeleton v-if="loading" :lines="6" />
|
|
||||||
|
|
||||||
<template v-else-if="snapshot">
|
|
||||||
<section class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_22rem]">
|
|
||||||
<AppCard class="space-y-4">
|
|
||||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Snapshot Summary</h2>
|
|
||||||
<p class="text-sm text-secondary">Latest view mode: {{ snapshot.mode }}</p>
|
|
||||||
<ul class="grid gap-2 text-sm text-zinc-700 dark:text-zinc-300">
|
|
||||||
<li class="rounded-xl border border-zinc-200 bg-zinc-50 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900/60">
|
|
||||||
Latest run introduced
|
|
||||||
<span class="font-semibold text-zinc-900 dark:text-zinc-100">{{ snapshot.newCount }}</span>
|
|
||||||
publications.
|
|
||||||
</li>
|
|
||||||
<li class="rounded-xl border border-zinc-200 bg-zinc-50 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900/60">
|
|
||||||
Total tracked publications:
|
|
||||||
<span class="font-semibold text-zinc-900 dark:text-zinc-100">{{ snapshot.totalCount }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
|
||||||
<AppCard class="space-y-4">
|
<AppCard class="flex min-h-0 flex-col gap-4 xl:overflow-hidden">
|
||||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Latest Run</h2>
|
|
||||||
<div v-if="snapshot.latestRun" class="space-y-2 text-sm">
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
<RunStatusBadge :status="snapshot.latestRun.status" />
|
<div class="flex items-center gap-1">
|
||||||
<RouterLink
|
<h2 class="text-lg font-semibold text-ink-primary">Activity Monitor</h2>
|
||||||
|
<AppHelpHint
|
||||||
|
text="Combines latest profile-check details with recent check history so you can spot issues quickly."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<AppButton
|
||||||
v-if="auth.isAdmin"
|
v-if="auth.isAdmin"
|
||||||
:to="`/admin/runs/${snapshot.latestRun.id}`"
|
:disabled="pendingRun"
|
||||||
class="link-inline"
|
@click="onTriggerRun"
|
||||||
>
|
>
|
||||||
Run #{{ snapshot.latestRun.id }}
|
{{ pendingRun ? "Starting..." : "Start check" }}
|
||||||
</RouterLink>
|
</AppButton>
|
||||||
<span v-else class="text-secondary">Run #{{ snapshot.latestRun.id }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-secondary">Started: {{ formatDate(snapshot.latestRun.start_dt) }}</p>
|
|
||||||
<p class="text-muted">
|
|
||||||
{{ snapshot.latestRun.scholar_count }} scholars, {{ snapshot.latestRun.new_publication_count }} new
|
|
||||||
publications
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<AppEmptyState
|
|
||||||
v-else
|
|
||||||
title="No runs recorded"
|
|
||||||
body="Trigger a manual run from the Runs screen to begin tracking."
|
|
||||||
/>
|
|
||||||
</AppCard>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="grid gap-4 xl:grid-cols-2">
|
|
||||||
<AppCard class="space-y-4">
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Recent Publications</h2>
|
|
||||||
<RouterLink
|
|
||||||
to="/publications"
|
|
||||||
class="link-inline text-sm"
|
|
||||||
>
|
|
||||||
Open publications
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
<AppEmptyState
|
|
||||||
v-if="snapshot.recentPublications.length === 0"
|
|
||||||
title="No new publications"
|
|
||||||
body="When a completed run discovers updates, they will appear here."
|
|
||||||
/>
|
|
||||||
<ul v-else class="grid gap-3">
|
|
||||||
<li
|
|
||||||
v-for="item in snapshot.recentPublications.slice(0, 6)"
|
|
||||||
:key="item.publication_id"
|
|
||||||
class="grid gap-1 rounded-xl border border-zinc-200 bg-zinc-50 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900/60"
|
|
||||||
>
|
|
||||||
<strong class="text-zinc-900 dark:text-zinc-100">{{ item.title }}</strong>
|
|
||||||
<span class="text-secondary">{{ item.scholar_label }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</AppCard>
|
|
||||||
|
|
||||||
<AppCard class="space-y-4">
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Recent Runs</h2>
|
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-if="auth.isAdmin"
|
v-if="auth.isAdmin"
|
||||||
to="/admin/runs"
|
to="/admin/runs"
|
||||||
class="link-inline text-sm"
|
class="link-inline text-sm"
|
||||||
>
|
>
|
||||||
Open runs
|
Open check history
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="snapshot.latestRun"
|
||||||
|
class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<RunStatusBadge :status="snapshot.latestRun.status" />
|
||||||
|
<span class="text-sm font-semibold text-ink-primary">Latest check #{{ snapshot.latestRun.id }}</span>
|
||||||
|
</div>
|
||||||
|
<RouterLink
|
||||||
|
v-if="auth.isAdmin"
|
||||||
|
:to="`/admin/runs/${snapshot.latestRun.id}`"
|
||||||
|
class="link-inline text-xs"
|
||||||
|
>
|
||||||
|
View diagnostics
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-secondary">
|
||||||
|
Started {{ formatDate(snapshot.latestRun.start_dt) }}. Processed
|
||||||
|
{{ snapshot.latestRun.scholar_count }} scholars and discovered
|
||||||
|
{{ snapshot.latestRun.new_publication_count }} new publications.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AppEmptyState
|
||||||
|
v-else
|
||||||
|
title="No checks recorded"
|
||||||
|
body="Start an update check to begin monitoring activity."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="grid min-h-0 flex-1 gap-2 xl:overflow-hidden">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-muted">Recent checks</p>
|
||||||
<AppEmptyState
|
<AppEmptyState
|
||||||
v-if="snapshot.recentRuns.length === 0"
|
v-if="snapshot.recentRuns.length === 0"
|
||||||
title="No runs yet"
|
title="No checks yet"
|
||||||
body="Trigger a manual run from the Runs screen to begin tracking."
|
body="Check history will appear here."
|
||||||
/>
|
/>
|
||||||
<ul v-else class="grid gap-3">
|
<ul v-else class="grid gap-2 overflow-y-auto pr-1">
|
||||||
<li
|
<li
|
||||||
v-for="run in snapshot.recentRuns"
|
v-for="run in snapshot.recentRuns"
|
||||||
:key="run.id"
|
:key="run.id"
|
||||||
class="grid gap-1 rounded-xl border border-zinc-200 bg-zinc-50 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900/60"
|
class="grid gap-1 rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2"
|
||||||
>
|
>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<RunStatusBadge :status="run.status" />
|
<RunStatusBadge :status="run.status" />
|
||||||
<span class="text-secondary">Run #{{ run.id }}</span>
|
<span class="text-sm text-secondary">Check #{{ run.id }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-secondary">{{ formatDate(run.start_dt) }}</span>
|
<RouterLink v-if="auth.isAdmin" :to="`/admin/runs/${run.id}`" class="link-inline text-xs">
|
||||||
|
Details
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-secondary">
|
||||||
|
{{ formatDate(run.start_dt) }} • {{ run.new_publication_count }} new
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
</AsyncStateGate>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</AppPage>
|
</AppPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -54,43 +54,43 @@ async function onSubmit(): Promise<void> {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="relative min-h-screen overflow-hidden bg-zinc-100 dark:bg-zinc-950">
|
<div class="relative h-[100dvh] max-h-[100dvh] overflow-y-auto overflow-x-hidden bg-surface-app">
|
||||||
<div class="pointer-events-none absolute inset-0">
|
<div class="pointer-events-none absolute inset-0">
|
||||||
<div class="absolute -top-20 left-[-8rem] h-72 w-72 rounded-full bg-brand-300/35 blur-3xl dark:bg-brand-900/35" />
|
<div class="absolute -top-20 left-[-8rem] h-72 w-72 rounded-full bg-brand-300/30 blur-3xl" />
|
||||||
<div class="absolute bottom-[-7rem] right-[-6rem] h-80 w-80 rounded-full bg-emerald-300/25 blur-3xl dark:bg-emerald-900/25" />
|
<div class="absolute bottom-[-7rem] right-[-6rem] h-80 w-80 rounded-full bg-success-300/25 blur-3xl" />
|
||||||
<div class="absolute left-1/3 top-1/3 h-52 w-52 rounded-full bg-amber-300/20 blur-3xl dark:bg-amber-900/20" />
|
<div class="absolute left-1/3 top-1/3 h-52 w-52 rounded-full bg-warning-300/20 blur-3xl" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="relative mx-auto grid min-h-screen w-full max-w-6xl items-center gap-8 px-4 py-10 sm:px-6 lg:grid-cols-[minmax(0,1fr)_26rem] lg:px-8"
|
class="relative mx-auto grid min-h-full w-full max-w-6xl items-center gap-8 px-4 py-6 sm:px-6 lg:grid-cols-[minmax(0,1fr)_26rem] lg:px-8"
|
||||||
>
|
>
|
||||||
<section class="hidden space-y-5 lg:block">
|
<section class="hidden space-y-5 lg:block">
|
||||||
<p class="inline-flex items-center rounded-full border border-zinc-300 bg-white/70 px-3 py-1 text-xs font-medium uppercase tracking-[0.12em] text-zinc-600 dark:border-zinc-700 dark:bg-zinc-900/70 dark:text-zinc-300">
|
<p class="inline-flex items-center rounded-full border border-stroke-strong bg-surface-card/70 px-3 py-1 text-xs font-medium uppercase tracking-[0.12em] text-ink-muted">
|
||||||
Scholarr Control Center
|
Scholarr Control Center
|
||||||
</p>
|
</p>
|
||||||
<h1 class="max-w-xl font-display text-4xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">
|
<h1 class="max-w-xl font-display text-4xl font-semibold tracking-tight text-ink-primary">
|
||||||
Scholar tracking with reliable operational controls.
|
Scholar tracking with reliable operational controls.
|
||||||
</h1>
|
</h1>
|
||||||
<p class="max-w-xl text-base leading-relaxed text-zinc-600 dark:text-zinc-400">
|
<p class="max-w-xl text-base leading-relaxed text-ink-muted">
|
||||||
Use your account to review ingestion runs, publication changes, and continuation queue diagnostics from a
|
Use your account to review ingestion runs, publication changes, and continuation queue diagnostics from a
|
||||||
single workspace.
|
single workspace.
|
||||||
</p>
|
</p>
|
||||||
<ul class="grid max-w-xl gap-2 text-sm text-zinc-600 dark:text-zinc-400">
|
<ul class="grid max-w-xl gap-2 text-sm text-ink-muted">
|
||||||
<li class="rounded-lg border border-zinc-200 bg-white/60 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900/60">
|
<li class="rounded-lg border border-stroke-default bg-surface-card/60 px-3 py-2">
|
||||||
Session-based authentication with CSRF enforcement.
|
Session-based authentication with CSRF enforcement.
|
||||||
</li>
|
</li>
|
||||||
<li class="rounded-lg border border-zinc-200 bg-white/60 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900/60">
|
<li class="rounded-lg border border-stroke-default bg-surface-card/60 px-3 py-2">
|
||||||
Run and queue diagnostics are available for support workflows.
|
Run and queue diagnostics are available for support workflows.
|
||||||
</li>
|
</li>
|
||||||
<li class="rounded-lg border border-zinc-200 bg-white/60 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900/60">
|
<li class="rounded-lg border border-stroke-default bg-surface-card/60 px-3 py-2">
|
||||||
Theme-aware interface with light and dark mode support.
|
Theme-aware interface with light and dark mode support.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<AppCard class="w-full max-w-md justify-self-end space-y-6 border-zinc-200/80 bg-white/90 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/85">
|
<AppCard class="w-full max-w-md justify-self-end space-y-6 border-stroke-default/80 bg-surface-card/90 backdrop-blur">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<h1 class="font-display text-3xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">Sign In</h1>
|
<h1 class="font-display text-3xl font-semibold tracking-tight text-ink-primary">Sign In</h1>
|
||||||
<p class="text-sm text-secondary">
|
<p class="text-sm text-secondary">
|
||||||
Sign in to access scholar tracking, publication updates, and run diagnostics.
|
Sign in to access scholar tracking, publication updates, and run diagnostics.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -104,12 +104,12 @@ async function onSubmit(): Promise<void> {
|
||||||
</AppAlert>
|
</AppAlert>
|
||||||
|
|
||||||
<form class="grid gap-4" @submit.prevent="onSubmit">
|
<form class="grid gap-4" @submit.prevent="onSubmit">
|
||||||
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
||||||
<span>Email</span>
|
<span>Email</span>
|
||||||
<AppInput id="login-email" v-model="email" type="email" autocomplete="email" />
|
<AppInput id="login-email" v-model="email" type="email" autocomplete="email" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
||||||
<span>Password</span>
|
<span>Password</span>
|
||||||
<AppInput
|
<AppInput
|
||||||
id="login-password"
|
id="login-password"
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,14 @@ import { computed, onMounted, ref, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
|
||||||
import AppPage from "@/components/layout/AppPage.vue";
|
import AppPage from "@/components/layout/AppPage.vue";
|
||||||
import AppAlert from "@/components/ui/AppAlert.vue";
|
import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
|
||||||
|
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
|
||||||
import AppBadge from "@/components/ui/AppBadge.vue";
|
import AppBadge from "@/components/ui/AppBadge.vue";
|
||||||
import AppButton from "@/components/ui/AppButton.vue";
|
import AppButton from "@/components/ui/AppButton.vue";
|
||||||
import AppCard from "@/components/ui/AppCard.vue";
|
import AppCard from "@/components/ui/AppCard.vue";
|
||||||
import AppEmptyState from "@/components/ui/AppEmptyState.vue";
|
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||||
import AppInput from "@/components/ui/AppInput.vue";
|
import AppInput from "@/components/ui/AppInput.vue";
|
||||||
import AppSelect from "@/components/ui/AppSelect.vue";
|
import AppSelect from "@/components/ui/AppSelect.vue";
|
||||||
import AppSkeleton from "@/components/ui/AppSkeleton.vue";
|
|
||||||
import AppTable from "@/components/ui/AppTable.vue";
|
import AppTable from "@/components/ui/AppTable.vue";
|
||||||
import {
|
import {
|
||||||
listPublications,
|
listPublications,
|
||||||
|
|
@ -23,12 +23,16 @@ import {
|
||||||
import { listScholars, type ScholarProfile } from "@/features/scholars";
|
import { listScholars, type ScholarProfile } from "@/features/scholars";
|
||||||
import { ApiRequestError } from "@/lib/api/errors";
|
import { ApiRequestError } from "@/lib/api/errors";
|
||||||
|
|
||||||
|
type PublicationSortKey = "title" | "scholar" | "year" | "citations" | "status" | "first_seen";
|
||||||
|
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const publishingAll = ref(false);
|
const publishingAll = ref(false);
|
||||||
const publishingSelected = ref(false);
|
const publishingSelected = ref(false);
|
||||||
const mode = ref<PublicationMode>("new");
|
const mode = ref<PublicationMode>("new");
|
||||||
const selectedScholarFilter = ref("");
|
const selectedScholarFilter = ref("");
|
||||||
const searchQuery = ref("");
|
const searchQuery = ref("");
|
||||||
|
const sortKey = ref<PublicationSortKey>("first_seen");
|
||||||
|
const sortDirection = ref<"asc" | "desc">("desc");
|
||||||
|
|
||||||
const scholars = ref<ScholarProfile[]>([]);
|
const scholars = ref<ScholarProfile[]>([]);
|
||||||
const listState = ref<PublicationsResult | null>(null);
|
const listState = ref<PublicationsResult | null>(null);
|
||||||
|
|
@ -39,6 +43,7 @@ const errorRequestId = ref<string | null>(null);
|
||||||
const successMessage = ref<string | null>(null);
|
const successMessage = ref<string | null>(null);
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const textCollator = new Intl.Collator(undefined, { sensitivity: "base", numeric: true });
|
||||||
|
|
||||||
function normalizeScholarFilterQuery(value: unknown): string {
|
function normalizeScholarFilterQuery(value: unknown): string {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
|
|
@ -90,6 +95,19 @@ function publicationKey(item: PublicationItem): string {
|
||||||
return `${item.scholar_profile_id}:${item.publication_id}`;
|
return `${item.scholar_profile_id}:${item.publication_id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scholarLabel(item: ScholarProfile): string {
|
||||||
|
return item.display_name || item.scholar_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedScholarName = computed(() => {
|
||||||
|
const selectedId = Number(selectedScholarFilter.value);
|
||||||
|
if (!Number.isInteger(selectedId) || selectedId <= 0) {
|
||||||
|
return "all scholars";
|
||||||
|
}
|
||||||
|
const profile = scholars.value.find((item) => item.id === selectedId);
|
||||||
|
return profile ? scholarLabel(profile) : "the selected scholar";
|
||||||
|
});
|
||||||
|
|
||||||
const filteredPublications = computed(() => {
|
const filteredPublications = computed(() => {
|
||||||
const base = listState.value?.publications ?? [];
|
const base = listState.value?.publications ?? [];
|
||||||
const normalized = searchQuery.value.trim().toLowerCase();
|
const normalized = searchQuery.value.trim().toLowerCase();
|
||||||
|
|
@ -106,9 +124,56 @@ const filteredPublications = computed(() => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function publicationSortValue(item: PublicationItem, key: PublicationSortKey): number | string {
|
||||||
|
if (key === "title") {
|
||||||
|
return item.title;
|
||||||
|
}
|
||||||
|
if (key === "scholar") {
|
||||||
|
return item.scholar_label;
|
||||||
|
}
|
||||||
|
if (key === "year") {
|
||||||
|
return item.year ?? -1;
|
||||||
|
}
|
||||||
|
if (key === "citations") {
|
||||||
|
return item.citation_count;
|
||||||
|
}
|
||||||
|
if (key === "status") {
|
||||||
|
if (item.is_read) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
if (item.is_new_in_latest_run) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const timestamp = Date.parse(item.first_seen_at);
|
||||||
|
return Number.isNaN(timestamp) ? 0 : timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedPublications = computed(() => {
|
||||||
|
const sorted = [...filteredPublications.value];
|
||||||
|
sorted.sort((a, b) => {
|
||||||
|
const left = publicationSortValue(a, sortKey.value);
|
||||||
|
const right = publicationSortValue(b, sortKey.value);
|
||||||
|
|
||||||
|
let comparison: number;
|
||||||
|
if (typeof left === "string" && typeof right === "string") {
|
||||||
|
comparison = textCollator.compare(left, right);
|
||||||
|
} else {
|
||||||
|
comparison = Number(left) - Number(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (comparison === 0) {
|
||||||
|
comparison = textCollator.compare(a.title, b.title);
|
||||||
|
}
|
||||||
|
return sortDirection.value === "asc" ? comparison : comparison * -1;
|
||||||
|
});
|
||||||
|
return sorted;
|
||||||
|
});
|
||||||
|
|
||||||
const visibleUnreadKeys = computed(() => {
|
const visibleUnreadKeys = computed(() => {
|
||||||
const keys = new Set<string>();
|
const keys = new Set<string>();
|
||||||
for (const item of filteredPublications.value) {
|
for (const item of sortedPublications.value) {
|
||||||
if (!item.is_read) {
|
if (!item.is_read) {
|
||||||
keys.add(publicationKey(item));
|
keys.add(publicationKey(item));
|
||||||
}
|
}
|
||||||
|
|
@ -117,6 +182,25 @@ const visibleUnreadKeys = computed(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedCount = computed(() => selectedPublicationKeys.value.size);
|
const selectedCount = computed(() => selectedPublicationKeys.value.size);
|
||||||
|
const visibleCount = computed(() => sortedPublications.value.length);
|
||||||
|
const visibleUnreadCount = computed(() => visibleUnreadKeys.value.size);
|
||||||
|
const actionBusy = computed(() => loading.value || publishingAll.value || publishingSelected.value);
|
||||||
|
const showingEmptyList = computed(() => Boolean(listState.value) && sortedPublications.value.length === 0);
|
||||||
|
const modeLabel = computed(() => (mode.value === "new" ? "Needs review" : "All records"));
|
||||||
|
|
||||||
|
const emptyTitle = computed(() =>
|
||||||
|
searchQuery.value.trim().length > 0 ? "No publications match this search" : "No publications found",
|
||||||
|
);
|
||||||
|
|
||||||
|
const emptyBody = computed(() => {
|
||||||
|
if (searchQuery.value.trim().length > 0) {
|
||||||
|
return "Try another title, scholar, venue, or year.";
|
||||||
|
}
|
||||||
|
if (mode.value === "new") {
|
||||||
|
return `No publications currently need review for ${selectedScholarName.value}.`;
|
||||||
|
}
|
||||||
|
return `No publication records for ${selectedScholarName.value}.`;
|
||||||
|
});
|
||||||
|
|
||||||
const allVisibleUnreadSelected = computed(() => {
|
const allVisibleUnreadSelected = computed(() => {
|
||||||
if (visibleUnreadKeys.value.size === 0) {
|
if (visibleUnreadKeys.value.size === 0) {
|
||||||
|
|
@ -130,7 +214,7 @@ const allVisibleUnreadSelected = computed(() => {
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(filteredPublications, (items) => {
|
watch(sortedPublications, (items) => {
|
||||||
const validKeys = new Set(items.filter((item) => !item.is_read).map((item) => publicationKey(item)));
|
const validKeys = new Set(items.filter((item) => !item.is_read).map((item) => publicationKey(item)));
|
||||||
const next = new Set<string>();
|
const next = new Set<string>();
|
||||||
for (const key of selectedPublicationKeys.value) {
|
for (const key of selectedPublicationKeys.value) {
|
||||||
|
|
@ -143,6 +227,22 @@ watch(filteredPublications, (items) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function toggleSort(nextKey: PublicationSortKey): void {
|
||||||
|
if (sortKey.value === nextKey) {
|
||||||
|
sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sortKey.value = nextKey;
|
||||||
|
sortDirection.value = nextKey === "first_seen" ? "desc" : "asc";
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortMarker(key: PublicationSortKey): string {
|
||||||
|
if (sortKey.value !== key) {
|
||||||
|
return "↕";
|
||||||
|
}
|
||||||
|
return sortDirection.value === "asc" ? "↑" : "↓";
|
||||||
|
}
|
||||||
|
|
||||||
async function loadScholarFilters(): Promise<void> {
|
async function loadScholarFilters(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
scholars.value = await listScholars();
|
scholars.value = await listScholars();
|
||||||
|
|
@ -240,14 +340,14 @@ async function onMarkSelectedRead(): Promise<void> {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await markSelectedRead(selections);
|
const response = await markSelectedRead(selections);
|
||||||
successMessage.value = `${response.updated_count} publication${response.updated_count === 1 ? "" : "s"} marked as read.`;
|
successMessage.value = `${response.updated_count} publication${response.updated_count === 1 ? "" : "s"} marked as reviewed.`;
|
||||||
await loadPublications();
|
await loadPublications();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ApiRequestError) {
|
if (error instanceof ApiRequestError) {
|
||||||
errorMessage.value = error.message;
|
errorMessage.value = error.message;
|
||||||
errorRequestId.value = error.requestId;
|
errorRequestId.value = error.requestId;
|
||||||
} else {
|
} else {
|
||||||
errorMessage.value = "Unable to mark selected publications as read.";
|
errorMessage.value = "Unable to mark selected publications as reviewed.";
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
publishingSelected.value = false;
|
publishingSelected.value = false;
|
||||||
|
|
@ -262,20 +362,24 @@ async function onMarkAllRead(): Promise<void> {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await markAllRead();
|
const response = await markAllRead();
|
||||||
successMessage.value = response.message;
|
successMessage.value = `${response.updated_count} publication${response.updated_count === 1 ? "" : "s"} marked as reviewed.`;
|
||||||
await loadPublications();
|
await loadPublications();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ApiRequestError) {
|
if (error instanceof ApiRequestError) {
|
||||||
errorMessage.value = error.message;
|
errorMessage.value = error.message;
|
||||||
errorRequestId.value = error.requestId;
|
errorRequestId.value = error.requestId;
|
||||||
} else {
|
} else {
|
||||||
errorMessage.value = "Unable to mark publications as read.";
|
errorMessage.value = "Unable to mark publications as reviewed.";
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
publishingAll.value = false;
|
publishingAll.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetSearchQuery(): void {
|
||||||
|
searchQuery.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
syncScholarFilterFromRoute();
|
syncScholarFilterFromRoute();
|
||||||
void Promise.all([loadScholarFilters(), loadPublications()]);
|
void Promise.all([loadScholarFilters(), loadPublications()]);
|
||||||
|
|
@ -294,17 +398,48 @@ watch(
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppPage title="Publications" subtitle="Filter, search, and triage publication status quickly.">
|
<AppPage
|
||||||
<div class="grid gap-3 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-end">
|
title="Publications"
|
||||||
<div class="grid gap-3 md:grid-cols-[auto_auto_minmax(0,1fr)] md:items-end">
|
subtitle="Filter and review discovered publications, then mark what you have handled so the next check stays focused."
|
||||||
<div class="grid gap-1 text-xs text-secondary">
|
>
|
||||||
<span>Scope</span>
|
<AppCard class="space-y-4">
|
||||||
<div class="flex min-h-10 flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
<AppButton :variant="mode === 'new' ? 'primary' : 'secondary'" @click="setMode('new')">
|
<div class="space-y-1">
|
||||||
New
|
<div class="flex items-center gap-1">
|
||||||
|
<h2 class="text-lg font-semibold text-ink-primary">What you can do here</h2>
|
||||||
|
<AppHelpHint
|
||||||
|
text="Publications are discovered records from tracked scholar profiles. Review mode helps focus only on newly detected items."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-secondary">
|
||||||
|
Select a scholar or time scope, search within results, and mark items reviewed when you are done.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AppButton variant="ghost" :disabled="loading" @click="loadPublications">
|
||||||
|
{{ loading ? "Refreshing..." : "Refresh" }}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton :variant="mode === 'all' ? 'primary' : 'secondary'" @click="setMode('all')">
|
</div>
|
||||||
All
|
|
||||||
|
<div class="grid gap-3 xl:grid-cols-[minmax(0,15rem)_minmax(0,18rem)_minmax(0,1fr)] xl:items-end">
|
||||||
|
<div class="grid gap-1 text-xs text-secondary">
|
||||||
|
<span>View mode</span>
|
||||||
|
<div class="flex min-h-10 flex-wrap items-center gap-2">
|
||||||
|
<AppButton
|
||||||
|
:variant="mode === 'new' ? 'primary' : 'secondary'"
|
||||||
|
class="min-w-16 justify-center"
|
||||||
|
:disabled="actionBusy"
|
||||||
|
@click="setMode('new')"
|
||||||
|
>
|
||||||
|
Needs review
|
||||||
|
</AppButton>
|
||||||
|
<AppButton
|
||||||
|
:variant="mode === 'all' ? 'primary' : 'secondary'"
|
||||||
|
class="min-w-16 justify-center"
|
||||||
|
:disabled="actionBusy"
|
||||||
|
@click="setMode('all')"
|
||||||
|
>
|
||||||
|
All records
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -314,100 +449,140 @@ watch(
|
||||||
<AppSelect
|
<AppSelect
|
||||||
id="publications-scholar-filter"
|
id="publications-scholar-filter"
|
||||||
v-model="selectedScholarFilter"
|
v-model="selectedScholarFilter"
|
||||||
:disabled="loading || publishingAll || publishingSelected"
|
:disabled="actionBusy"
|
||||||
@change="onScholarFilterChanged"
|
@change="onScholarFilterChanged"
|
||||||
>
|
>
|
||||||
<option value="">All scholars</option>
|
<option value="">All scholars</option>
|
||||||
<option v-for="scholar in scholars" :key="scholar.id" :value="String(scholar.id)">
|
<option v-for="scholar in scholars" :key="scholar.id" :value="String(scholar.id)">
|
||||||
{{ scholar.display_name || scholar.scholar_id }}
|
{{ scholarLabel(scholar) }}
|
||||||
</option>
|
</option>
|
||||||
</AppSelect>
|
</AppSelect>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="grid gap-1 text-xs text-secondary" for="publications-search-input">
|
<label class="grid gap-1 text-xs text-secondary" for="publications-search-input">
|
||||||
<span>Search</span>
|
<span>Search within current scope</span>
|
||||||
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
<AppInput
|
<AppInput
|
||||||
id="publications-search-input"
|
id="publications-search-input"
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
placeholder="Search title, scholar, venue, year"
|
placeholder="Search title, scholar, venue, year"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
/>
|
/>
|
||||||
|
<AppButton
|
||||||
|
v-if="searchQuery.trim().length > 0"
|
||||||
|
variant="secondary"
|
||||||
|
class="shrink-0"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="resetSearchQuery"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</AppButton>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2 border-t border-stroke-default pt-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<AppBadge tone="info">Mode: {{ modeLabel }}</AppBadge>
|
||||||
|
<AppBadge tone="neutral">Visible: {{ visibleCount }}</AppBadge>
|
||||||
|
<AppBadge tone="warning">Needs review: {{ visibleUnreadCount }}</AppBadge>
|
||||||
|
<AppBadge tone="success">Selected: {{ selectedCount }}</AppBadge>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<AppButton
|
<AppButton
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
:disabled="selectedCount === 0 || loading || publishingSelected || publishingAll"
|
:disabled="selectedCount === 0 || actionBusy"
|
||||||
@click="onMarkSelectedRead"
|
@click="onMarkSelectedRead"
|
||||||
>
|
>
|
||||||
{{ publishingSelected ? "Marking..." : `Mark selected read (${selectedCount})` }}
|
{{ publishingSelected ? "Updating..." : `Mark selected reviewed (${selectedCount})` }}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton variant="secondary" :disabled="publishingAll || loading || publishingSelected" @click="onMarkAllRead">
|
<AppButton variant="secondary" :disabled="actionBusy || visibleUnreadCount === 0" @click="onMarkAllRead">
|
||||||
{{ publishingAll ? "Marking..." : "Mark all unread as read" }}
|
{{ publishingAll ? "Updating..." : "Mark all as reviewed" }}
|
||||||
</AppButton>
|
|
||||||
<AppButton variant="ghost" :disabled="loading" @click="loadPublications">
|
|
||||||
{{ loading ? "Refreshing..." : "Refresh" }}
|
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
<AppAlert v-if="successMessage" tone="success" dismissible @dismiss="successMessage = null">
|
<RequestStateAlerts
|
||||||
<template #title>Update complete</template>
|
:success-message="successMessage"
|
||||||
<p>{{ successMessage }}</p>
|
success-title="Publication update complete"
|
||||||
</AppAlert>
|
:error-message="errorMessage"
|
||||||
|
:error-request-id="errorRequestId"
|
||||||
<AppAlert v-if="errorMessage" tone="danger">
|
error-title="Publication request failed"
|
||||||
<template #title>Publication request failed</template>
|
@dismiss-success="successMessage = null"
|
||||||
<p>{{ errorMessage }}</p>
|
|
||||||
<p class="text-secondary">Request ID: {{ errorRequestId || "n/a" }}</p>
|
|
||||||
</AppAlert>
|
|
||||||
|
|
||||||
<AppSkeleton v-if="loading" :lines="8" />
|
|
||||||
|
|
||||||
<template v-else-if="listState">
|
|
||||||
<AppCard class="space-y-4">
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Publication list</h2>
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<AppBadge tone="info">Mode: {{ listState.mode }}</AppBadge>
|
|
||||||
<AppBadge tone="neutral">Visible: {{ filteredPublications.length }}</AppBadge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AppEmptyState
|
|
||||||
v-if="filteredPublications.length === 0"
|
|
||||||
title="No publications found"
|
|
||||||
body="Try changing mode, scholar filter, or search terms."
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AppTable v-else label="Publication list table">
|
<AppCard class="space-y-4">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<h2 class="text-lg font-semibold text-ink-primary">Publication List</h2>
|
||||||
|
<AppHelpHint
|
||||||
|
text="Use sorting, search, and bulk actions here to move discovered records from needs-review into reviewed history."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-secondary">Currently showing {{ selectedScholarName }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AsyncStateGate
|
||||||
|
:loading="loading"
|
||||||
|
:loading-lines="8"
|
||||||
|
:empty="showingEmptyList"
|
||||||
|
:empty-title="emptyTitle"
|
||||||
|
:empty-body="emptyBody"
|
||||||
|
:show-empty="!errorMessage"
|
||||||
|
>
|
||||||
|
<AppTable v-if="listState" label="Publication list table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" class="w-12">
|
<th scope="col" class="w-12">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="h-4 w-4 rounded border-zinc-400 text-brand-600 focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:border-zinc-600 dark:bg-zinc-900 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950"
|
class="h-4 w-4 rounded border-stroke-interactive bg-surface-input text-brand-600 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
|
||||||
:checked="allVisibleUnreadSelected"
|
:checked="allVisibleUnreadSelected"
|
||||||
:disabled="visibleUnreadKeys.size === 0"
|
:disabled="visibleUnreadKeys.size === 0"
|
||||||
aria-label="Select all visible unread publications"
|
aria-label="Select all visible publications that need review"
|
||||||
@change="onToggleAllVisible"
|
@change="onToggleAllVisible"
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
<th scope="col">Title</th>
|
<th scope="col">
|
||||||
<th scope="col">Scholar</th>
|
<button type="button" class="table-sort" @click="toggleSort('title')">
|
||||||
<th scope="col">Year</th>
|
Title <span aria-hidden="true">{{ sortMarker("title") }}</span>
|
||||||
<th scope="col">Citations</th>
|
</button>
|
||||||
<th scope="col">Status</th>
|
</th>
|
||||||
<th scope="col">First seen</th>
|
<th scope="col">
|
||||||
|
<button type="button" class="table-sort" @click="toggleSort('scholar')">
|
||||||
|
Scholar <span aria-hidden="true">{{ sortMarker("scholar") }}</span>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th scope="col">
|
||||||
|
<button type="button" class="table-sort" @click="toggleSort('year')">
|
||||||
|
Year <span aria-hidden="true">{{ sortMarker("year") }}</span>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th scope="col">
|
||||||
|
<button type="button" class="table-sort" @click="toggleSort('citations')">
|
||||||
|
Citations <span aria-hidden="true">{{ sortMarker("citations") }}</span>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th scope="col">
|
||||||
|
<button type="button" class="table-sort" @click="toggleSort('status')">
|
||||||
|
Review status <span aria-hidden="true">{{ sortMarker("status") }}</span>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th scope="col">
|
||||||
|
<button type="button" class="table-sort" @click="toggleSort('first_seen')">
|
||||||
|
First seen <span aria-hidden="true">{{ sortMarker("first_seen") }}</span>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="item in filteredPublications" :key="publicationKey(item)">
|
<tr v-for="item in sortedPublications" :key="publicationKey(item)">
|
||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="h-4 w-4 rounded border-zinc-400 text-brand-600 focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:border-zinc-600 dark:bg-zinc-900 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950"
|
class="h-4 w-4 rounded border-stroke-interactive bg-surface-input text-brand-600 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
|
||||||
:checked="selectedPublicationKeys.has(publicationKey(item))"
|
:checked="selectedPublicationKeys.has(publicationKey(item))"
|
||||||
:disabled="item.is_read"
|
:disabled="item.is_read"
|
||||||
:aria-label="`Select publication ${item.title}`"
|
:aria-label="`Select publication ${item.title}`"
|
||||||
|
|
@ -432,10 +607,10 @@ watch(
|
||||||
<td>
|
<td>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<AppBadge :tone="item.is_new_in_latest_run ? 'info' : 'neutral'">
|
<AppBadge :tone="item.is_new_in_latest_run ? 'info' : 'neutral'">
|
||||||
{{ item.is_new_in_latest_run ? "New" : "Existing" }}
|
{{ item.is_new_in_latest_run ? "New this check" : "Previously seen" }}
|
||||||
</AppBadge>
|
</AppBadge>
|
||||||
<AppBadge :tone="item.is_read ? 'success' : 'warning'">
|
<AppBadge :tone="item.is_read ? 'success' : 'warning'">
|
||||||
{{ item.is_read ? "Read" : "Unread" }}
|
{{ item.is_read ? "Reviewed" : "Needs review" }}
|
||||||
</AppBadge>
|
</AppBadge>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -443,7 +618,13 @@ watch(
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</AppTable>
|
</AppTable>
|
||||||
|
</AsyncStateGate>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
</template>
|
|
||||||
</AppPage>
|
</AppPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.table-sort {
|
||||||
|
@apply inline-flex items-center gap-1 text-left font-semibold text-ink-primary transition hover:text-ink-link focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,13 @@ import { computed, onMounted, ref } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
|
|
||||||
import AppPage from "@/components/layout/AppPage.vue";
|
import AppPage from "@/components/layout/AppPage.vue";
|
||||||
|
import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
|
||||||
|
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
|
||||||
import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue";
|
import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue";
|
||||||
import AppAlert from "@/components/ui/AppAlert.vue";
|
|
||||||
import AppButton from "@/components/ui/AppButton.vue";
|
import AppButton from "@/components/ui/AppButton.vue";
|
||||||
import AppCard from "@/components/ui/AppCard.vue";
|
import AppCard from "@/components/ui/AppCard.vue";
|
||||||
import AppEmptyState from "@/components/ui/AppEmptyState.vue";
|
import AppEmptyState from "@/components/ui/AppEmptyState.vue";
|
||||||
import AppSkeleton from "@/components/ui/AppSkeleton.vue";
|
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||||
import AppTable from "@/components/ui/AppTable.vue";
|
import AppTable from "@/components/ui/AppTable.vue";
|
||||||
import { getRunDetail, type RunDetail } from "@/features/runs";
|
import { getRunDetail, type RunDetail } from "@/features/runs";
|
||||||
import { ApiRequestError } from "@/lib/api/errors";
|
import { ApiRequestError } from "@/lib/api/errors";
|
||||||
|
|
@ -21,6 +22,35 @@ const errorMessage = ref<string | null>(null);
|
||||||
const errorRequestId = ref<string | null>(null);
|
const errorRequestId = ref<string | null>(null);
|
||||||
|
|
||||||
const runId = computed(() => Number(route.params.id));
|
const runId = computed(() => Number(route.params.id));
|
||||||
|
const defaultRetryCounts = {
|
||||||
|
retries_scheduled_count: 0,
|
||||||
|
scholars_with_retries_count: 0,
|
||||||
|
retry_exhausted_count: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function sortedCountEntries(record: Record<string, number> | null | undefined): Array<[string, number]> {
|
||||||
|
if (!record) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Object.entries(record)
|
||||||
|
.filter(([, value]) => Number.isFinite(value) && value > 0)
|
||||||
|
.sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const failureBucketEntries = computed(() =>
|
||||||
|
detail.value ? sortedCountEntries(detail.value.summary.scrape_failure_counts) : [],
|
||||||
|
);
|
||||||
|
|
||||||
|
const alertFlagEntries = computed(() => {
|
||||||
|
if (!detail.value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Object.entries(detail.value.summary.alert_flags ?? {})
|
||||||
|
.filter(([, enabled]) => Boolean(enabled))
|
||||||
|
.sort((a, b) => a[0].localeCompare(b[0]));
|
||||||
|
});
|
||||||
|
|
||||||
|
const retryCounts = computed(() => detail.value?.summary.retry_counts ?? defaultRetryCounts);
|
||||||
|
|
||||||
function formatDate(value: string | null): string {
|
function formatDate(value: string | null): string {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
|
|
@ -67,39 +97,89 @@ onMounted(() => {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppPage title="Run Detail" subtitle="Per-scholar diagnostics for the selected run.">
|
<AppPage title="Run Detail" subtitle="Per-scholar diagnostics for the selected run.">
|
||||||
<div class="flex justify-end">
|
<AppCard class="space-y-4">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<h2 class="text-lg font-semibold text-ink-primary">Diagnostics controls</h2>
|
||||||
|
<AppHelpHint
|
||||||
|
text="Run diagnostics explain per-scholar outcomes, retry behavior, and scrape failure classifications."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-secondary">Refresh this run detail view after queue/run updates.</p>
|
||||||
|
</div>
|
||||||
<AppButton variant="secondary" @click="loadDetail" :disabled="loading">
|
<AppButton variant="secondary" @click="loadDetail" :disabled="loading">
|
||||||
{{ loading ? "Refreshing..." : "Refresh" }}
|
{{ loading ? "Refreshing..." : "Refresh" }}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</div>
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
<AppAlert v-if="errorMessage" tone="danger">
|
<RequestStateAlerts
|
||||||
<template #title>Run detail request failed</template>
|
:error-message="errorMessage"
|
||||||
<p>{{ errorMessage }}</p>
|
:error-request-id="errorRequestId"
|
||||||
<p class="text-secondary">Request ID: {{ errorRequestId || "n/a" }}</p>
|
error-title="Run detail request failed"
|
||||||
</AppAlert>
|
/>
|
||||||
|
|
||||||
<AppSkeleton v-if="loading" :lines="8" />
|
<AsyncStateGate :loading="loading" :loading-lines="8" :show-empty="false">
|
||||||
|
<template v-if="detail">
|
||||||
<template v-else-if="detail">
|
|
||||||
<AppCard class="space-y-4">
|
<AppCard class="space-y-4">
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Run #{{ detail.run.id }}</h2>
|
<div class="flex items-center gap-1">
|
||||||
|
<h2 class="text-lg font-semibold text-ink-primary">Run #{{ detail.run.id }}</h2>
|
||||||
|
<AppHelpHint text="This panel summarizes one full checking cycle and its completion status." />
|
||||||
|
</div>
|
||||||
<p class="text-sm text-secondary">Started: {{ formatDate(detail.run.start_dt) }}</p>
|
<p class="text-sm text-secondary">Started: {{ formatDate(detail.run.start_dt) }}</p>
|
||||||
<p class="text-sm text-secondary">Ended: {{ formatDate(detail.run.end_dt) }}</p>
|
<p class="text-sm text-secondary">Ended: {{ formatDate(detail.run.end_dt) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<RunStatusBadge :status="detail.run.status" />
|
<RunStatusBadge :status="detail.run.status" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-sm text-zinc-700 dark:text-zinc-300">
|
<p class="text-sm text-ink-secondary">
|
||||||
Outcome summary: {{ detail.summary.succeeded_count }} succeeded, {{ detail.summary.partial_count }} partial,
|
Outcome summary: {{ detail.summary.succeeded_count }} succeeded, {{ detail.summary.partial_count }} partial,
|
||||||
{{ detail.summary.failed_count }} failed.
|
{{ detail.summary.failed_count }} failed.
|
||||||
</p>
|
</p>
|
||||||
|
<div class="grid gap-3 md:grid-cols-3">
|
||||||
|
<div class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2 text-sm">
|
||||||
|
<p class="font-medium text-ink-primary">Retries scheduled</p>
|
||||||
|
<p>{{ retryCounts.retries_scheduled_count }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2 text-sm">
|
||||||
|
<p class="font-medium text-ink-primary">Scholars with retries</p>
|
||||||
|
<p>{{ retryCounts.scholars_with_retries_count }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2 text-sm">
|
||||||
|
<p class="font-medium text-ink-primary">Retry exhausted</p>
|
||||||
|
<p>{{ retryCounts.retry_exhausted_count }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
|
<div class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2 text-sm">
|
||||||
|
<p class="font-medium text-ink-primary">Scrape failure buckets</p>
|
||||||
|
<p v-if="failureBucketEntries.length === 0" class="text-secondary">n/a</p>
|
||||||
|
<ul v-else class="space-y-1">
|
||||||
|
<li v-for="[name, count] in failureBucketEntries" :key="name">
|
||||||
|
<code>{{ name }}</code>: {{ count }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-stroke-default bg-surface-card-muted px-3 py-2 text-sm">
|
||||||
|
<p class="font-medium text-ink-primary">Active alerts</p>
|
||||||
|
<p v-if="alertFlagEntries.length === 0" class="text-secondary">none</p>
|
||||||
|
<ul v-else class="space-y-1">
|
||||||
|
<li v-for="[name] in alertFlagEntries" :key="name">
|
||||||
|
<code>{{ name }}</code>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
|
||||||
<AppCard class="space-y-4">
|
<AppCard class="space-y-4">
|
||||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Scholar Results</h2>
|
<div class="flex items-center gap-1">
|
||||||
|
<h2 class="text-lg font-semibold text-ink-primary">Scholar Results</h2>
|
||||||
|
<AppHelpHint text="One row per scholar profile processed in this run, including final state and warnings." />
|
||||||
|
</div>
|
||||||
<AppEmptyState
|
<AppEmptyState
|
||||||
v-if="detail.scholar_results.length === 0"
|
v-if="detail.scholar_results.length === 0"
|
||||||
title="No scholar diagnostics"
|
title="No scholar diagnostics"
|
||||||
|
|
@ -130,5 +210,6 @@ onMounted(() => {
|
||||||
</AppTable>
|
</AppTable>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
</template>
|
</template>
|
||||||
|
</AsyncStateGate>
|
||||||
</AppPage>
|
</AppPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
|
||||||
import AppPage from "@/components/layout/AppPage.vue";
|
import AppPage from "@/components/layout/AppPage.vue";
|
||||||
|
import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
|
||||||
import QueueHealthBadge from "@/components/patterns/QueueHealthBadge.vue";
|
import QueueHealthBadge from "@/components/patterns/QueueHealthBadge.vue";
|
||||||
|
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
|
||||||
import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue";
|
import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue";
|
||||||
import AppAlert from "@/components/ui/AppAlert.vue";
|
|
||||||
import AppButton from "@/components/ui/AppButton.vue";
|
import AppButton from "@/components/ui/AppButton.vue";
|
||||||
import AppCard from "@/components/ui/AppCard.vue";
|
import AppCard from "@/components/ui/AppCard.vue";
|
||||||
import AppEmptyState from "@/components/ui/AppEmptyState.vue";
|
import AppEmptyState from "@/components/ui/AppEmptyState.vue";
|
||||||
import AppSkeleton from "@/components/ui/AppSkeleton.vue";
|
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||||
import AppTable from "@/components/ui/AppTable.vue";
|
import AppTable from "@/components/ui/AppTable.vue";
|
||||||
import {
|
import {
|
||||||
clearQueueItem,
|
clearQueueItem,
|
||||||
|
|
@ -136,7 +137,23 @@ onMounted(() => {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppPage title="Runs" subtitle="Manual run controls and continuation queue diagnostics.">
|
<AppPage title="Runs" subtitle="Manual run controls and continuation queue diagnostics.">
|
||||||
|
<AppCard class="space-y-4">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<h2 class="text-lg font-semibold text-ink-primary">Run controls</h2>
|
||||||
|
<AppHelpHint
|
||||||
|
text="A run is one full publication-check cycle across eligible tracked scholar profiles."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-secondary">Trigger runs and monitor continuation-queue pressure.</p>
|
||||||
|
</div>
|
||||||
|
<QueueHealthBadge
|
||||||
|
:queued="queueCounts.queued"
|
||||||
|
:retrying="queueCounts.retrying"
|
||||||
|
:dropped="queueCounts.dropped"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<AppButton :disabled="pendingRun" @click="onTriggerManualRun">
|
<AppButton :disabled="pendingRun" @click="onTriggerManualRun">
|
||||||
{{ pendingRun ? "Triggering..." : "Run now" }}
|
{{ pendingRun ? "Triggering..." : "Run now" }}
|
||||||
|
|
@ -145,30 +162,22 @@ onMounted(() => {
|
||||||
{{ loading ? "Refreshing..." : "Refresh" }}
|
{{ loading ? "Refreshing..." : "Refresh" }}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</div>
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
<QueueHealthBadge
|
<RequestStateAlerts
|
||||||
:queued="queueCounts.queued"
|
:success-message="successMessage"
|
||||||
:retrying="queueCounts.retrying"
|
:error-message="errorMessage"
|
||||||
:dropped="queueCounts.dropped"
|
:error-request-id="errorRequestId"
|
||||||
|
error-title="Run diagnostics request failed"
|
||||||
|
@dismiss-success="successMessage = null"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<AppAlert v-if="successMessage" tone="success" dismissible @dismiss="successMessage = null">
|
<AsyncStateGate :loading="loading" :loading-lines="8" :show-empty="false">
|
||||||
<template #title>Operation complete</template>
|
|
||||||
<p>{{ successMessage }}</p>
|
|
||||||
</AppAlert>
|
|
||||||
|
|
||||||
<AppAlert v-if="errorMessage" tone="danger">
|
|
||||||
<template #title>Run diagnostics request failed</template>
|
|
||||||
<p>{{ errorMessage }}</p>
|
|
||||||
<p class="text-secondary">Request ID: {{ errorRequestId || "n/a" }}</p>
|
|
||||||
</AppAlert>
|
|
||||||
|
|
||||||
<AppSkeleton v-if="loading" :lines="8" />
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<AppCard class="space-y-4">
|
<AppCard class="space-y-4">
|
||||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Recent Runs</h2>
|
<div class="flex items-center gap-1">
|
||||||
|
<h2 class="text-lg font-semibold text-ink-primary">Recent Runs</h2>
|
||||||
|
<AppHelpHint text="Recent run history with volume and failure indicators per cycle." />
|
||||||
|
</div>
|
||||||
<AppEmptyState
|
<AppEmptyState
|
||||||
v-if="runs.length === 0"
|
v-if="runs.length === 0"
|
||||||
title="No runs found"
|
title="No runs found"
|
||||||
|
|
@ -207,7 +216,12 @@ onMounted(() => {
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
|
||||||
<AppCard class="space-y-4">
|
<AppCard class="space-y-4">
|
||||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Continuation Queue</h2>
|
<div class="flex items-center gap-1">
|
||||||
|
<h2 class="text-lg font-semibold text-ink-primary">Continuation Queue</h2>
|
||||||
|
<AppHelpHint
|
||||||
|
text="Retry queue for interrupted profile checks. Use actions to retry, drop, or clear items."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<AppEmptyState
|
<AppEmptyState
|
||||||
v-if="queueItems.length === 0"
|
v-if="queueItems.length === 0"
|
||||||
title="Queue is empty"
|
title="Queue is empty"
|
||||||
|
|
@ -261,6 +275,6 @@ onMounted(() => {
|
||||||
</tbody>
|
</tbody>
|
||||||
</AppTable>
|
</AppTable>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
</template>
|
</AsyncStateGate>
|
||||||
</AppPage>
|
</AppPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,15 @@
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
|
||||||
import AppPage from "@/components/layout/AppPage.vue";
|
import AppPage from "@/components/layout/AppPage.vue";
|
||||||
import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue";
|
import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
|
||||||
|
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
|
||||||
import AppAlert from "@/components/ui/AppAlert.vue";
|
import AppAlert from "@/components/ui/AppAlert.vue";
|
||||||
import AppBadge from "@/components/ui/AppBadge.vue";
|
import AppBadge from "@/components/ui/AppBadge.vue";
|
||||||
import AppButton from "@/components/ui/AppButton.vue";
|
import AppButton from "@/components/ui/AppButton.vue";
|
||||||
import AppCard from "@/components/ui/AppCard.vue";
|
import AppCard from "@/components/ui/AppCard.vue";
|
||||||
import AppEmptyState from "@/components/ui/AppEmptyState.vue";
|
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||||
import AppInput from "@/components/ui/AppInput.vue";
|
import AppInput from "@/components/ui/AppInput.vue";
|
||||||
import AppModal from "@/components/ui/AppModal.vue";
|
import AppModal from "@/components/ui/AppModal.vue";
|
||||||
import AppSkeleton from "@/components/ui/AppSkeleton.vue";
|
|
||||||
import AppTable from "@/components/ui/AppTable.vue";
|
import AppTable from "@/components/ui/AppTable.vue";
|
||||||
import {
|
import {
|
||||||
clearScholarImage,
|
clearScholarImage,
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
type ScholarSearchCandidate,
|
type ScholarSearchCandidate,
|
||||||
type ScholarSearchResult,
|
type ScholarSearchResult,
|
||||||
} from "@/features/scholars";
|
} from "@/features/scholars";
|
||||||
|
import ScholarAvatar from "@/features/scholars/components/ScholarAvatar.vue";
|
||||||
import { ApiRequestError } from "@/lib/api/errors";
|
import { ApiRequestError } from "@/lib/api/errors";
|
||||||
|
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
|
@ -38,7 +39,6 @@ const activeScholarSettingsId = ref<number | null>(null);
|
||||||
|
|
||||||
const scholars = ref<ScholarProfile[]>([]);
|
const scholars = ref<ScholarProfile[]>([]);
|
||||||
const imageUrlDraftByScholarId = ref<Record<number, string>>({});
|
const imageUrlDraftByScholarId = ref<Record<number, string>>({});
|
||||||
const failedImageByKey = ref<Record<string, boolean>>({});
|
|
||||||
|
|
||||||
const scholarBatchInput = ref("");
|
const scholarBatchInput = ref("");
|
||||||
const searchQuery = ref("");
|
const searchQuery = ref("");
|
||||||
|
|
@ -52,34 +52,40 @@ const successMessage = ref<string | null>(null);
|
||||||
|
|
||||||
const SCHOLAR_ID_PATTERN = /^[a-zA-Z0-9_-]{12}$/;
|
const SCHOLAR_ID_PATTERN = /^[a-zA-Z0-9_-]{12}$/;
|
||||||
const URL_USER_PARAM_PATTERN = /(?:\?|&)user=([a-zA-Z0-9_-]{12})(?:&|#|$)/i;
|
const URL_USER_PARAM_PATTERN = /(?:\?|&)user=([a-zA-Z0-9_-]{12})(?:&|#|$)/i;
|
||||||
|
const nameSearchWip = true;
|
||||||
|
|
||||||
const trackedScholarIds = computed(() => new Set(scholars.value.map((item) => item.scholar_id)));
|
const trackedScholarIds = computed(() => new Set(scholars.value.map((item) => item.scholar_id)));
|
||||||
const activeScholarSettings = computed(
|
const activeScholarSettings = computed(
|
||||||
() => scholars.value.find((item) => item.id === activeScholarSettingsId.value) ?? null,
|
() => scholars.value.find((item) => item.id === activeScholarSettingsId.value) ?? null,
|
||||||
);
|
);
|
||||||
|
const parsedBatchCount = computed(() => parseScholarIds(scholarBatchInput.value).length);
|
||||||
|
const searchHasRun = computed(() => searchResult.value !== null);
|
||||||
const searchIsDegraded = computed(() => {
|
const searchIsDegraded = computed(() => {
|
||||||
if (!searchResult.value) {
|
if (!searchResult.value) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return searchResult.value.state === "blocked_or_captcha" || searchResult.value.state === "network_error";
|
return searchResult.value.state === "blocked_or_captcha" || searchResult.value.state === "network_error";
|
||||||
});
|
});
|
||||||
|
const searchStateTone = computed(() => {
|
||||||
function formatDate(value: string | null, compact = false): string {
|
const result = searchResult.value;
|
||||||
if (!value) {
|
if (!result) {
|
||||||
return "n/a";
|
return "neutral" as const;
|
||||||
}
|
}
|
||||||
const asDate = new Date(value);
|
if (result.state === "ok") {
|
||||||
if (Number.isNaN(asDate.getTime())) {
|
return "success" as const;
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
if (compact) {
|
if (result.state === "blocked_or_captcha" || result.state === "network_error") {
|
||||||
return asDate.toLocaleString(undefined, {
|
return "warning" as const;
|
||||||
dateStyle: "short",
|
}
|
||||||
timeStyle: "short",
|
return "neutral" as const;
|
||||||
});
|
});
|
||||||
|
const emptySearchCandidates = computed(() => {
|
||||||
|
const result = searchResult.value;
|
||||||
|
if (!result) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
return asDate.toLocaleString();
|
return result.candidates.length === 0;
|
||||||
}
|
});
|
||||||
|
|
||||||
function scholarProfileUrl(scholarId: string): string {
|
function scholarProfileUrl(scholarId: string): string {
|
||||||
return `https://scholar.google.com/citations?hl=en&user=${encodeURIComponent(scholarId)}`;
|
return `https://scholar.google.com/citations?hl=en&user=${encodeURIComponent(scholarId)}`;
|
||||||
|
|
@ -138,58 +144,6 @@ function parseScholarIds(raw: string): string[] {
|
||||||
return ordered;
|
return ordered;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatImageSource(value: ScholarProfile["profile_image_source"]): string {
|
|
||||||
if (value === "upload") {
|
|
||||||
return "Uploaded";
|
|
||||||
}
|
|
||||||
if (value === "override") {
|
|
||||||
return "Custom URL";
|
|
||||||
}
|
|
||||||
if (value === "scraped") {
|
|
||||||
return "Scraped";
|
|
||||||
}
|
|
||||||
return "Fallback";
|
|
||||||
}
|
|
||||||
|
|
||||||
function sourceTone(value: ScholarProfile["profile_image_source"]): "neutral" | "info" | "success" {
|
|
||||||
if (value === "upload") {
|
|
||||||
return "success";
|
|
||||||
}
|
|
||||||
if (value === "override" || value === "scraped") {
|
|
||||||
return "info";
|
|
||||||
}
|
|
||||||
return "neutral";
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeInitials(label: string | null | undefined, scholarId: string): string {
|
|
||||||
const source = (label || "").trim();
|
|
||||||
if (source.length === 0) {
|
|
||||||
return scholarId.slice(0, 2).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokens = source.split(/\s+/).filter(Boolean);
|
|
||||||
if (tokens.length === 1) {
|
|
||||||
return tokens[0].slice(0, 2).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${tokens[0].charAt(0)}${tokens[1].charAt(0)}`.toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function imageKey(prefix: string, id: string | number, imageUrl: string | null): string {
|
|
||||||
return `${prefix}:${String(id)}:${imageUrl || "none"}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function canRenderImage(prefix: string, id: string | number, imageUrl: string | null): boolean {
|
|
||||||
if (!imageUrl) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return !failedImageByKey.value[imageKey(prefix, id, imageUrl)];
|
|
||||||
}
|
|
||||||
|
|
||||||
function markImageFailed(prefix: string, id: string | number, imageUrl: string | null): void {
|
|
||||||
failedImageByKey.value[imageKey(prefix, id, imageUrl)] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function scholarLabel(profile: ScholarProfile): string {
|
function scholarLabel(profile: ScholarProfile): string {
|
||||||
return profile.display_name || profile.scholar_id;
|
return profile.display_name || profile.scholar_id;
|
||||||
}
|
}
|
||||||
|
|
@ -311,6 +265,11 @@ async function onAddScholars(): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSearchByName(): Promise<void> {
|
async function onSearchByName(): Promise<void> {
|
||||||
|
if (nameSearchWip) {
|
||||||
|
searchErrorMessage.value = "Search by name is temporarily disabled while reliability hardening is in progress.";
|
||||||
|
searchErrorRequestId.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
searchingByName.value = true;
|
searchingByName.value = true;
|
||||||
searchErrorMessage.value = null;
|
searchErrorMessage.value = null;
|
||||||
searchErrorRequestId.value = null;
|
searchErrorRequestId.value = null;
|
||||||
|
|
@ -502,171 +461,151 @@ onMounted(() => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppPage title="Scholars" subtitle="Track scholars and manage profile behavior with less noise.">
|
<AppPage
|
||||||
<div class="flex justify-end">
|
title="Scholars"
|
||||||
<AppButton variant="secondary" @click="loadScholars" :disabled="loading || saving || searchingByName">
|
subtitle="Add and maintain the Google Scholar profiles you want Scholarr to monitor."
|
||||||
{{ loading ? "Refreshing..." : "Refresh" }}
|
>
|
||||||
</AppButton>
|
<RequestStateAlerts
|
||||||
</div>
|
:success-message="successMessage"
|
||||||
|
success-title="Scholar update complete"
|
||||||
|
:error-message="errorMessage"
|
||||||
|
:error-request-id="errorRequestId"
|
||||||
|
error-title="Scholar request failed"
|
||||||
|
@dismiss-success="successMessage = null"
|
||||||
|
/>
|
||||||
|
|
||||||
<AppAlert v-if="successMessage" tone="success" dismissible @dismiss="successMessage = null">
|
<section class="grid gap-4 xl:grid-cols-2">
|
||||||
<template #title>Scholar update complete</template>
|
|
||||||
<p>{{ successMessage }}</p>
|
|
||||||
</AppAlert>
|
|
||||||
|
|
||||||
<AppAlert v-if="errorMessage" tone="danger">
|
|
||||||
<template #title>Scholar request failed</template>
|
|
||||||
<p>{{ errorMessage }}</p>
|
|
||||||
<p class="text-secondary">Request ID: {{ errorRequestId || "n/a" }}</p>
|
|
||||||
</AppAlert>
|
|
||||||
|
|
||||||
<section class="grid gap-4 xl:grid-cols-[minmax(0,34rem)_minmax(0,1fr)]">
|
|
||||||
<div class="grid content-start gap-4">
|
<div class="grid content-start gap-4">
|
||||||
<AppCard class="space-y-4">
|
<AppCard class="space-y-4">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Add Scholars</h2>
|
<div class="flex items-center gap-1">
|
||||||
<p class="text-sm text-secondary">
|
<h2 class="text-lg font-semibold text-ink-primary">Add Scholar Profiles</h2>
|
||||||
Paste one or many Scholar IDs or profile URLs. Duplicate and already-tracked IDs are ignored per response.
|
<AppHelpHint
|
||||||
</p>
|
text="A scholar profile is a Google Scholar author page that Scholarr will monitor for publication changes."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-secondary">Paste one or more Scholar IDs or profile URLs and add them in one action.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="grid gap-3" @submit.prevent="onAddScholars">
|
<form class="grid gap-3" @submit.prevent="onAddScholars">
|
||||||
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
<label class="grid gap-2 text-sm font-medium text-ink-secondary" for="scholar-batch-input">
|
||||||
<span>Scholar IDs or profile URLs</span>
|
<span>Scholar IDs or profile URLs</span>
|
||||||
<textarea
|
<textarea
|
||||||
id="scholar-batch-input"
|
id="scholar-batch-input"
|
||||||
v-model="scholarBatchInput"
|
v-model="scholarBatchInput"
|
||||||
rows="5"
|
rows="5"
|
||||||
placeholder="A-UbBTPM15wL\nhttps://scholar.google.com/citations?hl=en&user=A-UbBTPM15wL"
|
placeholder="A-UbBTPM15wL\nhttps://scholar.google.com/citations?hl=en&user=A-UbBTPM15wL"
|
||||||
class="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 outline-none ring-brand-300 transition placeholder:text-zinc-400 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:ring-brand-400 dark:focus-visible:ring-offset-zinc-950 dark:placeholder:text-zinc-500"
|
class="w-full rounded-lg border border-stroke-interactive bg-surface-input px-3 py-2 text-sm text-ink-primary outline-none ring-focus-ring transition placeholder:text-ink-muted focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<p class="text-xs text-secondary">
|
||||||
|
Parsed IDs: <strong class="text-ink-primary">{{ parsedBatchCount }}</strong>
|
||||||
|
</p>
|
||||||
<AppButton type="submit" :disabled="saving || loading">
|
<AppButton type="submit" :disabled="saving || loading">
|
||||||
{{ saving ? "Adding..." : "Add scholars" }}
|
{{ saving ? "Adding..." : "Add scholars" }}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<span class="text-xs text-secondary">Accepted pattern: <code>[a-zA-Z0-9_-]{12}</code></span>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
|
||||||
<AppCard class="space-y-4">
|
<AppCard class="relative space-y-4 select-none">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Search by Name</h2>
|
<div class="flex items-center gap-1">
|
||||||
<p class="text-sm text-secondary">
|
<h2 class="text-lg font-semibold text-ink-primary">Search by Name</h2>
|
||||||
Best-effort helper. For reliable adds, use Scholar URL/ID above.
|
<AppHelpHint text="This workflow is currently paused while anti-block reliability changes are finalized." />
|
||||||
</p>
|
</div>
|
||||||
|
<p class="text-sm text-secondary">This helper is paused while reliability hardening is completed.</p>
|
||||||
|
</div>
|
||||||
|
<AppHelpHint text="Name search is not currently supported in production. It is on the roadmap and will return after reliability hardening.">
|
||||||
|
<template #trigger>
|
||||||
|
<AppBadge tone="warning">WIP</AppBadge>
|
||||||
|
</template>
|
||||||
|
</AppHelpHint>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="flex flex-wrap items-center gap-2" @submit.prevent="onSearchByName">
|
<form class="pointer-events-none flex flex-wrap items-center gap-2 opacity-60">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<AppInput
|
<AppInput
|
||||||
id="scholar-search-name"
|
id="scholar-search-name"
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
placeholder="e.g. Geoffrey Hinton"
|
placeholder="e.g. Geoffrey Hinton"
|
||||||
:disabled="searchingByName"
|
:disabled="nameSearchWip"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<AppButton type="submit" :disabled="searchingByName || searchQuery.trim().length < 2">
|
<AppButton type="button" :disabled="nameSearchWip">
|
||||||
{{ searchingByName ? "Searching..." : "Search" }}
|
Search
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</form>
|
</form>
|
||||||
|
<p class="text-xs text-secondary">
|
||||||
|
Direct Scholar ID/URL adds above remain the dependable path while this feature is in progress.
|
||||||
|
</p>
|
||||||
|
|
||||||
<AppAlert v-if="searchErrorMessage" tone="danger">
|
<RequestStateAlerts
|
||||||
<template #title>Search failed</template>
|
:error-message="searchErrorMessage"
|
||||||
<p>{{ searchErrorMessage }}</p>
|
:error-request-id="searchErrorRequestId"
|
||||||
<p class="text-secondary">Request ID: {{ searchErrorRequestId || "n/a" }}</p>
|
error-title="Search request failed"
|
||||||
</AppAlert>
|
/>
|
||||||
|
|
||||||
<AppSkeleton v-else-if="searchingByName" :lines="4" />
|
<AsyncStateGate :loading="searchingByName" :loading-lines="4" :show-empty="false">
|
||||||
|
<template v-if="searchResult">
|
||||||
<template v-else-if="searchResult">
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
<p class="text-sm text-secondary">
|
<p class="text-sm text-secondary">
|
||||||
{{ searchResult.candidates.length }} candidate{{ searchResult.candidates.length === 1 ? "" : "s" }} for
|
{{ searchResult.candidates.length }} candidate{{ searchResult.candidates.length === 1 ? "" : "s" }}
|
||||||
<strong class="text-zinc-900 dark:text-zinc-100">{{ searchResult.query }}</strong>
|
for <strong class="text-ink-primary">{{ searchResult.query }}</strong>
|
||||||
</p>
|
</p>
|
||||||
<AppBadge
|
<AppBadge :tone="searchStateTone">{{ searchResult.state }}</AppBadge>
|
||||||
:tone="
|
|
||||||
searchResult.state === 'ok'
|
|
||||||
? 'success'
|
|
||||||
: searchResult.state === 'blocked_or_captcha' || searchResult.state === 'network_error'
|
|
||||||
? 'warning'
|
|
||||||
: 'neutral'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ searchResult.state }}
|
|
||||||
</AppBadge>
|
|
||||||
</div>
|
</div>
|
||||||
<p
|
|
||||||
v-if="searchResult.state !== 'ok' || searchResult.warnings.length > 0"
|
<p v-if="searchResult.state !== 'ok' || searchResult.warnings.length > 0" class="text-xs text-secondary">
|
||||||
class="text-xs text-secondary"
|
|
||||||
>
|
|
||||||
<span>State reason: <code>{{ searchResult.state_reason }}</code></span>
|
<span>State reason: <code>{{ searchResult.state_reason }}</code></span>
|
||||||
<span v-if="searchResult.action_hint">. {{ searchResult.action_hint }}</span>
|
<span v-if="searchResult.action_hint">. {{ searchResult.action_hint }}</span>
|
||||||
<span v-if="searchResult.warnings.length > 0">
|
<span v-if="searchResult.warnings.length > 0">. Warnings: {{ searchResult.warnings.join(", ") }}</span>
|
||||||
. Warnings: {{ searchResult.warnings.join(", ") }}
|
|
||||||
</span>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<AppAlert v-if="searchIsDegraded" tone="warning">
|
<AppAlert v-if="searchIsDegraded" tone="warning">
|
||||||
<template #title>Name search is degraded</template>
|
<template #title>Name search is degraded</template>
|
||||||
<p>
|
<p>
|
||||||
To avoid blocks, this feature is throttled and may temporarily pause itself. Use Scholar URL/ID adds
|
This endpoint throttles aggressively to avoid blocks. Use Scholar URL/ID adds for dependable tracking.
|
||||||
for dependable tracking.
|
|
||||||
</p>
|
</p>
|
||||||
</AppAlert>
|
</AppAlert>
|
||||||
|
|
||||||
<AppEmptyState
|
<AsyncStateGate
|
||||||
v-if="searchResult.candidates.length === 0"
|
:loading="false"
|
||||||
title="No scholar matches returned"
|
:show-empty="true"
|
||||||
body="Try again later or paste Scholar profile URLs/IDs to continue safely."
|
:empty="emptySearchCandidates"
|
||||||
/>
|
empty-title="No scholar matches returned"
|
||||||
|
empty-body="Try another query later or add directly by Scholar URL/ID."
|
||||||
<ul v-else class="grid gap-3">
|
>
|
||||||
|
<ul class="grid gap-3">
|
||||||
<li
|
<li
|
||||||
v-for="candidate in searchResult.candidates"
|
v-for="candidate in searchResult.candidates"
|
||||||
:key="candidate.scholar_id"
|
:key="candidate.scholar_id"
|
||||||
class="rounded-xl border border-zinc-200 bg-zinc-50/70 p-3 dark:border-zinc-800 dark:bg-zinc-900/50"
|
class="rounded-xl border border-stroke-default bg-surface-card-muted/70 p-3"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<div
|
<ScholarAvatar
|
||||||
class="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-full border border-zinc-200 bg-zinc-100 text-xs font-semibold text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200"
|
size="sm"
|
||||||
>
|
:label="candidate.display_name"
|
||||||
<img
|
:scholar-id="candidate.scholar_id"
|
||||||
v-if="canRenderImage('candidate', candidate.scholar_id, candidate.profile_image_url)"
|
:image-url="candidate.profile_image_url"
|
||||||
:src="candidate.profile_image_url || ''"
|
|
||||||
:alt="`${candidate.display_name} profile image`"
|
|
||||||
class="h-full w-full object-cover"
|
|
||||||
loading="lazy"
|
|
||||||
referrerpolicy="no-referrer"
|
|
||||||
@error="markImageFailed('candidate', candidate.scholar_id, candidate.profile_image_url)"
|
|
||||||
/>
|
/>
|
||||||
<span v-else>{{ makeInitials(candidate.display_name, candidate.scholar_id) }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="min-w-0 flex-1 space-y-1">
|
<div class="min-w-0 flex-1 space-y-1">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<strong class="text-sm text-zinc-900 dark:text-zinc-100">{{ candidate.display_name }}</strong>
|
<strong class="text-sm text-ink-primary">{{ candidate.display_name }}</strong>
|
||||||
<code class="text-xs text-secondary">{{ candidate.scholar_id }}</code>
|
<code class="text-xs text-secondary">{{ candidate.scholar_id }}</code>
|
||||||
<AppBadge v-if="trackedScholarIds.has(candidate.scholar_id)" tone="success">Tracked</AppBadge>
|
<AppBadge v-if="trackedScholarIds.has(candidate.scholar_id)" tone="success">Tracked</AppBadge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="truncate text-xs text-secondary">
|
<p class="truncate text-xs text-secondary">{{ candidate.affiliation || "No affiliation provided" }}</p>
|
||||||
{{ candidate.affiliation || "No affiliation provided" }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2 text-xs text-secondary">
|
<div class="flex flex-wrap items-center gap-2 text-xs text-secondary">
|
||||||
<span v-if="candidate.email_domain">Email: {{ candidate.email_domain }}</span>
|
<span v-if="candidate.email_domain">Email: {{ candidate.email_domain }}</span>
|
||||||
<span v-if="candidate.cited_by_count !== null">Cited by: {{ candidate.cited_by_count }}</span>
|
<span v-if="candidate.cited_by_count !== null">Cited by: {{ candidate.cited_by_count }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-1">
|
|
||||||
<AppBadge v-for="interest in candidate.interests.slice(0, 3)" :key="interest" tone="neutral">
|
|
||||||
{{ interest }}
|
|
||||||
</AppBadge>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid shrink-0 gap-2">
|
<div class="grid shrink-0 gap-2">
|
||||||
|
|
@ -683,62 +622,71 @@ onMounted(() => {
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
</AsyncStateGate>
|
||||||
</template>
|
</template>
|
||||||
|
<p v-else-if="!searchErrorMessage && !searchHasRun" class="text-sm text-secondary">
|
||||||
<p v-else class="text-sm text-secondary">Run a name search to see matching scholar candidates.</p>
|
Run a name search to get candidates with one-click add actions.
|
||||||
|
</p>
|
||||||
|
</AsyncStateGate>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AppCard class="space-y-4">
|
<AppCard class="space-y-4">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<div class="space-y-3">
|
||||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Tracked Scholars</h2>
|
<div class="space-y-1">
|
||||||
<span class="text-sm text-secondary">{{ scholars.length }} total</span>
|
<div class="flex items-center gap-1">
|
||||||
|
<h2 class="text-lg font-semibold text-ink-primary">Tracked Scholars</h2>
|
||||||
|
<AppHelpHint
|
||||||
|
text="Tracked scholars are active profile sources. Open Manage to control status, image overrides, and removal."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-secondary">Review tracked profiles and open per-scholar settings when needed.</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-stroke-default bg-surface-card-muted/70 px-3 py-2"
|
||||||
|
>
|
||||||
|
<p class="text-xs font-medium uppercase tracking-wide text-secondary">Tracking status</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="inline-flex min-h-10 items-center rounded-lg border border-state-info-border bg-state-info-bg px-3 text-sm font-semibold text-state-info-text"
|
||||||
|
>
|
||||||
|
{{ scholars.length }} tracked
|
||||||
|
</span>
|
||||||
|
<AppButton variant="secondary" :disabled="loading || saving" @click="loadScholars">
|
||||||
|
{{ loading ? "Refreshing..." : "Refresh" }}
|
||||||
|
</AppButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AppSkeleton v-if="loading" :lines="6" />
|
<AsyncStateGate
|
||||||
|
:loading="loading"
|
||||||
<AppEmptyState
|
:loading-lines="6"
|
||||||
v-else-if="scholars.length === 0"
|
:empty="scholars.length === 0"
|
||||||
title="No scholars tracked"
|
:show-empty="!errorMessage"
|
||||||
body="Add a Scholar ID directly or search by name to start ingestion tracking."
|
empty-title="No scholars tracked"
|
||||||
/>
|
empty-body="Add a Scholar ID or URL to start ingestion tracking."
|
||||||
|
>
|
||||||
<div v-else class="space-y-3">
|
<div class="space-y-3">
|
||||||
<ul class="grid gap-3 lg:hidden">
|
<ul class="grid gap-3 lg:hidden">
|
||||||
<li
|
<li
|
||||||
v-for="item in scholars"
|
v-for="item in scholars"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="rounded-xl border border-zinc-200 bg-zinc-50/70 p-3 dark:border-zinc-800 dark:bg-zinc-900/50"
|
class="rounded-xl border border-stroke-default bg-surface-card-muted/70 p-3"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<div
|
<ScholarAvatar :label="item.display_name" :scholar-id="item.scholar_id" :image-url="item.profile_image_url" />
|
||||||
class="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-full border border-zinc-200 bg-zinc-100 text-xs font-semibold text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
v-if="canRenderImage('scholar', item.id, item.profile_image_url)"
|
|
||||||
:src="item.profile_image_url || ''"
|
|
||||||
:alt="`${scholarLabel(item)} profile image`"
|
|
||||||
class="h-full w-full object-cover"
|
|
||||||
loading="lazy"
|
|
||||||
referrerpolicy="no-referrer"
|
|
||||||
@error="markImageFailed('scholar', item.id, item.profile_image_url)"
|
|
||||||
/>
|
|
||||||
<span v-else>{{ makeInitials(item.display_name, item.scholar_id) }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="min-w-0 flex-1 space-y-1">
|
<div class="min-w-0 flex-1 space-y-1">
|
||||||
<p class="truncate text-sm font-semibold text-zinc-900 dark:text-zinc-100">{{ scholarLabel(item) }}</p>
|
<p class="truncate text-sm font-semibold text-ink-primary">{{ scholarLabel(item) }}</p>
|
||||||
<p class="text-xs text-secondary"><code>{{ item.scholar_id }}</code></p>
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<AppBadge :tone="item.is_enabled ? 'success' : 'warning'">
|
|
||||||
{{ item.is_enabled ? "Enabled" : "Disabled" }}
|
|
||||||
</AppBadge>
|
|
||||||
<RunStatusBadge :status="item.last_run_status || 'unknown'" />
|
|
||||||
<span class="text-xs text-secondary">{{ formatDate(item.last_run_dt, true) }}</span>
|
|
||||||
</div>
|
|
||||||
<RouterLink :to="scholarPublicationsRoute(item)" class="link-inline text-xs">
|
<RouterLink :to="scholarPublicationsRoute(item)" class="link-inline text-xs">
|
||||||
View publications
|
Publications
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<a :href="scholarProfileUrl(item.scholar_id)" target="_blank" rel="noreferrer" class="link-inline text-xs">
|
||||||
|
Open profile
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AppButton variant="secondary" :disabled="saving" @click="openScholarSettings(item)">Manage</AppButton>
|
<AppButton variant="secondary" :disabled="saving" @click="openScholarSettings(item)">Manage</AppButton>
|
||||||
|
|
@ -746,95 +694,54 @@ onMounted(() => {
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<AppTable class="hidden lg:block" label="Scholars table">
|
<AppTable class="hidden lg:block" label="Tracked scholars table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Scholar</th>
|
<th scope="col">Scholar</th>
|
||||||
<th scope="col">Scholar ID</th>
|
<th scope="col" class="w-[11rem]">Manage</th>
|
||||||
<th scope="col">Enabled</th>
|
|
||||||
<th scope="col" class="w-[15rem]">Last run</th>
|
|
||||||
<th scope="col">Manage</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="item in scholars" :key="item.id">
|
<tr v-for="item in scholars" :key="item.id">
|
||||||
<td>
|
<td>
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<div
|
<ScholarAvatar :label="item.display_name" :scholar-id="item.scholar_id" :image-url="item.profile_image_url" />
|
||||||
class="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-full border border-zinc-200 bg-zinc-100 text-xs font-semibold text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
v-if="canRenderImage('scholar', item.id, item.profile_image_url)"
|
|
||||||
:src="item.profile_image_url || ''"
|
|
||||||
:alt="`${scholarLabel(item)} profile image`"
|
|
||||||
class="h-full w-full object-cover"
|
|
||||||
loading="lazy"
|
|
||||||
referrerpolicy="no-referrer"
|
|
||||||
@error="markImageFailed('scholar', item.id, item.profile_image_url)"
|
|
||||||
/>
|
|
||||||
<span v-else>{{ makeInitials(item.display_name, item.scholar_id) }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid min-w-0 gap-1">
|
<div class="grid min-w-0 gap-1">
|
||||||
<strong class="truncate text-zinc-900 dark:text-zinc-100">{{ scholarLabel(item) }}</strong>
|
<strong class="truncate text-ink-primary">{{ scholarLabel(item) }}</strong>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<RouterLink :to="scholarPublicationsRoute(item)" class="link-inline text-xs">
|
<RouterLink :to="scholarPublicationsRoute(item)" class="link-inline text-xs">
|
||||||
Publications
|
Publications
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<a :href="scholarProfileUrl(item.scholar_id)" target="_blank" rel="noreferrer" class="link-inline text-xs">
|
<a :href="scholarProfileUrl(item.scholar_id)" target="_blank" rel="noreferrer" class="link-inline text-xs">
|
||||||
Open profile
|
Open profile
|
||||||
</a>
|
</a>
|
||||||
<AppBadge :tone="sourceTone(item.profile_image_source)">
|
|
||||||
{{ formatImageSource(item.profile_image_source) }}
|
|
||||||
</AppBadge>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td><code class="text-xs">{{ item.scholar_id }}</code></td>
|
|
||||||
<td>
|
<td>
|
||||||
<AppBadge :tone="item.is_enabled ? 'success' : 'warning'">
|
<AppButton variant="secondary" :disabled="saving" @click="openScholarSettings(item)">Manage</AppButton>
|
||||||
{{ item.is_enabled ? "Enabled" : "Disabled" }}
|
|
||||||
</AppBadge>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="grid gap-1 text-xs">
|
|
||||||
<RunStatusBadge :status="item.last_run_status || 'unknown'" />
|
|
||||||
<span class="whitespace-nowrap text-secondary">{{ formatDate(item.last_run_dt, true) }}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<AppButton variant="secondary" :disabled="saving" @click="openScholarSettings(item)">
|
|
||||||
Manage
|
|
||||||
</AppButton>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</AppTable>
|
</AppTable>
|
||||||
</div>
|
</div>
|
||||||
|
</AsyncStateGate>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<AppModal :open="activeScholarSettings !== null" title="Scholar settings" @close="closeScholarSettings">
|
<AppModal :open="activeScholarSettings !== null" title="Scholar settings" @close="closeScholarSettings">
|
||||||
<div v-if="activeScholarSettings" class="grid gap-4">
|
<div v-if="activeScholarSettings" class="grid gap-4">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<div
|
<ScholarAvatar
|
||||||
class="flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-full border border-zinc-200 bg-zinc-100 text-xs font-semibold text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200"
|
:label="activeScholarSettings.display_name"
|
||||||
>
|
:scholar-id="activeScholarSettings.scholar_id"
|
||||||
<img
|
:image-url="activeScholarSettings.profile_image_url"
|
||||||
v-if="canRenderImage('scholar', activeScholarSettings.id, activeScholarSettings.profile_image_url)"
|
|
||||||
:src="activeScholarSettings.profile_image_url || ''"
|
|
||||||
:alt="`${scholarLabel(activeScholarSettings)} profile image`"
|
|
||||||
class="h-full w-full object-cover"
|
|
||||||
loading="lazy"
|
|
||||||
referrerpolicy="no-referrer"
|
|
||||||
@error="markImageFailed('scholar', activeScholarSettings.id, activeScholarSettings.profile_image_url)"
|
|
||||||
/>
|
/>
|
||||||
<span v-else>{{ makeInitials(activeScholarSettings.display_name, activeScholarSettings.scholar_id) }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="min-w-0 space-y-1">
|
<div class="min-w-0 space-y-1">
|
||||||
<p class="truncate text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
<p class="truncate text-sm font-semibold text-ink-primary">
|
||||||
{{ scholarLabel(activeScholarSettings) }}
|
{{ scholarLabel(activeScholarSettings) }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-secondary">
|
<p class="text-xs text-secondary">
|
||||||
|
|
@ -857,7 +764,7 @@ onMounted(() => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
<label class="text-sm font-medium text-zinc-700 dark:text-zinc-300" :for="`scholar-image-url-${activeScholarSettings.id}`">
|
<label class="text-sm font-medium text-ink-secondary" :for="`scholar-image-url-${activeScholarSettings.id}`">
|
||||||
Profile image URL override
|
Profile image URL override
|
||||||
</label>
|
</label>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
|
@ -880,7 +787,7 @@ onMounted(() => {
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<label
|
<label
|
||||||
:for="`scholar-image-upload-${activeScholarSettings.id}`"
|
:for="`scholar-image-upload-${activeScholarSettings.id}`"
|
||||||
class="inline-flex min-h-10 cursor-pointer items-center justify-center rounded-lg border border-zinc-300 bg-zinc-100 px-3 py-2 text-sm font-semibold text-zinc-900 transition hover:bg-zinc-200 focus-within:outline-none focus-within:ring-2 focus-within:ring-brand-500 focus-within:ring-offset-2 focus-within:ring-offset-zinc-100 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700 dark:focus-within:ring-brand-400 dark:focus-within:ring-offset-zinc-950"
|
class="inline-flex min-h-10 cursor-pointer items-center justify-center rounded-lg border border-stroke-strong bg-action-secondary-bg px-3 py-2 text-sm font-semibold text-action-secondary-text transition hover:bg-action-secondary-hover-bg focus-within:outline-none focus-within:ring-2 focus-within:ring-focus-ring focus-within:ring-offset-2 focus-within:ring-offset-focus-offset"
|
||||||
:class="{ 'pointer-events-none opacity-60': isImageBusy(activeScholarSettings.id) }"
|
:class="{ 'pointer-events-none opacity-60': isImageBusy(activeScholarSettings.id) }"
|
||||||
>
|
>
|
||||||
{{ imageUploadingScholarId === activeScholarSettings.id ? "Uploading..." : "Upload image" }}
|
{{ imageUploadingScholarId === activeScholarSettings.id ? "Uploading..." : "Upload image" }}
|
||||||
|
|
@ -899,7 +806,7 @@ onMounted(() => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2 border-t border-zinc-200 pt-3 dark:border-zinc-800">
|
<div class="flex flex-wrap items-center justify-between gap-2 border-t border-stroke-default pt-3">
|
||||||
<AppButton
|
<AppButton
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
:disabled="isImageBusy(activeScholarSettings.id) || saving"
|
:disabled="isImageBusy(activeScholarSettings.id) || saving"
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,83 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
|
||||||
import AppPage from "@/components/layout/AppPage.vue";
|
import AppPage from "@/components/layout/AppPage.vue";
|
||||||
import AppAlert from "@/components/ui/AppAlert.vue";
|
import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
|
||||||
|
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
|
||||||
import AppButton from "@/components/ui/AppButton.vue";
|
import AppButton from "@/components/ui/AppButton.vue";
|
||||||
import AppCard from "@/components/ui/AppCard.vue";
|
import AppCard from "@/components/ui/AppCard.vue";
|
||||||
import AppCheckbox from "@/components/ui/AppCheckbox.vue";
|
import AppCheckbox from "@/components/ui/AppCheckbox.vue";
|
||||||
|
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||||
import AppInput from "@/components/ui/AppInput.vue";
|
import AppInput from "@/components/ui/AppInput.vue";
|
||||||
import AppModal from "@/components/ui/AppModal.vue";
|
import AppModal from "@/components/ui/AppModal.vue";
|
||||||
import AppSkeleton from "@/components/ui/AppSkeleton.vue";
|
|
||||||
import {
|
import {
|
||||||
changePassword,
|
changePassword,
|
||||||
fetchSettings,
|
fetchSettings,
|
||||||
updateSettings,
|
|
||||||
type UserSettings,
|
type UserSettings,
|
||||||
|
updateSettings,
|
||||||
} from "@/features/settings";
|
} from "@/features/settings";
|
||||||
import { ApiRequestError } from "@/lib/api/errors";
|
import { ApiRequestError } from "@/lib/api/errors";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
import { normalizeUserNavVisiblePages, useUserSettingsStore } from "@/stores/user_settings";
|
||||||
|
|
||||||
|
interface NavPageOption {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
required: boolean;
|
||||||
|
adminOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAV_PAGE_OPTIONS: NavPageOption[] = [
|
||||||
|
{
|
||||||
|
id: "dashboard",
|
||||||
|
label: "Dashboard",
|
||||||
|
description: "Overview and latest publication updates.",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "scholars",
|
||||||
|
label: "Scholars",
|
||||||
|
description: "Tracked scholar profiles and profile management.",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "publications",
|
||||||
|
label: "Publications",
|
||||||
|
description: "Review and search discovered publication records.",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "settings",
|
||||||
|
label: "Settings",
|
||||||
|
description: "Configuration and account controls.",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "style-guide",
|
||||||
|
label: "Style Guide",
|
||||||
|
description: "Admin-only visual reference for theme and component tokens.",
|
||||||
|
required: false,
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "runs",
|
||||||
|
label: "Runs",
|
||||||
|
description: "Admin-only diagnostics and queue operations.",
|
||||||
|
required: false,
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "users",
|
||||||
|
label: "Users",
|
||||||
|
description: "Admin-only user management.",
|
||||||
|
required: false,
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const userSettings = useUserSettingsStore();
|
||||||
|
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
|
@ -24,6 +86,7 @@ const updatingPassword = ref(false);
|
||||||
const autoRunEnabled = ref(false);
|
const autoRunEnabled = ref(false);
|
||||||
const runIntervalMinutes = ref("60");
|
const runIntervalMinutes = ref("60");
|
||||||
const requestDelaySeconds = ref("2");
|
const requestDelaySeconds = ref("2");
|
||||||
|
const navVisiblePages = ref<string[]>([]);
|
||||||
|
|
||||||
const currentPassword = ref("");
|
const currentPassword = ref("");
|
||||||
const newPassword = ref("");
|
const newPassword = ref("");
|
||||||
|
|
@ -34,21 +97,58 @@ const errorRequestId = ref<string | null>(null);
|
||||||
const successMessage = ref<string | null>(null);
|
const successMessage = ref<string | null>(null);
|
||||||
const showIngestionModal = ref(false);
|
const showIngestionModal = ref(false);
|
||||||
const showPasswordModal = ref(false);
|
const showPasswordModal = ref(false);
|
||||||
|
const showNavigationModal = ref(false);
|
||||||
|
const MIN_CHECK_INTERVAL_MINUTES = 15;
|
||||||
|
const MIN_REQUEST_DELAY_SECONDS = 2;
|
||||||
|
|
||||||
|
const visibleNavOptions = computed(() =>
|
||||||
|
NAV_PAGE_OPTIONS.filter((option) => !option.adminOnly || auth.isAdmin),
|
||||||
|
);
|
||||||
|
const visibleNavLabels = computed(() =>
|
||||||
|
visibleNavOptions.value
|
||||||
|
.filter((option) => navVisiblePages.value.includes(option.id))
|
||||||
|
.map((option) => option.label),
|
||||||
|
);
|
||||||
|
|
||||||
function hydrateSettings(settings: UserSettings): void {
|
function hydrateSettings(settings: UserSettings): void {
|
||||||
autoRunEnabled.value = settings.auto_run_enabled;
|
autoRunEnabled.value = settings.auto_run_enabled;
|
||||||
runIntervalMinutes.value = String(settings.run_interval_minutes);
|
runIntervalMinutes.value = String(settings.run_interval_minutes);
|
||||||
requestDelaySeconds.value = String(settings.request_delay_seconds);
|
requestDelaySeconds.value = String(settings.request_delay_seconds);
|
||||||
|
navVisiblePages.value = normalizeUserNavVisiblePages(settings.nav_visible_pages);
|
||||||
|
userSettings.applySettings(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsePositiveInteger(value: string, label: string): number {
|
function parseBoundedInteger(value: string, label: string, minimum: number): number {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
if (!Number.isInteger(parsed)) {
|
||||||
throw new Error(`${label} must be a positive integer.`);
|
throw new Error(`${label} must be a whole number.`);
|
||||||
|
}
|
||||||
|
if (parsed < minimum) {
|
||||||
|
throw new Error(`${label} must be at least ${minimum}.`);
|
||||||
}
|
}
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isNavPageVisible(pageId: string): boolean {
|
||||||
|
return navVisiblePages.value.includes(pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onToggleNavPage(page: NavPageOption, event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
if (page.required && !input.checked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.checked) {
|
||||||
|
navVisiblePages.value = normalizeUserNavVisiblePages([...navVisiblePages.value, page.id]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navVisiblePages.value = normalizeUserNavVisiblePages(
|
||||||
|
navVisiblePages.value.filter((pageId) => pageId !== page.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadSettings(): Promise<void> {
|
async function loadSettings(): Promise<void> {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
errorMessage.value = null;
|
errorMessage.value = null;
|
||||||
|
|
@ -78,14 +178,24 @@ async function onSaveSettings(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const payload: UserSettings = {
|
const payload: UserSettings = {
|
||||||
auto_run_enabled: autoRunEnabled.value,
|
auto_run_enabled: autoRunEnabled.value,
|
||||||
run_interval_minutes: parsePositiveInteger(runIntervalMinutes.value, "Run interval"),
|
run_interval_minutes: parseBoundedInteger(
|
||||||
request_delay_seconds: parsePositiveInteger(requestDelaySeconds.value, "Request delay"),
|
runIntervalMinutes.value,
|
||||||
|
"Check interval (minutes)",
|
||||||
|
MIN_CHECK_INTERVAL_MINUTES,
|
||||||
|
),
|
||||||
|
request_delay_seconds: parseBoundedInteger(
|
||||||
|
requestDelaySeconds.value,
|
||||||
|
"Delay between requests (seconds)",
|
||||||
|
MIN_REQUEST_DELAY_SECONDS,
|
||||||
|
),
|
||||||
|
nav_visible_pages: normalizeUserNavVisiblePages(navVisiblePages.value),
|
||||||
};
|
};
|
||||||
|
|
||||||
const saved = await updateSettings(payload);
|
const saved = await updateSettings(payload);
|
||||||
hydrateSettings(saved);
|
hydrateSettings(saved);
|
||||||
successMessage.value = "Settings updated.";
|
successMessage.value = "Settings updated.";
|
||||||
showIngestionModal.value = false;
|
showIngestionModal.value = false;
|
||||||
|
showNavigationModal.value = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ApiRequestError) {
|
if (error instanceof ApiRequestError) {
|
||||||
errorMessage.value = error.message;
|
errorMessage.value = error.message;
|
||||||
|
|
@ -142,65 +252,107 @@ onMounted(() => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppPage title="Settings" subtitle="Configure ingestion and account controls.">
|
<AppPage
|
||||||
<AppAlert v-if="successMessage" tone="success" dismissible @dismiss="successMessage = null">
|
title="Settings"
|
||||||
<template #title>Saved</template>
|
subtitle="Control how often Scholarr checks profiles and how cautiously it sends requests."
|
||||||
<p>{{ successMessage }}</p>
|
>
|
||||||
</AppAlert>
|
<RequestStateAlerts
|
||||||
|
:success-message="successMessage"
|
||||||
|
success-title="Saved"
|
||||||
|
:error-message="errorMessage"
|
||||||
|
:error-request-id="errorRequestId"
|
||||||
|
error-title="Settings request failed"
|
||||||
|
@dismiss-success="successMessage = null"
|
||||||
|
/>
|
||||||
|
|
||||||
<AppAlert v-if="errorMessage" tone="danger">
|
<AsyncStateGate :loading="loading" :loading-lines="7" :show-empty="false">
|
||||||
<template #title>Settings request failed</template>
|
<section class="grid gap-4 xl:grid-cols-3">
|
||||||
<p>{{ errorMessage }}</p>
|
<AppCard class="flex h-full flex-col gap-4">
|
||||||
<p class="text-secondary">Request ID: {{ errorRequestId || "n/a" }}</p>
|
<div class="flex items-center gap-1">
|
||||||
</AppAlert>
|
<h2 class="text-lg font-semibold text-ink-primary">Automatic Checking</h2>
|
||||||
|
<AppHelpHint text="Controls when Scholarr runs automatic profile checks and how cautiously it scrapes." />
|
||||||
<AppSkeleton v-if="loading" :lines="7" />
|
</div>
|
||||||
|
<p class="text-sm text-secondary">
|
||||||
<template v-else>
|
Configure the background checker that looks for new publications on your tracked profiles.
|
||||||
<section class="grid gap-4 lg:grid-cols-2">
|
</p>
|
||||||
<AppCard class="space-y-4">
|
|
||||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Scholar ingestion defaults</h2>
|
|
||||||
<dl class="grid gap-2 text-sm text-secondary">
|
<dl class="grid gap-2 text-sm text-secondary">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<dt>Scheduler</dt>
|
<dt>Automatic checks</dt>
|
||||||
<dd class="font-semibold text-zinc-900 dark:text-zinc-100">
|
<dd class="font-semibold text-ink-primary">
|
||||||
{{ autoRunEnabled ? "Enabled" : "Disabled" }}
|
{{ autoRunEnabled ? "Enabled" : "Disabled" }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<dt>Run interval</dt>
|
<dt>Check interval</dt>
|
||||||
<dd class="font-semibold text-zinc-900 dark:text-zinc-100">{{ runIntervalMinutes }} min</dd>
|
<dd class="font-semibold text-ink-primary">Every {{ runIntervalMinutes }} min</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<dt>Request delay</dt>
|
<dt>Delay between requests</dt>
|
||||||
<dd class="font-semibold text-zinc-900 dark:text-zinc-100">{{ requestDelaySeconds }} sec</dd>
|
<dd class="font-semibold text-ink-primary">{{ requestDelaySeconds }} sec</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<AppButton variant="secondary" @click="showIngestionModal = true">
|
<AppButton variant="secondary" class="mt-auto self-start" @click="showIngestionModal = true">
|
||||||
Manage ingestion settings
|
Edit checking rules
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
|
||||||
<AppCard class="space-y-4">
|
<AppCard class="flex h-full flex-col gap-4">
|
||||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Account security</h2>
|
<div class="flex items-center gap-1">
|
||||||
<p class="text-sm text-secondary">Password changes are handled in a dedicated overlay to reduce clutter.</p>
|
<h2 class="text-lg font-semibold text-ink-primary">Account Access</h2>
|
||||||
<AppButton variant="secondary" @click="showPasswordModal = true">Manage password</AppButton>
|
<AppHelpHint text="Manage credentials for your current signed-in account on this instance." />
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-secondary">
|
||||||
|
Change your sign-in password from a focused view. This does not affect other users.
|
||||||
|
</p>
|
||||||
|
<AppButton variant="secondary" class="mt-auto self-start" @click="showPasswordModal = true">
|
||||||
|
Change password
|
||||||
|
</AppButton>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<AppCard class="flex h-full flex-col gap-4">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<h2 class="text-lg font-semibold text-ink-primary">Navigation</h2>
|
||||||
|
<AppHelpHint text="Choose which pages appear in the left sidebar. Dashboard, Scholars, and Settings are always visible." />
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-secondary">
|
||||||
|
Visible now: {{ visibleNavLabels.length > 0 ? visibleNavLabels.join(", ") : "none" }}.
|
||||||
|
</p>
|
||||||
|
<AppButton variant="secondary" class="mt-auto self-start" @click="showNavigationModal = true">
|
||||||
|
Customize sidebar pages
|
||||||
|
</AppButton>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</AsyncStateGate>
|
||||||
|
|
||||||
<AppModal :open="showIngestionModal" title="Ingestion settings" @close="showIngestionModal = false">
|
<AppModal
|
||||||
|
:open="showIngestionModal"
|
||||||
|
title="Automatic Checking Settings"
|
||||||
|
@close="showIngestionModal = false"
|
||||||
|
>
|
||||||
<form class="grid gap-3" @submit.prevent="onSaveSettings">
|
<form class="grid gap-3" @submit.prevent="onSaveSettings">
|
||||||
<AppCheckbox id="auto-run-enabled" v-model="autoRunEnabled" label="Enable scheduler auto-run" />
|
<AppCheckbox id="auto-run-enabled" v-model="autoRunEnabled" label="Enable automatic background checks" />
|
||||||
|
|
||||||
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
||||||
<span>Run interval (minutes)</span>
|
<span class="inline-flex items-center gap-1">
|
||||||
<AppInput id="run-interval" v-model="runIntervalMinutes" type="number" min="1" />
|
Check interval (minutes)
|
||||||
|
<AppHelpHint text="How often Scholarr starts a background update check." />
|
||||||
|
</span>
|
||||||
|
<AppInput id="run-interval" v-model="runIntervalMinutes" type="number" :min="MIN_CHECK_INTERVAL_MINUTES" />
|
||||||
|
<span class="text-xs text-secondary">Minimum: {{ MIN_CHECK_INTERVAL_MINUTES }} minutes.</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
||||||
<span>Request delay (seconds)</span>
|
<span class="inline-flex items-center gap-1">
|
||||||
<AppInput id="request-delay" v-model="requestDelaySeconds" type="number" min="1" />
|
Delay between requests (seconds)
|
||||||
|
<AppHelpHint text="Pause between profile requests during a check. Higher values are slower but safer." />
|
||||||
|
</span>
|
||||||
|
<AppInput
|
||||||
|
id="request-delay"
|
||||||
|
v-model="requestDelaySeconds"
|
||||||
|
type="number"
|
||||||
|
:min="MIN_REQUEST_DELAY_SECONDS"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-secondary">Minimum: {{ MIN_REQUEST_DELAY_SECONDS }} seconds.</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="mt-2 flex flex-wrap justify-end gap-2">
|
<div class="mt-2 flex flex-wrap justify-end gap-2">
|
||||||
|
|
@ -219,19 +371,19 @@ onMounted(() => {
|
||||||
</form>
|
</form>
|
||||||
</AppModal>
|
</AppModal>
|
||||||
|
|
||||||
<AppModal :open="showPasswordModal" title="Change password" @close="showPasswordModal = false">
|
<AppModal :open="showPasswordModal" title="Change Sign-in Password" @close="showPasswordModal = false">
|
||||||
<form class="grid gap-3" @submit.prevent="onChangePassword">
|
<form class="grid gap-3" @submit.prevent="onChangePassword">
|
||||||
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
||||||
<span>Current password</span>
|
<span>Current password</span>
|
||||||
<AppInput v-model="currentPassword" type="password" autocomplete="current-password" />
|
<AppInput v-model="currentPassword" type="password" autocomplete="current-password" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
||||||
<span>New password</span>
|
<span>New password</span>
|
||||||
<AppInput v-model="newPassword" type="password" autocomplete="new-password" />
|
<AppInput v-model="newPassword" type="password" autocomplete="new-password" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
||||||
<span>Confirm new password</span>
|
<span>Confirm new password</span>
|
||||||
<AppInput v-model="confirmPassword" type="password" autocomplete="new-password" />
|
<AppInput v-model="confirmPassword" type="password" autocomplete="new-password" />
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -251,5 +403,52 @@ onMounted(() => {
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppModal>
|
</AppModal>
|
||||||
|
|
||||||
|
<AppModal :open="showNavigationModal" title="Sidebar Page Visibility" @close="showNavigationModal = false">
|
||||||
|
<form class="grid gap-3" @submit.prevent="onSaveSettings">
|
||||||
|
<p class="text-sm text-secondary">
|
||||||
|
Turn optional pages on or off in the sidebar for your account.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="grid gap-2">
|
||||||
|
<li
|
||||||
|
v-for="option in visibleNavOptions"
|
||||||
|
:key="option.id"
|
||||||
|
class="rounded-lg border border-stroke-default bg-surface-card-muted/70 px-3 py-2"
|
||||||
|
>
|
||||||
|
<label class="flex items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="mt-0.5 h-4 w-4 rounded border-stroke-interactive bg-surface-input text-brand-600 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
|
||||||
|
:checked="isNavPageVisible(option.id)"
|
||||||
|
:disabled="saving || option.required"
|
||||||
|
@change="onToggleNavPage(option, $event)"
|
||||||
|
/>
|
||||||
|
<span class="grid gap-0.5">
|
||||||
|
<span class="text-sm font-medium text-ink-primary">
|
||||||
|
{{ option.label }}
|
||||||
|
<span v-if="option.required" class="ml-1 text-xs text-ink-muted">(required)</span>
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-secondary">{{ option.description }}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="mt-2 flex flex-wrap justify-end gap-2">
|
||||||
|
<AppButton
|
||||||
|
variant="ghost"
|
||||||
|
type="button"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="showNavigationModal = false"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</AppButton>
|
||||||
|
<AppButton type="submit" :disabled="saving">
|
||||||
|
{{ saving ? "Saving..." : "Save sidebar settings" }}
|
||||||
|
</AppButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppModal>
|
||||||
</AppPage>
|
</AppPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
|
|
||||||
import AppPage from "@/components/layout/AppPage.vue";
|
import AppPage from "@/components/layout/AppPage.vue";
|
||||||
import QueueHealthBadge from "@/components/patterns/QueueHealthBadge.vue";
|
import QueueHealthBadge from "@/components/patterns/QueueHealthBadge.vue";
|
||||||
|
|
@ -12,17 +12,107 @@ import AppCheckbox from "@/components/ui/AppCheckbox.vue";
|
||||||
import AppInput from "@/components/ui/AppInput.vue";
|
import AppInput from "@/components/ui/AppInput.vue";
|
||||||
import AppSelect from "@/components/ui/AppSelect.vue";
|
import AppSelect from "@/components/ui/AppSelect.vue";
|
||||||
import AppTable from "@/components/ui/AppTable.vue";
|
import AppTable from "@/components/ui/AppTable.vue";
|
||||||
|
import { useThemeStore } from "@/stores/theme";
|
||||||
|
|
||||||
const sampleName = ref("Ada Lovelace");
|
const sampleName = ref("Ada Lovelace");
|
||||||
const sampleEmail = ref("ada@example.com");
|
const sampleEmail = ref("ada@example.com");
|
||||||
const sampleRole = ref("operator");
|
const sampleRole = ref("operator");
|
||||||
const sampleEnabled = ref(true);
|
const sampleEnabled = ref(true);
|
||||||
|
const theme = useThemeStore();
|
||||||
|
|
||||||
|
const isDarkTheme = computed(() => theme.active === "dark");
|
||||||
|
const toggleThemeLabel = computed(() =>
|
||||||
|
isDarkTheme.value ? "Switch to light mode" : "Switch to dark mode",
|
||||||
|
);
|
||||||
|
const selectedPreset = computed({
|
||||||
|
get: () => theme.preset,
|
||||||
|
set: (value: string) => theme.setPreset(value),
|
||||||
|
});
|
||||||
|
|
||||||
|
const surfaceSamples = [
|
||||||
|
{ label: "App surface", value: "--theme-surface-app" },
|
||||||
|
{ label: "Nav surface", value: "--theme-surface-nav" },
|
||||||
|
{ label: "Active nav", value: "--theme-surface-nav-active" },
|
||||||
|
{ label: "Card surface", value: "--theme-surface-card" },
|
||||||
|
{ label: "Table header", value: "--theme-surface-table-header" },
|
||||||
|
{ label: "Input surface", value: "--theme-surface-input" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const textSamples = [
|
||||||
|
{ label: "Primary text", value: "--theme-text-primary" },
|
||||||
|
{ label: "Secondary text", value: "--theme-text-secondary" },
|
||||||
|
{ label: "Muted text", value: "--theme-text-muted" },
|
||||||
|
{ label: "Inverse text", value: "--theme-text-inverse" },
|
||||||
|
{ label: "Link text", value: "--theme-text-link" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const actionSamples = [
|
||||||
|
{ label: "Primary action", value: "--theme-action-primary-bg" },
|
||||||
|
{ label: "Secondary action", value: "--theme-action-secondary-bg" },
|
||||||
|
{ label: "Ghost action", value: "--theme-action-ghost-bg" },
|
||||||
|
{ label: "Danger action", value: "--theme-action-danger-bg" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const borderSamples = [
|
||||||
|
{ label: "Default border", value: "--theme-border-default" },
|
||||||
|
{ label: "Strong border", value: "--theme-border-strong" },
|
||||||
|
{ label: "Subtle border", value: "--theme-border-subtle" },
|
||||||
|
{ label: "Interactive border", value: "--theme-border-interactive" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const focusSamples = [
|
||||||
|
{ label: "Focus ring", value: "--theme-focus-ring" },
|
||||||
|
{ label: "Focus offset", value: "--theme-focus-ring-offset" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const stateSamples = [
|
||||||
|
{ label: "Info state", value: "--theme-state-info-bg", text: "--theme-state-info-text", border: "--theme-state-info-border" },
|
||||||
|
{
|
||||||
|
label: "Success state",
|
||||||
|
value: "--theme-state-success-bg",
|
||||||
|
text: "--theme-state-success-text",
|
||||||
|
border: "--theme-state-success-border",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Warning state",
|
||||||
|
value: "--theme-state-warning-bg",
|
||||||
|
text: "--theme-state-warning-text",
|
||||||
|
border: "--theme-state-warning-border",
|
||||||
|
},
|
||||||
|
{ label: "Danger state", value: "--theme-state-danger-bg", text: "--theme-state-danger-text", border: "--theme-state-danger-border" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const scaleFamilies = ["brand", "info", "success", "warning", "danger"] as const;
|
||||||
|
const scaleSteps = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900", "950"] as const;
|
||||||
|
|
||||||
const sampleRows = [
|
const sampleRows = [
|
||||||
{ id: 101, title: "Entity Resolution Improvements", status: "running", owner: "Scheduler" },
|
{ id: 101, title: "Entity Resolution Improvements", status: "running", owner: "Scheduler" },
|
||||||
{ id: 102, title: "Retry Queue Drain", status: "partial_failure", owner: "Ops" },
|
{ id: 102, title: "Retry Queue Drain", status: "partial_failure", owner: "Ops" },
|
||||||
{ id: 103, title: "Weekly Publication Sync", status: "success", owner: "Ingestion" },
|
{ id: 103, title: "Weekly Publication Sync", status: "success", owner: "Ingestion" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function cssVarStyle(tokenName: string): Record<string, string> {
|
||||||
|
return { backgroundColor: `rgb(var(${tokenName}) / 1)` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cssVarBorderStyle(tokenName: string): Record<string, string> {
|
||||||
|
return { borderColor: `rgb(var(${tokenName}) / 1)` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function textSampleStyle(tokenName: string): Record<string, string> {
|
||||||
|
if (tokenName === "--theme-text-inverse") {
|
||||||
|
return { backgroundColor: "rgb(var(--theme-surface-overlay) / 1)" };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorScaleStyle(family: (typeof scaleFamilies)[number], step: (typeof scaleSteps)[number]): Record<string, string> {
|
||||||
|
return { backgroundColor: `rgb(var(--color-${family}-${step}) / 1)` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function onToggleTheme(): void {
|
||||||
|
theme.setPreference(isDarkTheme.value ? "light" : "dark");
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -30,9 +120,193 @@ const sampleRows = [
|
||||||
title="Style Guide"
|
title="Style Guide"
|
||||||
subtitle="Reference page for Tailwind component patterns used across the application."
|
subtitle="Reference page for Tailwind component patterns used across the application."
|
||||||
>
|
>
|
||||||
|
<AppCard class="space-y-4">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h2 class="text-lg font-semibold text-ink-primary">Theme Lab</h2>
|
||||||
|
<p class="text-sm text-secondary">Preview preset + mode behavior and inspect semantic token groups.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<AppSelect v-model="selectedPreset" class="w-40">
|
||||||
|
<option v-for="preset in theme.availablePresets" :key="preset.id" :value="preset.id">
|
||||||
|
{{ preset.label }}
|
||||||
|
</option>
|
||||||
|
</AppSelect>
|
||||||
|
<AppButton variant="secondary" @click="onToggleTheme">
|
||||||
|
{{ toggleThemeLabel }}
|
||||||
|
</AppButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 lg:grid-cols-3">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-muted">Surfaces</p>
|
||||||
|
<ul class="grid gap-2">
|
||||||
|
<li
|
||||||
|
v-for="sample in surfaceSamples"
|
||||||
|
:key="sample.value"
|
||||||
|
class="flex items-center justify-between rounded-lg border border-stroke-default px-3 py-2 text-xs text-ink-secondary"
|
||||||
|
:style="cssVarStyle(sample.value)"
|
||||||
|
>
|
||||||
|
<span class="font-medium text-ink-primary">{{ sample.label }}</span>
|
||||||
|
<code>{{ sample.value }}</code>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-muted">Text</p>
|
||||||
|
<ul class="grid gap-2">
|
||||||
|
<li
|
||||||
|
v-for="sample in textSamples"
|
||||||
|
:key="sample.value"
|
||||||
|
class="flex items-center justify-between rounded-lg border border-stroke-default bg-surface-card px-3 py-2 text-xs"
|
||||||
|
:style="textSampleStyle(sample.value)"
|
||||||
|
>
|
||||||
|
<span class="font-medium" :style="{ color: `rgb(var(${sample.value}) / 1)` }">{{ sample.label }}</span>
|
||||||
|
<code class="text-ink-muted">{{ sample.value }}</code>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-muted">Actions</p>
|
||||||
|
<ul class="grid gap-2">
|
||||||
|
<li
|
||||||
|
v-for="sample in actionSamples"
|
||||||
|
:key="sample.value"
|
||||||
|
class="flex items-center justify-between rounded-lg border border-stroke-default px-3 py-2 text-xs text-ink-secondary"
|
||||||
|
:style="cssVarStyle(sample.value)"
|
||||||
|
>
|
||||||
|
<span class="font-medium text-ink-primary">{{ sample.label }}</span>
|
||||||
|
<code>{{ sample.value }}</code>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 lg:grid-cols-3">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-muted">Borders</p>
|
||||||
|
<ul class="grid gap-2">
|
||||||
|
<li
|
||||||
|
v-for="sample in borderSamples"
|
||||||
|
:key="sample.value"
|
||||||
|
class="flex items-center justify-between rounded-lg border-2 bg-surface-card px-3 py-2 text-xs text-ink-secondary"
|
||||||
|
:style="cssVarBorderStyle(sample.value)"
|
||||||
|
>
|
||||||
|
<span class="font-medium text-ink-primary">{{ sample.label }}</span>
|
||||||
|
<code>{{ sample.value }}</code>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-muted">Focus</p>
|
||||||
|
<ul class="grid gap-2">
|
||||||
|
<li
|
||||||
|
v-for="sample in focusSamples"
|
||||||
|
:key="sample.value"
|
||||||
|
class="flex items-center justify-between rounded-lg border border-stroke-default bg-surface-card px-3 py-2 text-xs text-ink-secondary"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md px-2 py-1 font-medium text-ink-primary focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
|
:style="{ '--tw-ring-color': `rgb(var(${sample.value}) / 1)`, '--tw-ring-offset-color': 'rgb(var(--theme-focus-ring-offset) / 1)' }"
|
||||||
|
>
|
||||||
|
Focus me
|
||||||
|
</button>
|
||||||
|
<code>{{ sample.value }}</code>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-muted">States</p>
|
||||||
|
<ul class="grid gap-2">
|
||||||
|
<li
|
||||||
|
v-for="sample in stateSamples"
|
||||||
|
:key="sample.value"
|
||||||
|
class="flex items-center justify-between rounded-lg border px-3 py-2 text-xs"
|
||||||
|
:style="{
|
||||||
|
backgroundColor: `rgb(var(${sample.value}) / 1)`,
|
||||||
|
borderColor: `rgb(var(${sample.border}) / 1)`,
|
||||||
|
color: `rgb(var(${sample.text}) / 1)`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<span class="font-medium">{{ sample.label }}</span>
|
||||||
|
<code>{{ sample.value }}</code>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<AppCard class="space-y-4">
|
||||||
|
<h2 class="text-lg font-semibold text-ink-primary">Scale Ramps</h2>
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<div v-for="family in scaleFamilies" :key="family" class="grid gap-1">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-muted">{{ family }}</p>
|
||||||
|
<div class="grid grid-cols-11 gap-1">
|
||||||
|
<span
|
||||||
|
v-for="step in scaleSteps"
|
||||||
|
:key="`${family}-${step}`"
|
||||||
|
class="h-8 rounded-md border border-stroke-default"
|
||||||
|
:style="colorScaleStyle(family, step)"
|
||||||
|
:title="`--color-${family}-${step}`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<AppCard class="space-y-4">
|
||||||
|
<h2 class="text-lg font-semibold text-ink-primary">Shell Preview</h2>
|
||||||
|
<p class="text-sm text-secondary">Quick check for app background, nav, card, table, action, and status token coverage.</p>
|
||||||
|
<div class="grid gap-3 rounded-2xl border border-stroke-default bg-surface-app p-3 lg:grid-cols-[14rem_minmax(0,1fr)]">
|
||||||
|
<aside class="space-y-2 rounded-xl border border-stroke-default bg-surface-nav p-3">
|
||||||
|
<div class="rounded-lg bg-surface-nav-active px-2 py-1 text-sm font-semibold text-ink-inverse">Dashboard</div>
|
||||||
|
<div class="rounded-lg px-2 py-1 text-sm text-ink-secondary">Publications</div>
|
||||||
|
<div class="rounded-lg px-2 py-1 text-sm text-ink-secondary">Scholars</div>
|
||||||
|
</aside>
|
||||||
|
<section class="space-y-3 rounded-xl border border-stroke-default bg-surface-card p-3">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<p class="text-sm font-semibold text-ink-primary">Latest Update Check</p>
|
||||||
|
<RunStatusBadge status="running" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<AppButton>Primary action</AppButton>
|
||||||
|
<AppButton variant="secondary">Secondary</AppButton>
|
||||||
|
<AppBadge tone="warning">Needs review</AppBadge>
|
||||||
|
</div>
|
||||||
|
<AppTable label="Shell preview table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Scholar</th>
|
||||||
|
<th scope="col">State</th>
|
||||||
|
<th scope="col">Count</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Ada Lovelace</td>
|
||||||
|
<td>Checked</td>
|
||||||
|
<td>4</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Geoffrey Hinton</td>
|
||||||
|
<td>Queued</td>
|
||||||
|
<td>2</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</AppTable>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
<section class="grid gap-4 xl:grid-cols-2">
|
<section class="grid gap-4 xl:grid-cols-2">
|
||||||
<AppCard class="space-y-4">
|
<AppCard class="space-y-4">
|
||||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Buttons</h2>
|
<h2 class="text-lg font-semibold text-ink-primary">Buttons</h2>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<AppButton>Primary</AppButton>
|
<AppButton>Primary</AppButton>
|
||||||
<AppButton variant="secondary">Secondary</AppButton>
|
<AppButton variant="secondary">Secondary</AppButton>
|
||||||
|
|
@ -44,7 +318,7 @@ const sampleRows = [
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
|
||||||
<AppCard class="space-y-4">
|
<AppCard class="space-y-4">
|
||||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Status System</h2>
|
<h2 class="text-lg font-semibold text-ink-primary">Status System</h2>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<AppBadge tone="neutral">Neutral</AppBadge>
|
<AppBadge tone="neutral">Neutral</AppBadge>
|
||||||
<AppBadge tone="info">Info</AppBadge>
|
<AppBadge tone="info">Info</AppBadge>
|
||||||
|
|
@ -64,19 +338,19 @@ const sampleRows = [
|
||||||
|
|
||||||
<section class="grid gap-4 xl:grid-cols-2">
|
<section class="grid gap-4 xl:grid-cols-2">
|
||||||
<AppCard class="space-y-4">
|
<AppCard class="space-y-4">
|
||||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Forms</h2>
|
<h2 class="text-lg font-semibold text-ink-primary">Forms</h2>
|
||||||
<form class="grid gap-3" @submit.prevent>
|
<form class="grid gap-3" @submit.prevent>
|
||||||
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
||||||
<span>Name</span>
|
<span>Name</span>
|
||||||
<AppInput v-model="sampleName" autocomplete="name" />
|
<AppInput v-model="sampleName" autocomplete="name" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
||||||
<span>Email</span>
|
<span>Email</span>
|
||||||
<AppInput v-model="sampleEmail" type="email" autocomplete="email" />
|
<AppInput v-model="sampleEmail" type="email" autocomplete="email" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
||||||
<span>Role</span>
|
<span>Role</span>
|
||||||
<AppSelect v-model="sampleRole">
|
<AppSelect v-model="sampleRole">
|
||||||
<option value="viewer">Viewer</option>
|
<option value="viewer">Viewer</option>
|
||||||
|
|
@ -95,7 +369,7 @@ const sampleRows = [
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
|
||||||
<AppCard class="space-y-4">
|
<AppCard class="space-y-4">
|
||||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Alerts</h2>
|
<h2 class="text-lg font-semibold text-ink-primary">Alerts</h2>
|
||||||
<AppAlert tone="info">
|
<AppAlert tone="info">
|
||||||
<template #title>Informational</template>
|
<template #title>Informational</template>
|
||||||
<p>Use for contextual guidance and non-critical notices.</p>
|
<p>Use for contextual guidance and non-critical notices.</p>
|
||||||
|
|
@ -112,7 +386,7 @@ const sampleRows = [
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<AppCard class="space-y-4">
|
<AppCard class="space-y-4">
|
||||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Table Pattern</h2>
|
<h2 class="text-lg font-semibold text-ink-primary">Table Pattern</h2>
|
||||||
<AppTable label="Style guide sample run table">
|
<AppTable label="Style guide sample run table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { ApiRequestError } from "@/lib/api/errors";
|
||||||
import { fetchCsrfBootstrap } from "@/lib/api/csrf";
|
import { fetchCsrfBootstrap } from "@/lib/api/csrf";
|
||||||
import { fetchMe, loginSession, logoutSession, type SessionUser } from "@/lib/auth/session";
|
import { fetchMe, loginSession, logoutSession, type SessionUser } from "@/lib/auth/session";
|
||||||
import { useUiStore } from "@/stores/ui";
|
import { useUiStore } from "@/stores/ui";
|
||||||
|
import { useUserSettingsStore } from "@/stores/user_settings";
|
||||||
|
|
||||||
export type AuthState = "unknown" | "authenticated" | "anonymous";
|
export type AuthState = "unknown" | "authenticated" | "anonymous";
|
||||||
|
|
||||||
|
|
@ -53,12 +54,16 @@ export const useAuthStore = defineStore("auth", {
|
||||||
this.state = "authenticated";
|
this.state = "authenticated";
|
||||||
this.user = response.data.user;
|
this.user = response.data.user;
|
||||||
this.csrfToken = response.data.csrf_token;
|
this.csrfToken = response.data.csrf_token;
|
||||||
|
const userSettings = useUserSettingsStore();
|
||||||
|
await userSettings.bootstrap();
|
||||||
},
|
},
|
||||||
async logout(): Promise<void> {
|
async logout(): Promise<void> {
|
||||||
await logoutSession();
|
await logoutSession();
|
||||||
this.state = "anonymous";
|
this.state = "anonymous";
|
||||||
this.user = null;
|
this.user = null;
|
||||||
this.csrfToken = null;
|
this.csrfToken = null;
|
||||||
|
const userSettings = useUserSettingsStore();
|
||||||
|
userSettings.reset();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const csrf = await fetchCsrfBootstrap();
|
const csrf = await fetchCsrfBootstrap();
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,87 @@
|
||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
|
import {
|
||||||
|
DEFAULT_THEME_PRESET,
|
||||||
|
THEME_PRESET_OPTIONS,
|
||||||
|
getThemePreset,
|
||||||
|
isThemePresetId,
|
||||||
|
themeCssVariables,
|
||||||
|
type ThemePresetId,
|
||||||
|
} from "@/theme/presets";
|
||||||
|
|
||||||
export type ThemePreference = "system" | "light" | "dark";
|
export type ThemePreference = "system" | "light" | "dark";
|
||||||
export type ThemeValue = "light" | "dark";
|
export type ThemeValue = "light" | "dark";
|
||||||
|
|
||||||
const STORAGE_KEY = "scholarr-theme-preference";
|
const STORAGE_KEY = "scholarr-theme-preference";
|
||||||
|
const PRESET_STORAGE_KEY = "scholarr-theme-preset";
|
||||||
|
|
||||||
function systemTheme(): ThemeValue {
|
function systemTheme(): ThemeValue {
|
||||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTheme(theme: ThemeValue, preference: ThemePreference): void {
|
function applyTheme(theme: ThemeValue, preference: ThemePreference, preset: ThemePresetId): void {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.classList.toggle("dark", theme === "dark");
|
root.classList.toggle("dark", theme === "dark");
|
||||||
root.setAttribute("data-theme-preference", preference);
|
root.setAttribute("data-theme-preference", preference);
|
||||||
|
root.setAttribute("data-theme-preset", preset);
|
||||||
|
|
||||||
|
const variables = themeCssVariables(preset, theme);
|
||||||
|
Object.entries(variables).forEach(([name, value]) => {
|
||||||
|
root.style.setProperty(name, value);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useThemeStore = defineStore("theme", {
|
export const useThemeStore = defineStore("theme", {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
preference: "system" as ThemePreference,
|
preference: "system" as ThemePreference,
|
||||||
active: "light" as ThemeValue,
|
active: "light" as ThemeValue,
|
||||||
|
preset: DEFAULT_THEME_PRESET as ThemePresetId,
|
||||||
}),
|
}),
|
||||||
|
getters: {
|
||||||
|
availablePresets: () => THEME_PRESET_OPTIONS,
|
||||||
|
presetLabel: (state) => getThemePreset(state.preset).label,
|
||||||
|
},
|
||||||
actions: {
|
actions: {
|
||||||
initialize(): void {
|
initialize(): void {
|
||||||
let stored: string | null = null;
|
let storedPreference: string | null = null;
|
||||||
|
let storedPreset: string | null = null;
|
||||||
try {
|
try {
|
||||||
stored = localStorage.getItem(STORAGE_KEY);
|
storedPreference = localStorage.getItem(STORAGE_KEY);
|
||||||
|
storedPreset = localStorage.getItem(PRESET_STORAGE_KEY);
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
stored = null;
|
storedPreference = null;
|
||||||
|
storedPreset = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stored === "light" || stored === "dark" || stored === "system") {
|
if (storedPreference === "light" || storedPreference === "dark" || storedPreference === "system") {
|
||||||
this.preference = stored;
|
this.preference = storedPreference;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storedPreset && isThemePresetId(storedPreset)) {
|
||||||
|
this.preset = storedPreset;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.active = this.preference === "system" ? systemTheme() : this.preference;
|
this.active = this.preference === "system" ? systemTheme() : this.preference;
|
||||||
applyTheme(this.active, this.preference);
|
applyTheme(this.active, this.preference, this.preset);
|
||||||
|
|
||||||
|
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
const handleSystemChange = (): void => {
|
||||||
|
if (this.preference !== "system") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.active = systemTheme();
|
||||||
|
applyTheme(this.active, this.preference, this.preset);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof mediaQuery.addEventListener === "function") {
|
||||||
|
mediaQuery.addEventListener("change", handleSystemChange);
|
||||||
|
} else if (typeof mediaQuery.addListener === "function") {
|
||||||
|
mediaQuery.addListener(handleSystemChange);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
setPreference(preference: ThemePreference): void {
|
setPreference(preference: ThemePreference): void {
|
||||||
this.preference = preference;
|
this.preference = preference;
|
||||||
this.active = preference === "system" ? systemTheme() : preference;
|
this.active = preference === "system" ? systemTheme() : preference;
|
||||||
applyTheme(this.active, this.preference);
|
applyTheme(this.active, this.preference, this.preset);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, preference);
|
localStorage.setItem(STORAGE_KEY, preference);
|
||||||
|
|
@ -47,5 +89,19 @@ export const useThemeStore = defineStore("theme", {
|
||||||
// Ignore storage failures in private contexts.
|
// Ignore storage failures in private contexts.
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
setPreset(preset: ThemePresetId): void {
|
||||||
|
if (!isThemePresetId(preset)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.preset = preset;
|
||||||
|
applyTheme(this.active, this.preference, this.preset);
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PRESET_STORAGE_KEY, preset);
|
||||||
|
} catch (_err) {
|
||||||
|
// Ignore storage failures in private contexts.
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
87
frontend/src/stores/user_settings.ts
Normal file
87
frontend/src/stores/user_settings.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
|
||||||
|
import { fetchSettings, type UserSettings } from "@/features/settings";
|
||||||
|
|
||||||
|
export const REQUIRED_NAV_PAGES = ["dashboard", "scholars", "settings"] as const;
|
||||||
|
export const DEFAULT_NAV_VISIBLE_PAGES = [
|
||||||
|
"dashboard",
|
||||||
|
"scholars",
|
||||||
|
"publications",
|
||||||
|
"settings",
|
||||||
|
"style-guide",
|
||||||
|
"runs",
|
||||||
|
"users",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const ALLOWED_NAV_PAGES = new Set<string>(DEFAULT_NAV_VISIBLE_PAGES);
|
||||||
|
const REQUIRED_NAV_PAGES_SET = new Set<string>(REQUIRED_NAV_PAGES);
|
||||||
|
|
||||||
|
function normalizeNavVisiblePages(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [...DEFAULT_NAV_VISIBLE_PAGES];
|
||||||
|
}
|
||||||
|
|
||||||
|
const deduped: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const candidate of value) {
|
||||||
|
if (typeof candidate !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageId = candidate.trim();
|
||||||
|
if (!ALLOWED_NAV_PAGES.has(pageId) || seen.has(pageId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(pageId);
|
||||||
|
deduped.push(pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const requiredPage of REQUIRED_NAV_PAGES) {
|
||||||
|
if (!seen.has(requiredPage)) {
|
||||||
|
deduped.push(requiredPage);
|
||||||
|
seen.add(requiredPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return deduped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUserSettingsStore = defineStore("userSettings", {
|
||||||
|
state: () => ({
|
||||||
|
navVisiblePages: [...DEFAULT_NAV_VISIBLE_PAGES] as string[],
|
||||||
|
}),
|
||||||
|
getters: {
|
||||||
|
visiblePageSet: (state) => new Set(state.navVisiblePages),
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
setNavVisiblePages(value: unknown): void {
|
||||||
|
this.navVisiblePages = normalizeNavVisiblePages(value);
|
||||||
|
},
|
||||||
|
applySettings(settings: UserSettings): void {
|
||||||
|
this.setNavVisiblePages(settings.nav_visible_pages);
|
||||||
|
},
|
||||||
|
reset(): void {
|
||||||
|
this.navVisiblePages = [...DEFAULT_NAV_VISIBLE_PAGES];
|
||||||
|
},
|
||||||
|
isPageVisible(pageId: string): boolean {
|
||||||
|
if (REQUIRED_NAV_PAGES_SET.has(pageId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return this.visiblePageSet.has(pageId);
|
||||||
|
},
|
||||||
|
async bootstrap(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const settings = await fetchSettings();
|
||||||
|
this.applySettings(settings);
|
||||||
|
} catch {
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function normalizeUserNavVisiblePages(value: unknown): string[] {
|
||||||
|
return normalizeNavVisiblePages(value);
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,143 @@
|
||||||
@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Space+Grotesk:wght@500;600;700&display=swap");
|
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--color-brand-50: 243 246 251;
|
||||||
|
--color-brand-100: 231 237 247;
|
||||||
|
--color-brand-200: 206 220 238;
|
||||||
|
--color-brand-300: 175 198 223;
|
||||||
|
--color-brand-400: 142 170 200;
|
||||||
|
--color-brand-500: 111 143 172;
|
||||||
|
--color-brand-600: 90 116 144;
|
||||||
|
--color-brand-700: 73 95 119;
|
||||||
|
--color-brand-800: 63 80 99;
|
||||||
|
--color-brand-900: 55 69 84;
|
||||||
|
--color-brand-950: 36 45 56;
|
||||||
|
|
||||||
|
--color-info-50: 240 249 255;
|
||||||
|
--color-info-100: 224 242 254;
|
||||||
|
--color-info-200: 186 230 253;
|
||||||
|
--color-info-300: 125 211 252;
|
||||||
|
--color-info-400: 56 189 248;
|
||||||
|
--color-info-500: 14 165 233;
|
||||||
|
--color-info-600: 2 132 199;
|
||||||
|
--color-info-700: 3 105 161;
|
||||||
|
--color-info-800: 7 89 133;
|
||||||
|
--color-info-900: 12 74 110;
|
||||||
|
--color-info-950: 8 47 73;
|
||||||
|
|
||||||
|
--color-success-50: 236 253 245;
|
||||||
|
--color-success-100: 209 250 229;
|
||||||
|
--color-success-200: 167 243 208;
|
||||||
|
--color-success-300: 110 231 183;
|
||||||
|
--color-success-400: 52 211 153;
|
||||||
|
--color-success-500: 16 185 129;
|
||||||
|
--color-success-600: 5 150 105;
|
||||||
|
--color-success-700: 4 120 87;
|
||||||
|
--color-success-800: 6 95 70;
|
||||||
|
--color-success-900: 6 78 59;
|
||||||
|
--color-success-950: 2 44 34;
|
||||||
|
|
||||||
|
--color-warning-50: 255 251 235;
|
||||||
|
--color-warning-100: 254 243 199;
|
||||||
|
--color-warning-200: 253 230 138;
|
||||||
|
--color-warning-300: 252 211 77;
|
||||||
|
--color-warning-400: 251 191 36;
|
||||||
|
--color-warning-500: 245 158 11;
|
||||||
|
--color-warning-600: 217 119 6;
|
||||||
|
--color-warning-700: 180 83 9;
|
||||||
|
--color-warning-800: 146 64 14;
|
||||||
|
--color-warning-900: 120 53 15;
|
||||||
|
--color-warning-950: 69 26 3;
|
||||||
|
|
||||||
|
--color-danger-50: 255 241 242;
|
||||||
|
--color-danger-100: 255 228 230;
|
||||||
|
--color-danger-200: 254 205 211;
|
||||||
|
--color-danger-300: 253 164 175;
|
||||||
|
--color-danger-400: 251 113 133;
|
||||||
|
--color-danger-500: 244 63 94;
|
||||||
|
--color-danger-600: 225 29 72;
|
||||||
|
--color-danger-700: 190 18 60;
|
||||||
|
--color-danger-800: 159 18 57;
|
||||||
|
--color-danger-900: 136 19 55;
|
||||||
|
--color-danger-950: 76 5 25;
|
||||||
|
|
||||||
|
--theme-surface-app: 243 246 251;
|
||||||
|
--theme-surface-nav: 255 255 255;
|
||||||
|
--theme-surface-nav-active: 73 95 119;
|
||||||
|
--theme-surface-card: 255 255 255;
|
||||||
|
--theme-surface-card-muted: 231 237 247;
|
||||||
|
--theme-surface-table: 255 255 255;
|
||||||
|
--theme-surface-table-header: 243 246 251;
|
||||||
|
--theme-surface-input: 255 255 255;
|
||||||
|
--theme-surface-overlay: 15 23 42;
|
||||||
|
|
||||||
|
--theme-text-primary: 31 41 55;
|
||||||
|
--theme-text-secondary: 71 85 105;
|
||||||
|
--theme-text-muted: 100 116 139;
|
||||||
|
--theme-text-inverse: 248 250 252;
|
||||||
|
--theme-text-link: 73 95 119;
|
||||||
|
|
||||||
|
--theme-border-default: 206 220 238;
|
||||||
|
--theme-border-strong: 175 198 223;
|
||||||
|
--theme-border-subtle: 231 237 247;
|
||||||
|
--theme-border-interactive: 142 170 200;
|
||||||
|
|
||||||
|
--theme-focus-ring: 111 143 172;
|
||||||
|
--theme-focus-ring-offset: 243 246 251;
|
||||||
|
|
||||||
|
--theme-action-primary-bg: 90 116 144;
|
||||||
|
--theme-action-primary-border: 90 116 144;
|
||||||
|
--theme-action-primary-text: 248 250 252;
|
||||||
|
--theme-action-primary-hover-bg: 73 95 119;
|
||||||
|
--theme-action-primary-hover-border: 73 95 119;
|
||||||
|
--theme-action-primary-hover-text: 255 255 255;
|
||||||
|
|
||||||
|
--theme-action-secondary-bg: 238 242 247;
|
||||||
|
--theme-action-secondary-border: 206 220 238;
|
||||||
|
--theme-action-secondary-text: 51 65 85;
|
||||||
|
--theme-action-secondary-hover-bg: 226 232 240;
|
||||||
|
--theme-action-secondary-hover-border: 175 198 223;
|
||||||
|
--theme-action-secondary-hover-text: 31 41 55;
|
||||||
|
|
||||||
|
--theme-action-ghost-bg: 243 246 251;
|
||||||
|
--theme-action-ghost-border: 206 220 238;
|
||||||
|
--theme-action-ghost-text: 73 95 119;
|
||||||
|
--theme-action-ghost-hover-bg: 231 237 247;
|
||||||
|
--theme-action-ghost-hover-border: 175 198 223;
|
||||||
|
--theme-action-ghost-hover-text: 55 69 84;
|
||||||
|
|
||||||
|
--theme-action-danger-bg: 255 228 230;
|
||||||
|
--theme-action-danger-border: 254 205 211;
|
||||||
|
--theme-action-danger-text: 190 18 60;
|
||||||
|
--theme-action-danger-hover-bg: 254 205 211;
|
||||||
|
--theme-action-danger-hover-border: 253 164 175;
|
||||||
|
--theme-action-danger-hover-text: 159 18 57;
|
||||||
|
|
||||||
|
--theme-state-info-bg: 240 249 255;
|
||||||
|
--theme-state-info-border: 186 230 253;
|
||||||
|
--theme-state-info-text: 7 89 133;
|
||||||
|
--theme-state-success-bg: 236 253 245;
|
||||||
|
--theme-state-success-border: 167 243 208;
|
||||||
|
--theme-state-success-text: 4 120 87;
|
||||||
|
--theme-state-warning-bg: 255 251 235;
|
||||||
|
--theme-state-warning-border: 253 230 138;
|
||||||
|
--theme-state-warning-text: 146 64 14;
|
||||||
|
--theme-state-danger-bg: 255 241 242;
|
||||||
|
--theme-state-danger-border: 254 205 211;
|
||||||
|
--theme-state-danger-text: 190 18 60;
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
#app {
|
#app {
|
||||||
@apply min-h-full overflow-x-clip;
|
@apply h-full w-full max-w-full overflow-x-clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-zinc-100 text-zinc-900 antialiased transition-colors duration-300 dark:bg-zinc-950 dark:text-zinc-100;
|
@apply overflow-hidden bg-surface-app text-ink-primary antialiased transition-colors duration-300;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
|
|
@ -36,27 +161,27 @@
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.skip-link {
|
.skip-link {
|
||||||
@apply sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-3 focus:z-50 focus:rounded-md focus:bg-zinc-900 focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:text-white focus:outline-none dark:focus:bg-brand-400 dark:focus:text-zinc-950;
|
@apply sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-3 focus:z-50 focus:rounded-md focus:bg-action-primary-bg focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:text-action-primary-text focus:outline-none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-stack {
|
.page-stack {
|
||||||
@apply space-y-6;
|
@apply flex min-h-0 flex-col gap-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
@apply font-display text-3xl font-semibold tracking-tight text-zinc-900 sm:text-4xl dark:text-zinc-100;
|
@apply font-display text-3xl font-semibold tracking-tight text-ink-primary sm:text-4xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-subtitle {
|
.page-subtitle {
|
||||||
@apply text-sm leading-relaxed text-zinc-600 dark:text-zinc-400;
|
@apply text-sm leading-relaxed text-ink-secondary;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-secondary {
|
.text-secondary {
|
||||||
@apply text-zinc-600 dark:text-zinc-400;
|
@apply text-ink-secondary;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-muted {
|
.text-muted {
|
||||||
@apply text-zinc-500 dark:text-zinc-500;
|
@apply text-ink-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row {
|
.row {
|
||||||
|
|
@ -64,6 +189,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-inline {
|
.link-inline {
|
||||||
@apply rounded-sm text-brand-700 underline-offset-4 transition hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:text-brand-300 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950;
|
@apply rounded-sm text-ink-link underline-offset-4 transition hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
89
frontend/src/theme/THEME_PHASE0_INVENTORY.md
Normal file
89
frontend/src/theme/THEME_PHASE0_INVENTORY.md
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
# Theme Inventory (Phase 0)
|
||||||
|
|
||||||
|
This file captures the semantic color system baseline for the theme refactor.
|
||||||
|
|
||||||
|
## Token Domains
|
||||||
|
|
||||||
|
- `scale`:
|
||||||
|
- `brand` (`50..950`)
|
||||||
|
- `info` (`50..950`)
|
||||||
|
- `success` (`50..950`)
|
||||||
|
- `warning` (`50..950`)
|
||||||
|
- `danger` (`50..950`)
|
||||||
|
- `surface`:
|
||||||
|
- `app`
|
||||||
|
- `nav`
|
||||||
|
- `nav_active`
|
||||||
|
- `card`
|
||||||
|
- `card_muted`
|
||||||
|
- `table`
|
||||||
|
- `table_header`
|
||||||
|
- `input`
|
||||||
|
- `overlay`
|
||||||
|
- `text`:
|
||||||
|
- `primary`
|
||||||
|
- `secondary`
|
||||||
|
- `muted`
|
||||||
|
- `inverse`
|
||||||
|
- `link`
|
||||||
|
- `border`:
|
||||||
|
- `default`
|
||||||
|
- `strong`
|
||||||
|
- `subtle`
|
||||||
|
- `interactive`
|
||||||
|
- `focus`:
|
||||||
|
- `ring`
|
||||||
|
- `ring_offset`
|
||||||
|
- `action` variants (`primary`, `secondary`, `ghost`, `danger`):
|
||||||
|
- `bg`
|
||||||
|
- `border`
|
||||||
|
- `text`
|
||||||
|
- `hover_bg`
|
||||||
|
- `hover_border`
|
||||||
|
- `hover_text`
|
||||||
|
- `state` variants (`info`, `success`, `warning`, `danger`):
|
||||||
|
- `bg`
|
||||||
|
- `border`
|
||||||
|
- `text`
|
||||||
|
|
||||||
|
## Theme Sources
|
||||||
|
|
||||||
|
Theme presets are dynamically loaded from `frontend/src/theme/presets/*.{json,js}`.
|
||||||
|
|
||||||
|
Current preset files:
|
||||||
|
|
||||||
|
- `frontend/src/theme/presets/parchment.js`
|
||||||
|
- `frontend/src/theme/presets/lilac.js`
|
||||||
|
- `frontend/src/theme/presets/dune.js`
|
||||||
|
- `frontend/src/theme/presets/oatmeal.js`
|
||||||
|
- `frontend/src/theme/presets/scholarly.json`
|
||||||
|
- `frontend/src/theme/presets/graphite.json`
|
||||||
|
- `frontend/src/theme/presets/tide.json`
|
||||||
|
|
||||||
|
Each preset contains both `light` and `dark` mode token definitions.
|
||||||
|
|
||||||
|
## Adoption Status (Phase 0-3 complete baseline)
|
||||||
|
|
||||||
|
Tokenized foundation components:
|
||||||
|
|
||||||
|
- `AppButton`
|
||||||
|
- `AppCard`
|
||||||
|
- `AppCheckbox`
|
||||||
|
- `AppEmptyState`
|
||||||
|
- `AppHelpHint`
|
||||||
|
- `AppInput`
|
||||||
|
- `AppSelect`
|
||||||
|
- `AppTable`
|
||||||
|
- `AppModal`
|
||||||
|
- `AppHeader`
|
||||||
|
- `AppNav`
|
||||||
|
- `AppAlert`
|
||||||
|
- `AppBadge`
|
||||||
|
- `RunStatusBadge`
|
||||||
|
- `QueueHealthBadge`
|
||||||
|
|
||||||
|
Hardening in place:
|
||||||
|
|
||||||
|
- Frontend token policy check script: `frontend/scripts/check_theme_tokens.mjs`
|
||||||
|
- CI enforcement step in `frontend-quality` workflow
|
||||||
|
- Theme preset integrity tests in `frontend/src/theme/presets.test.ts`
|
||||||
69
frontend/src/theme/presets.test.ts
Normal file
69
frontend/src/theme/presets.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
COLOR_SCALE_STEPS,
|
||||||
|
THEME_ACTION_KEYS,
|
||||||
|
THEME_ACTION_VARIANTS,
|
||||||
|
THEME_BORDER_KEYS,
|
||||||
|
THEME_COLOR_TYPES,
|
||||||
|
THEME_FOCUS_KEYS,
|
||||||
|
THEME_PRESETS,
|
||||||
|
THEME_STATE_KEYS,
|
||||||
|
THEME_STATE_VARIANTS,
|
||||||
|
THEME_SURFACE_KEYS,
|
||||||
|
THEME_TEXT_KEYS,
|
||||||
|
THEME_MODES,
|
||||||
|
themeCssVariables,
|
||||||
|
} from "@/theme/presets";
|
||||||
|
|
||||||
|
const RGB_TRIPLET_PATTERN = /^\d{1,3} \d{1,3} \d{1,3}$/;
|
||||||
|
|
||||||
|
describe("theme presets", () => {
|
||||||
|
it("exposes unique preset identifiers", () => {
|
||||||
|
const ids = THEME_PRESETS.map((preset) => preset.id);
|
||||||
|
expect(ids.length).toBeGreaterThan(0);
|
||||||
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds complete CSS variable maps for each preset and mode", () => {
|
||||||
|
const expectedCount =
|
||||||
|
THEME_COLOR_TYPES.length * COLOR_SCALE_STEPS.length +
|
||||||
|
THEME_SURFACE_KEYS.length +
|
||||||
|
THEME_TEXT_KEYS.length +
|
||||||
|
THEME_BORDER_KEYS.length +
|
||||||
|
THEME_FOCUS_KEYS.length +
|
||||||
|
THEME_ACTION_VARIANTS.length * THEME_ACTION_KEYS.length +
|
||||||
|
THEME_STATE_VARIANTS.length * THEME_STATE_KEYS.length;
|
||||||
|
|
||||||
|
for (const preset of THEME_PRESETS) {
|
||||||
|
for (const mode of THEME_MODES) {
|
||||||
|
const cssVars = themeCssVariables(preset.id, mode);
|
||||||
|
expect(Object.keys(cssVars)).toHaveLength(expectedCount);
|
||||||
|
expect(cssVars["--theme-surface-app"]).toBeDefined();
|
||||||
|
expect(cssVars["--theme-text-primary"]).toBeDefined();
|
||||||
|
expect(cssVars["--theme-border-default"]).toBeDefined();
|
||||||
|
expect(cssVars["--theme-focus-ring"]).toBeDefined();
|
||||||
|
expect(cssVars["--theme-action-primary-bg"]).toBeDefined();
|
||||||
|
expect(cssVars["--theme-state-warning-text"]).toBeDefined();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts all theme variables to RGB triplets", () => {
|
||||||
|
for (const preset of THEME_PRESETS) {
|
||||||
|
for (const mode of THEME_MODES) {
|
||||||
|
const cssVars = themeCssVariables(preset.id, mode);
|
||||||
|
for (const value of Object.values(cssVars)) {
|
||||||
|
expect(value).toMatch(RGB_TRIPLET_PATTERN);
|
||||||
|
const [red, green, blue] = value.split(" ").map(Number);
|
||||||
|
expect(red).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(red).toBeLessThanOrEqual(255);
|
||||||
|
expect(green).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(green).toBeLessThanOrEqual(255);
|
||||||
|
expect(blue).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(blue).toBeLessThanOrEqual(255);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
274
frontend/src/theme/presets.ts
Normal file
274
frontend/src/theme/presets.ts
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
export const COLOR_SCALE_STEPS = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900", "950"] as const;
|
||||||
|
export const THEME_MODES = ["light", "dark"] as const;
|
||||||
|
export const THEME_COLOR_TYPES = ["brand", "info", "success", "warning", "danger"] as const;
|
||||||
|
export const THEME_SURFACE_KEYS = [
|
||||||
|
"app",
|
||||||
|
"nav",
|
||||||
|
"nav_active",
|
||||||
|
"card",
|
||||||
|
"card_muted",
|
||||||
|
"table",
|
||||||
|
"table_header",
|
||||||
|
"input",
|
||||||
|
"overlay",
|
||||||
|
] as const;
|
||||||
|
export const THEME_TEXT_KEYS = ["primary", "secondary", "muted", "inverse", "link"] as const;
|
||||||
|
export const THEME_BORDER_KEYS = ["default", "strong", "subtle", "interactive"] as const;
|
||||||
|
export const THEME_FOCUS_KEYS = ["ring", "ring_offset"] as const;
|
||||||
|
export const THEME_ACTION_VARIANTS = ["primary", "secondary", "ghost", "danger"] as const;
|
||||||
|
export const THEME_ACTION_KEYS = ["bg", "border", "text", "hover_bg", "hover_border", "hover_text"] as const;
|
||||||
|
export const THEME_STATE_VARIANTS = ["info", "success", "warning", "danger"] as const;
|
||||||
|
export const THEME_STATE_KEYS = ["bg", "border", "text"] as const;
|
||||||
|
|
||||||
|
export type ColorScaleStep = (typeof COLOR_SCALE_STEPS)[number];
|
||||||
|
export type ThemeMode = (typeof THEME_MODES)[number];
|
||||||
|
export type ThemeColorType = (typeof THEME_COLOR_TYPES)[number];
|
||||||
|
export type ThemeSurfaceKey = (typeof THEME_SURFACE_KEYS)[number];
|
||||||
|
export type ThemeTextKey = (typeof THEME_TEXT_KEYS)[number];
|
||||||
|
export type ThemeBorderKey = (typeof THEME_BORDER_KEYS)[number];
|
||||||
|
export type ThemeFocusKey = (typeof THEME_FOCUS_KEYS)[number];
|
||||||
|
export type ThemeActionVariant = (typeof THEME_ACTION_VARIANTS)[number];
|
||||||
|
export type ThemeActionKey = (typeof THEME_ACTION_KEYS)[number];
|
||||||
|
export type ThemeStateVariant = (typeof THEME_STATE_VARIANTS)[number];
|
||||||
|
export type ThemeStateKey = (typeof THEME_STATE_KEYS)[number];
|
||||||
|
export type ThemePresetId = string;
|
||||||
|
|
||||||
|
export type ThemeColorScale = Record<ColorScaleStep, string>;
|
||||||
|
export type ThemeScaleTokenMap = Record<ThemeColorType, ThemeColorScale>;
|
||||||
|
export type ThemeSurfaceTokens = Record<ThemeSurfaceKey, string>;
|
||||||
|
export type ThemeTextTokens = Record<ThemeTextKey, string>;
|
||||||
|
export type ThemeBorderTokens = Record<ThemeBorderKey, string>;
|
||||||
|
export type ThemeFocusTokens = Record<ThemeFocusKey, string>;
|
||||||
|
export type ThemeActionTokens = Record<ThemeActionKey, string>;
|
||||||
|
export type ThemeActionTokenMap = Record<ThemeActionVariant, ThemeActionTokens>;
|
||||||
|
export type ThemeStateTokens = Record<ThemeStateKey, string>;
|
||||||
|
export type ThemeStateTokenMap = Record<ThemeStateVariant, ThemeStateTokens>;
|
||||||
|
|
||||||
|
export interface ThemePresetOption {
|
||||||
|
id: ThemePresetId;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeModeDefinition {
|
||||||
|
scale: ThemeScaleTokenMap;
|
||||||
|
surface: ThemeSurfaceTokens;
|
||||||
|
text: ThemeTextTokens;
|
||||||
|
border: ThemeBorderTokens;
|
||||||
|
focus: ThemeFocusTokens;
|
||||||
|
action: ThemeActionTokenMap;
|
||||||
|
state: ThemeStateTokenMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemePresetDefinition extends ThemePresetOption {
|
||||||
|
modes: Record<ThemeMode, ThemeModeDefinition>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
||||||
|
const presetModules = import.meta.glob<{ default?: unknown }>(
|
||||||
|
"./presets/*.{json,js}",
|
||||||
|
{ eager: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function asObject(value: unknown, path: string): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw new Error(`Invalid theme schema at ${path}: expected object.`);
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(value: unknown, path: string): string {
|
||||||
|
if (typeof value !== "string" || value.trim().length === 0) {
|
||||||
|
throw new Error(`Invalid theme schema at ${path}: expected non-empty string.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asHexColor(value: unknown, path: string): string {
|
||||||
|
const color = asString(value, path).trim();
|
||||||
|
if (!HEX_COLOR_PATTERN.test(color)) {
|
||||||
|
throw new Error(`Invalid theme color at ${path}: expected #RGB or #RRGGBB.`);
|
||||||
|
}
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRecordByKeys<K extends readonly string[]>(
|
||||||
|
source: unknown,
|
||||||
|
keys: K,
|
||||||
|
path: string,
|
||||||
|
): Record<K[number], string> {
|
||||||
|
const record = asObject(source, path);
|
||||||
|
const result = {} as Record<K[number], string>;
|
||||||
|
|
||||||
|
for (const key of keys as readonly K[number][]) {
|
||||||
|
result[key] = asHexColor(record[key], `${path}.${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readScaleTokens(source: unknown, path: string): ThemeScaleTokenMap {
|
||||||
|
const record = asObject(source, path);
|
||||||
|
return {
|
||||||
|
brand: readRecordByKeys(record.brand, COLOR_SCALE_STEPS, `${path}.brand`),
|
||||||
|
info: readRecordByKeys(record.info, COLOR_SCALE_STEPS, `${path}.info`),
|
||||||
|
success: readRecordByKeys(record.success, COLOR_SCALE_STEPS, `${path}.success`),
|
||||||
|
warning: readRecordByKeys(record.warning, COLOR_SCALE_STEPS, `${path}.warning`),
|
||||||
|
danger: readRecordByKeys(record.danger, COLOR_SCALE_STEPS, `${path}.danger`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readActionTokens(source: unknown, path: string): ThemeActionTokenMap {
|
||||||
|
const record = asObject(source, path);
|
||||||
|
return {
|
||||||
|
primary: readRecordByKeys(record.primary, THEME_ACTION_KEYS, `${path}.primary`),
|
||||||
|
secondary: readRecordByKeys(record.secondary, THEME_ACTION_KEYS, `${path}.secondary`),
|
||||||
|
ghost: readRecordByKeys(record.ghost, THEME_ACTION_KEYS, `${path}.ghost`),
|
||||||
|
danger: readRecordByKeys(record.danger, THEME_ACTION_KEYS, `${path}.danger`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStateTokens(source: unknown, path: string): ThemeStateTokenMap {
|
||||||
|
const record = asObject(source, path);
|
||||||
|
return {
|
||||||
|
info: readRecordByKeys(record.info, THEME_STATE_KEYS, `${path}.info`),
|
||||||
|
success: readRecordByKeys(record.success, THEME_STATE_KEYS, `${path}.success`),
|
||||||
|
warning: readRecordByKeys(record.warning, THEME_STATE_KEYS, `${path}.warning`),
|
||||||
|
danger: readRecordByKeys(record.danger, THEME_STATE_KEYS, `${path}.danger`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readModeTokens(source: unknown, path: string): ThemeModeDefinition {
|
||||||
|
const record = asObject(source, path);
|
||||||
|
return {
|
||||||
|
scale: readScaleTokens(record.scale, `${path}.scale`),
|
||||||
|
surface: readRecordByKeys(record.surface, THEME_SURFACE_KEYS, `${path}.surface`),
|
||||||
|
text: readRecordByKeys(record.text, THEME_TEXT_KEYS, `${path}.text`),
|
||||||
|
border: readRecordByKeys(record.border, THEME_BORDER_KEYS, `${path}.border`),
|
||||||
|
focus: readRecordByKeys(record.focus, THEME_FOCUS_KEYS, `${path}.focus`),
|
||||||
|
action: readActionTokens(record.action, `${path}.action`),
|
||||||
|
state: readStateTokens(record.state, `${path}.state`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateThemePreset(source: unknown, sourcePath: string): ThemePresetDefinition {
|
||||||
|
const record = asObject(source, sourcePath);
|
||||||
|
const id = asString(record.id, `${sourcePath}.id`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: asString(record.label, `${sourcePath}.label`),
|
||||||
|
description: asString(record.description, `${sourcePath}.description`),
|
||||||
|
modes: {
|
||||||
|
light: readModeTokens(asObject(record.modes, `${sourcePath}.modes`).light, `${sourcePath}.modes.light`),
|
||||||
|
dark: readModeTokens(asObject(record.modes, `${sourcePath}.modes`).dark, `${sourcePath}.modes.dark`),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadedPresetSources = Object.entries(presetModules)
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([sourcePath, moduleValue]) => ({
|
||||||
|
sourcePath,
|
||||||
|
source:
|
||||||
|
moduleValue && typeof moduleValue === "object" && "default" in moduleValue
|
||||||
|
? moduleValue.default
|
||||||
|
: moduleValue,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const THEME_PRESETS: ThemePresetDefinition[] = loadedPresetSources.map(
|
||||||
|
({ source, sourcePath }) => validateThemePreset(source, sourcePath),
|
||||||
|
);
|
||||||
|
|
||||||
|
const presetById = new Map<ThemePresetId, ThemePresetDefinition>();
|
||||||
|
for (const preset of THEME_PRESETS) {
|
||||||
|
if (presetById.has(preset.id)) {
|
||||||
|
throw new Error(`Duplicate theme preset id detected: ${preset.id}`);
|
||||||
|
}
|
||||||
|
presetById.set(preset.id, preset);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_THEME_PRESET: ThemePresetId = presetById.has("parchment")
|
||||||
|
? "parchment"
|
||||||
|
: (THEME_PRESETS[0]?.id ?? "parchment");
|
||||||
|
|
||||||
|
export const THEME_PRESET_OPTIONS: ThemePresetOption[] = THEME_PRESETS.map((preset) => ({
|
||||||
|
id: preset.id,
|
||||||
|
label: preset.label,
|
||||||
|
description: preset.description,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function normalizeHex(hex: string): string {
|
||||||
|
const normalized = hex.trim().replace(/^#/, "");
|
||||||
|
if (normalized.length === 3) {
|
||||||
|
return normalized
|
||||||
|
.split("")
|
||||||
|
.map((char) => `${char}${char}`)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgbTriplet(hex: string): string {
|
||||||
|
const normalized = normalizeHex(hex);
|
||||||
|
const red = parseInt(normalized.slice(0, 2), 16);
|
||||||
|
const green = parseInt(normalized.slice(2, 4), 16);
|
||||||
|
const blue = parseInt(normalized.slice(4, 6), 16);
|
||||||
|
return `${red} ${green} ${blue}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isThemePresetId(value: string): boolean {
|
||||||
|
return presetById.has(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getThemePreset(presetId: ThemePresetId): ThemePresetDefinition {
|
||||||
|
return presetById.get(presetId) ?? presetById.get(DEFAULT_THEME_PRESET) ?? THEME_PRESETS[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function themeCssVariables(presetId: ThemePresetId, mode: ThemeMode): Record<string, string> {
|
||||||
|
const preset = getThemePreset(presetId);
|
||||||
|
const selected = preset.modes[mode];
|
||||||
|
const entries: Array<[string, string]> = [];
|
||||||
|
|
||||||
|
for (const role of THEME_COLOR_TYPES) {
|
||||||
|
for (const step of COLOR_SCALE_STEPS) {
|
||||||
|
entries.push([`--color-${role}-${step}`, hexToRgbTriplet(selected.scale[role][step])]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of THEME_SURFACE_KEYS) {
|
||||||
|
entries.push([`--theme-surface-${key.replace(/_/g, "-")}`, hexToRgbTriplet(selected.surface[key])]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of THEME_TEXT_KEYS) {
|
||||||
|
entries.push([`--theme-text-${key.replace(/_/g, "-")}`, hexToRgbTriplet(selected.text[key])]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of THEME_BORDER_KEYS) {
|
||||||
|
entries.push([`--theme-border-${key.replace(/_/g, "-")}`, hexToRgbTriplet(selected.border[key])]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of THEME_FOCUS_KEYS) {
|
||||||
|
entries.push([`--theme-focus-${key.replace(/_/g, "-")}`, hexToRgbTriplet(selected.focus[key])]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const variant of THEME_ACTION_VARIANTS) {
|
||||||
|
for (const key of THEME_ACTION_KEYS) {
|
||||||
|
entries.push([
|
||||||
|
`--theme-action-${variant}-${key.replace(/_/g, "-")}`,
|
||||||
|
hexToRgbTriplet(selected.action[variant][key]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const variant of THEME_STATE_VARIANTS) {
|
||||||
|
for (const key of THEME_STATE_KEYS) {
|
||||||
|
entries.push([
|
||||||
|
`--theme-state-${variant}-${key.replace(/_/g, "-")}`,
|
||||||
|
hexToRgbTriplet(selected.state[variant][key]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.fromEntries(entries);
|
||||||
|
}
|
||||||
314
frontend/src/theme/presets/dune.js
Normal file
314
frontend/src/theme/presets/dune.js
Normal file
|
|
@ -0,0 +1,314 @@
|
||||||
|
export default {
|
||||||
|
"id": "dune",
|
||||||
|
"label": "Dune",
|
||||||
|
"description": "Sandy beige environments featuring soft mauve highlights.",
|
||||||
|
"modes": {
|
||||||
|
"light": {
|
||||||
|
"scale": {
|
||||||
|
"brand": {
|
||||||
|
"50": "#f7f5f6",
|
||||||
|
"100": "#ece7e9",
|
||||||
|
"200": "#d7cccd",
|
||||||
|
"300": "#baabb2",
|
||||||
|
"400": "#9e8893",
|
||||||
|
"500": "#856b77",
|
||||||
|
"600": "#6c5460",
|
||||||
|
"700": "#58424d",
|
||||||
|
"800": "#4a3841",
|
||||||
|
"900": "#3d3036",
|
||||||
|
"950": "#21181d"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"50": "#f4f5f8",
|
||||||
|
"100": "#e5e8ef",
|
||||||
|
"200": "#cdd4e1",
|
||||||
|
"300": "#a9b6cb",
|
||||||
|
"400": "#8496b1",
|
||||||
|
"500": "#697a98",
|
||||||
|
"600": "#53617e",
|
||||||
|
"700": "#455067",
|
||||||
|
"800": "#3a4356",
|
||||||
|
"900": "#323948",
|
||||||
|
"950": "#21252f"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"50": "#f3f7f4",
|
||||||
|
"100": "#e1ebe4",
|
||||||
|
"200": "#c4d8c9",
|
||||||
|
"300": "#9dbfa6",
|
||||||
|
"400": "#75a383",
|
||||||
|
"500": "#588567",
|
||||||
|
"600": "#446950",
|
||||||
|
"700": "#385441",
|
||||||
|
"800": "#2f4436",
|
||||||
|
"900": "#27382d",
|
||||||
|
"950": "#131f18"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"50": "#fdf7f3",
|
||||||
|
"100": "#faebe2",
|
||||||
|
"200": "#f3d2be",
|
||||||
|
"300": "#eab192",
|
||||||
|
"400": "#de8f68",
|
||||||
|
"500": "#cc784f",
|
||||||
|
"600": "#b05e3b",
|
||||||
|
"700": "#924a2f",
|
||||||
|
"800": "#783e29",
|
||||||
|
"900": "#633524",
|
||||||
|
"950": "#351a0f"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"50": "#fcf5f6",
|
||||||
|
"100": "#f8e6e9",
|
||||||
|
"200": "#efcbd1",
|
||||||
|
"300": "#e2a6b1",
|
||||||
|
"400": "#d07d8d",
|
||||||
|
"500": "#b85d6e",
|
||||||
|
"600": "#9b4556",
|
||||||
|
"700": "#823746",
|
||||||
|
"800": "#6c303c",
|
||||||
|
"900": "#5a2a33",
|
||||||
|
"950": "#31141a"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"surface": {
|
||||||
|
"app": "#efece5",
|
||||||
|
"nav": "#f7f5f0",
|
||||||
|
"nav_active": "#d7cccd",
|
||||||
|
"card": "#f7f5f0",
|
||||||
|
"card_muted": "#e6e3dd",
|
||||||
|
"table": "#f7f5f0",
|
||||||
|
"table_header": "#efece5",
|
||||||
|
"input": "#ffffff",
|
||||||
|
"overlay": "#3d3a3b"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"primary": "#3d3a3b",
|
||||||
|
"secondary": "#757072",
|
||||||
|
"muted": "#9c9699",
|
||||||
|
"inverse": "#f7f5f0",
|
||||||
|
"link": "#6c5460"
|
||||||
|
},
|
||||||
|
"border": {
|
||||||
|
"default": "#e6e3dd",
|
||||||
|
"strong": "#d1cdc6",
|
||||||
|
"subtle": "#f2f0eb",
|
||||||
|
"interactive": "#baabb2"
|
||||||
|
},
|
||||||
|
"focus": {
|
||||||
|
"ring": "#9e8893",
|
||||||
|
"ring_offset": "#efece5"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"primary": {
|
||||||
|
"bg": "#9e8893",
|
||||||
|
"border": "#9e8893",
|
||||||
|
"text": "#f7f5f0",
|
||||||
|
"hover_bg": "#856b77",
|
||||||
|
"hover_border": "#856b77",
|
||||||
|
"hover_text": "#ffffff"
|
||||||
|
},
|
||||||
|
"secondary": {
|
||||||
|
"bg": "#e6e3dd",
|
||||||
|
"border": "#d1cdc6",
|
||||||
|
"text": "#3d3a3b",
|
||||||
|
"hover_bg": "#d8d5ce",
|
||||||
|
"hover_border": "#bcbbb5",
|
||||||
|
"hover_text": "#1f1d1e"
|
||||||
|
},
|
||||||
|
"ghost": {
|
||||||
|
"bg": "#efece5",
|
||||||
|
"border": "#e6e3dd",
|
||||||
|
"text": "#6c5460",
|
||||||
|
"hover_bg": "#e6e3dd",
|
||||||
|
"hover_border": "#d1cdc6",
|
||||||
|
"hover_text": "#3d3a3b"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"bg": "#f8e6e9",
|
||||||
|
"border": "#efcbd1",
|
||||||
|
"text": "#823746",
|
||||||
|
"hover_bg": "#efcbd1",
|
||||||
|
"hover_border": "#e2a6b1",
|
||||||
|
"hover_text": "#9b4556"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"info": {
|
||||||
|
"bg": "#e5e8ef",
|
||||||
|
"border": "#cdd4e1",
|
||||||
|
"text": "#455067"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"bg": "#e1ebe4",
|
||||||
|
"border": "#c4d8c9",
|
||||||
|
"text": "#385441"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"bg": "#faebe2",
|
||||||
|
"border": "#f3d2be",
|
||||||
|
"text": "#924a2f"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"bg": "#f8e6e9",
|
||||||
|
"border": "#efcbd1",
|
||||||
|
"text": "#823746"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dark": {
|
||||||
|
"scale": {
|
||||||
|
"brand": {
|
||||||
|
"50": "#f7f5f6",
|
||||||
|
"100": "#ece7e9",
|
||||||
|
"200": "#d7cccd",
|
||||||
|
"300": "#baabb2",
|
||||||
|
"400": "#9e8893",
|
||||||
|
"500": "#856b77",
|
||||||
|
"600": "#6c5460",
|
||||||
|
"700": "#58424d",
|
||||||
|
"800": "#4a3841",
|
||||||
|
"900": "#3d3036",
|
||||||
|
"950": "#21181d"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"50": "#f4f5f8",
|
||||||
|
"100": "#e5e8ef",
|
||||||
|
"200": "#cdd4e1",
|
||||||
|
"300": "#a9b6cb",
|
||||||
|
"400": "#8496b1",
|
||||||
|
"500": "#697a98",
|
||||||
|
"600": "#53617e",
|
||||||
|
"700": "#455067",
|
||||||
|
"800": "#3a4356",
|
||||||
|
"900": "#323948",
|
||||||
|
"950": "#21252f"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"50": "#f3f7f4",
|
||||||
|
"100": "#e1ebe4",
|
||||||
|
"200": "#c4d8c9",
|
||||||
|
"300": "#9dbfa6",
|
||||||
|
"400": "#75a383",
|
||||||
|
"500": "#588567",
|
||||||
|
"600": "#446950",
|
||||||
|
"700": "#385441",
|
||||||
|
"800": "#2f4436",
|
||||||
|
"900": "#27382d",
|
||||||
|
"950": "#131f18"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"50": "#fdf7f3",
|
||||||
|
"100": "#faebe2",
|
||||||
|
"200": "#f3d2be",
|
||||||
|
"300": "#eab192",
|
||||||
|
"400": "#de8f68",
|
||||||
|
"500": "#cc784f",
|
||||||
|
"600": "#b05e3b",
|
||||||
|
"700": "#924a2f",
|
||||||
|
"800": "#783e29",
|
||||||
|
"900": "#633524",
|
||||||
|
"950": "#351a0f"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"50": "#fcf5f6",
|
||||||
|
"100": "#f8e6e9",
|
||||||
|
"200": "#efcbd1",
|
||||||
|
"300": "#e2a6b1",
|
||||||
|
"400": "#d07d8d",
|
||||||
|
"500": "#b85d6e",
|
||||||
|
"600": "#9b4556",
|
||||||
|
"700": "#823746",
|
||||||
|
"800": "#6c303c",
|
||||||
|
"900": "#5a2a33",
|
||||||
|
"950": "#31141a"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"surface": {
|
||||||
|
"app": "#3d3a3b",
|
||||||
|
"nav": "#4a4748",
|
||||||
|
"nav_active": "#6c5460",
|
||||||
|
"card": "#4a4748",
|
||||||
|
"card_muted": "#575354",
|
||||||
|
"table": "#4a4748",
|
||||||
|
"table_header": "#575354",
|
||||||
|
"input": "#312e2f",
|
||||||
|
"overlay": "#1f1d1e"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"primary": "#e6e3e4",
|
||||||
|
"secondary": "#b8b4b6",
|
||||||
|
"muted": "#918e8f",
|
||||||
|
"inverse": "#3d3a3b",
|
||||||
|
"link": "#baabb2"
|
||||||
|
},
|
||||||
|
"border": {
|
||||||
|
"default": "#575354",
|
||||||
|
"strong": "#696466",
|
||||||
|
"subtle": "#474345",
|
||||||
|
"interactive": "#9e8893"
|
||||||
|
},
|
||||||
|
"focus": {
|
||||||
|
"ring": "#9e8893",
|
||||||
|
"ring_offset": "#3d3a3b"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"primary": {
|
||||||
|
"bg": "#9e8893",
|
||||||
|
"border": "#9e8893",
|
||||||
|
"text": "#1f1d1e",
|
||||||
|
"hover_bg": "#baabb2",
|
||||||
|
"hover_border": "#baabb2",
|
||||||
|
"hover_text": "#1f1d1e"
|
||||||
|
},
|
||||||
|
"secondary": {
|
||||||
|
"bg": "#575354",
|
||||||
|
"border": "#696466",
|
||||||
|
"text": "#e6e3e4",
|
||||||
|
"hover_bg": "#696466",
|
||||||
|
"hover_border": "#7a7577",
|
||||||
|
"hover_text": "#ffffff"
|
||||||
|
},
|
||||||
|
"ghost": {
|
||||||
|
"bg": "#3d3a3b",
|
||||||
|
"border": "#575354",
|
||||||
|
"text": "#b8b4b6",
|
||||||
|
"hover_bg": "#575354",
|
||||||
|
"hover_border": "#696466",
|
||||||
|
"hover_text": "#ffffff"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"bg": "#5a2a33",
|
||||||
|
"border": "#6c303c",
|
||||||
|
"text": "#efcbd1",
|
||||||
|
"hover_bg": "#6c303c",
|
||||||
|
"hover_border": "#823746",
|
||||||
|
"hover_text": "#f8e6e9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"info": {
|
||||||
|
"bg": "#323948",
|
||||||
|
"border": "#455067",
|
||||||
|
"text": "#cdd4e1"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"bg": "#27382d",
|
||||||
|
"border": "#385441",
|
||||||
|
"text": "#c4d8c9"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"bg": "#633524",
|
||||||
|
"border": "#924a2f",
|
||||||
|
"text": "#f3d2be"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"bg": "#5a2a33",
|
||||||
|
"border": "#823746",
|
||||||
|
"text": "#efcbd1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
;
|
||||||
313
frontend/src/theme/presets/graphite.json
Normal file
313
frontend/src/theme/presets/graphite.json
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
{
|
||||||
|
"id": "graphite",
|
||||||
|
"label": "Graphite",
|
||||||
|
"description": "Neutral slate base with crisp indigo accents.",
|
||||||
|
"modes": {
|
||||||
|
"light": {
|
||||||
|
"scale": {
|
||||||
|
"brand": {
|
||||||
|
"50": "#f8fafc",
|
||||||
|
"100": "#f1f5f9",
|
||||||
|
"200": "#e2e8f0",
|
||||||
|
"300": "#cbd5e1",
|
||||||
|
"400": "#94a3b8",
|
||||||
|
"500": "#64748b",
|
||||||
|
"600": "#475569",
|
||||||
|
"700": "#334155",
|
||||||
|
"800": "#1e293b",
|
||||||
|
"900": "#0f172a",
|
||||||
|
"950": "#020617"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"50": "#eef2ff",
|
||||||
|
"100": "#e0e7ff",
|
||||||
|
"200": "#c7d2fe",
|
||||||
|
"300": "#a5b4fc",
|
||||||
|
"400": "#818cf8",
|
||||||
|
"500": "#6366f1",
|
||||||
|
"600": "#4f46e5",
|
||||||
|
"700": "#4338ca",
|
||||||
|
"800": "#3730a3",
|
||||||
|
"900": "#312e81",
|
||||||
|
"950": "#1e1b4b"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"50": "#ecfdf5",
|
||||||
|
"100": "#d1fae5",
|
||||||
|
"200": "#a7f3d0",
|
||||||
|
"300": "#6ee7b7",
|
||||||
|
"400": "#34d399",
|
||||||
|
"500": "#10b981",
|
||||||
|
"600": "#059669",
|
||||||
|
"700": "#047857",
|
||||||
|
"800": "#065f46",
|
||||||
|
"900": "#064e3b",
|
||||||
|
"950": "#022c22"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"50": "#fffbeb",
|
||||||
|
"100": "#fef3c7",
|
||||||
|
"200": "#fde68a",
|
||||||
|
"300": "#fcd34d",
|
||||||
|
"400": "#fbbf24",
|
||||||
|
"500": "#f59e0b",
|
||||||
|
"600": "#d97706",
|
||||||
|
"700": "#b45309",
|
||||||
|
"800": "#92400e",
|
||||||
|
"900": "#78350f",
|
||||||
|
"950": "#451a03"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"50": "#fff1f2",
|
||||||
|
"100": "#ffe4e6",
|
||||||
|
"200": "#fecdd3",
|
||||||
|
"300": "#fda4af",
|
||||||
|
"400": "#fb7185",
|
||||||
|
"500": "#f43f5e",
|
||||||
|
"600": "#e11d48",
|
||||||
|
"700": "#be123c",
|
||||||
|
"800": "#9f1239",
|
||||||
|
"900": "#881337",
|
||||||
|
"950": "#4c0519"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"surface": {
|
||||||
|
"app": "#f5f7fb",
|
||||||
|
"nav": "#ffffff",
|
||||||
|
"nav_active": "#334155",
|
||||||
|
"card": "#ffffff",
|
||||||
|
"card_muted": "#f1f5f9",
|
||||||
|
"table": "#ffffff",
|
||||||
|
"table_header": "#f8fafc",
|
||||||
|
"input": "#ffffff",
|
||||||
|
"overlay": "#0f172a"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"primary": "#111827",
|
||||||
|
"secondary": "#374151",
|
||||||
|
"muted": "#6b7280",
|
||||||
|
"inverse": "#f9fafb",
|
||||||
|
"link": "#334155"
|
||||||
|
},
|
||||||
|
"border": {
|
||||||
|
"default": "#dbe2ea",
|
||||||
|
"strong": "#cbd5e1",
|
||||||
|
"subtle": "#eef2f7",
|
||||||
|
"interactive": "#94a3b8"
|
||||||
|
},
|
||||||
|
"focus": {
|
||||||
|
"ring": "#6366f1",
|
||||||
|
"ring_offset": "#f5f7fb"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"primary": {
|
||||||
|
"bg": "#334155",
|
||||||
|
"border": "#334155",
|
||||||
|
"text": "#f9fafb",
|
||||||
|
"hover_bg": "#1e293b",
|
||||||
|
"hover_border": "#1e293b",
|
||||||
|
"hover_text": "#ffffff"
|
||||||
|
},
|
||||||
|
"secondary": {
|
||||||
|
"bg": "#f3f4f6",
|
||||||
|
"border": "#d1d5db",
|
||||||
|
"text": "#1f2937",
|
||||||
|
"hover_bg": "#e5e7eb",
|
||||||
|
"hover_border": "#cbd5e1",
|
||||||
|
"hover_text": "#111827"
|
||||||
|
},
|
||||||
|
"ghost": {
|
||||||
|
"bg": "#f8fafc",
|
||||||
|
"border": "#cbd5e1",
|
||||||
|
"text": "#334155",
|
||||||
|
"hover_bg": "#f1f5f9",
|
||||||
|
"hover_border": "#94a3b8",
|
||||||
|
"hover_text": "#1f2937"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"bg": "#ffe4e6",
|
||||||
|
"border": "#fecdd3",
|
||||||
|
"text": "#be123c",
|
||||||
|
"hover_bg": "#fecdd3",
|
||||||
|
"hover_border": "#fda4af",
|
||||||
|
"hover_text": "#9f1239"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"info": {
|
||||||
|
"bg": "#eef2ff",
|
||||||
|
"border": "#c7d2fe",
|
||||||
|
"text": "#3730a3"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"bg": "#ecfdf5",
|
||||||
|
"border": "#a7f3d0",
|
||||||
|
"text": "#047857"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"bg": "#fffbeb",
|
||||||
|
"border": "#fde68a",
|
||||||
|
"text": "#92400e"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"bg": "#fff1f2",
|
||||||
|
"border": "#fecdd3",
|
||||||
|
"text": "#be123c"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dark": {
|
||||||
|
"scale": {
|
||||||
|
"brand": {
|
||||||
|
"50": "#f8fafc",
|
||||||
|
"100": "#f1f5f9",
|
||||||
|
"200": "#e2e8f0",
|
||||||
|
"300": "#cbd5e1",
|
||||||
|
"400": "#94a3b8",
|
||||||
|
"500": "#64748b",
|
||||||
|
"600": "#475569",
|
||||||
|
"700": "#334155",
|
||||||
|
"800": "#1e293b",
|
||||||
|
"900": "#0f172a",
|
||||||
|
"950": "#020617"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"50": "#eef2ff",
|
||||||
|
"100": "#e0e7ff",
|
||||||
|
"200": "#c7d2fe",
|
||||||
|
"300": "#a5b4fc",
|
||||||
|
"400": "#818cf8",
|
||||||
|
"500": "#6366f1",
|
||||||
|
"600": "#4f46e5",
|
||||||
|
"700": "#4338ca",
|
||||||
|
"800": "#3730a3",
|
||||||
|
"900": "#312e81",
|
||||||
|
"950": "#1e1b4b"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"50": "#ecfdf5",
|
||||||
|
"100": "#d1fae5",
|
||||||
|
"200": "#a7f3d0",
|
||||||
|
"300": "#6ee7b7",
|
||||||
|
"400": "#34d399",
|
||||||
|
"500": "#10b981",
|
||||||
|
"600": "#059669",
|
||||||
|
"700": "#047857",
|
||||||
|
"800": "#065f46",
|
||||||
|
"900": "#064e3b",
|
||||||
|
"950": "#022c22"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"50": "#fffbeb",
|
||||||
|
"100": "#fef3c7",
|
||||||
|
"200": "#fde68a",
|
||||||
|
"300": "#fcd34d",
|
||||||
|
"400": "#fbbf24",
|
||||||
|
"500": "#f59e0b",
|
||||||
|
"600": "#d97706",
|
||||||
|
"700": "#b45309",
|
||||||
|
"800": "#92400e",
|
||||||
|
"900": "#78350f",
|
||||||
|
"950": "#451a03"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"50": "#fff1f2",
|
||||||
|
"100": "#ffe4e6",
|
||||||
|
"200": "#fecdd3",
|
||||||
|
"300": "#fda4af",
|
||||||
|
"400": "#fb7185",
|
||||||
|
"500": "#f43f5e",
|
||||||
|
"600": "#e11d48",
|
||||||
|
"700": "#be123c",
|
||||||
|
"800": "#9f1239",
|
||||||
|
"900": "#881337",
|
||||||
|
"950": "#4c0519"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"surface": {
|
||||||
|
"app": "#0b1120",
|
||||||
|
"nav": "#111827",
|
||||||
|
"nav_active": "#64748b",
|
||||||
|
"card": "#111827",
|
||||||
|
"card_muted": "#1f2937",
|
||||||
|
"table": "#111827",
|
||||||
|
"table_header": "#1f2937",
|
||||||
|
"input": "#0f172a",
|
||||||
|
"overlay": "#020617"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"primary": "#e5e7eb",
|
||||||
|
"secondary": "#cbd5e1",
|
||||||
|
"muted": "#94a3b8",
|
||||||
|
"inverse": "#0b1120",
|
||||||
|
"link": "#a5b4fc"
|
||||||
|
},
|
||||||
|
"border": {
|
||||||
|
"default": "#334155",
|
||||||
|
"strong": "#475569",
|
||||||
|
"subtle": "#1f2937",
|
||||||
|
"interactive": "#64748b"
|
||||||
|
},
|
||||||
|
"focus": {
|
||||||
|
"ring": "#818cf8",
|
||||||
|
"ring_offset": "#0b1120"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"primary": {
|
||||||
|
"bg": "#64748b",
|
||||||
|
"border": "#64748b",
|
||||||
|
"text": "#020617",
|
||||||
|
"hover_bg": "#94a3b8",
|
||||||
|
"hover_border": "#94a3b8",
|
||||||
|
"hover_text": "#020617"
|
||||||
|
},
|
||||||
|
"secondary": {
|
||||||
|
"bg": "#1f2937",
|
||||||
|
"border": "#475569",
|
||||||
|
"text": "#e5e7eb",
|
||||||
|
"hover_bg": "#334155",
|
||||||
|
"hover_border": "#64748b",
|
||||||
|
"hover_text": "#f8fafc"
|
||||||
|
},
|
||||||
|
"ghost": {
|
||||||
|
"bg": "#0f172a",
|
||||||
|
"border": "#334155",
|
||||||
|
"text": "#cbd5e1",
|
||||||
|
"hover_bg": "#1f2937",
|
||||||
|
"hover_border": "#475569",
|
||||||
|
"hover_text": "#f8fafc"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"bg": "#4c0519",
|
||||||
|
"border": "#881337",
|
||||||
|
"text": "#fecdd3",
|
||||||
|
"hover_bg": "#9f1239",
|
||||||
|
"hover_border": "#be123c",
|
||||||
|
"hover_text": "#fff1f2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"info": {
|
||||||
|
"bg": "#1e1b4b",
|
||||||
|
"border": "#4338ca",
|
||||||
|
"text": "#c7d2fe"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"bg": "#022c22",
|
||||||
|
"border": "#047857",
|
||||||
|
"text": "#a7f3d0"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"bg": "#451a03",
|
||||||
|
"border": "#b45309",
|
||||||
|
"text": "#fde68a"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"bg": "#4c0519",
|
||||||
|
"border": "#be123c",
|
||||||
|
"text": "#fecdd3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
313
frontend/src/theme/presets/lilac.js
Normal file
313
frontend/src/theme/presets/lilac.js
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
export default {
|
||||||
|
"id": "lilac",
|
||||||
|
"label": "Lilac",
|
||||||
|
"description": "Soft pastel lilac foundation with gentle purple interactions.",
|
||||||
|
"modes": {
|
||||||
|
"light": {
|
||||||
|
"scale": {
|
||||||
|
"brand": {
|
||||||
|
"50": "#faf8fb",
|
||||||
|
"100": "#f3ecf5",
|
||||||
|
"200": "#e5d5ea",
|
||||||
|
"300": "#d1b4db",
|
||||||
|
"400": "#b98dc8",
|
||||||
|
"500": "#9e63b0",
|
||||||
|
"600": "#844a95",
|
||||||
|
"700": "#6d397a",
|
||||||
|
"800": "#5a3164",
|
||||||
|
"900": "#4a2a51",
|
||||||
|
"950": "#2d1332"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"50": "#f2f6fa",
|
||||||
|
"100": "#e1ebf4",
|
||||||
|
"200": "#c9dceb",
|
||||||
|
"300": "#a5c4df",
|
||||||
|
"400": "#7da8cc",
|
||||||
|
"500": "#5e8ab3",
|
||||||
|
"600": "#4a6e94",
|
||||||
|
"700": "#3c5978",
|
||||||
|
"800": "#334a63",
|
||||||
|
"900": "#2b3e51",
|
||||||
|
"950": "#1a2736"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"50": "#f2f8f4",
|
||||||
|
"100": "#e0efe5",
|
||||||
|
"200": "#c3dfcb",
|
||||||
|
"300": "#9cc8a8",
|
||||||
|
"400": "#74ad84",
|
||||||
|
"500": "#558e66",
|
||||||
|
"600": "#41704e",
|
||||||
|
"700": "#355a40",
|
||||||
|
"800": "#2d4935",
|
||||||
|
"900": "#253d2d",
|
||||||
|
"950": "#132218"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"50": "#fdf8f4",
|
||||||
|
"100": "#faeee5",
|
||||||
|
"200": "#f3daca",
|
||||||
|
"300": "#e8bea5",
|
||||||
|
"400": "#d99f7c",
|
||||||
|
"500": "#c68058",
|
||||||
|
"600": "#ab623e",
|
||||||
|
"700": "#8d4e32",
|
||||||
|
"800": "#73412b",
|
||||||
|
"900": "#5e3624",
|
||||||
|
"950": "#331b10"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"50": "#fdf5f6",
|
||||||
|
"100": "#f9e7ea",
|
||||||
|
"200": "#f1d1d7",
|
||||||
|
"300": "#e4b0bc",
|
||||||
|
"400": "#d3889a",
|
||||||
|
"500": "#be6379",
|
||||||
|
"600": "#a1485d",
|
||||||
|
"700": "#85384b",
|
||||||
|
"800": "#6f3140",
|
||||||
|
"900": "#5d2b37",
|
||||||
|
"950": "#33141c"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"surface": {
|
||||||
|
"app": "#f7f5f9",
|
||||||
|
"nav": "#ffffff",
|
||||||
|
"nav_active": "#e5d5ea",
|
||||||
|
"card": "#ffffff",
|
||||||
|
"card_muted": "#f3ecf5",
|
||||||
|
"table": "#ffffff",
|
||||||
|
"table_header": "#f7f5f9",
|
||||||
|
"input": "#ffffff",
|
||||||
|
"overlay": "#2d1332"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"primary": "#2d1332",
|
||||||
|
"secondary": "#5a3164",
|
||||||
|
"muted": "#844a95",
|
||||||
|
"inverse": "#ffffff",
|
||||||
|
"link": "#844a95"
|
||||||
|
},
|
||||||
|
"border": {
|
||||||
|
"default": "#e5d5ea",
|
||||||
|
"strong": "#d1b4db",
|
||||||
|
"subtle": "#f3ecf5",
|
||||||
|
"interactive": "#b98dc8"
|
||||||
|
},
|
||||||
|
"focus": {
|
||||||
|
"ring": "#9e63b0",
|
||||||
|
"ring_offset": "#f7f5f9"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"primary": {
|
||||||
|
"bg": "#9e63b0",
|
||||||
|
"border": "#9e63b0",
|
||||||
|
"text": "#ffffff",
|
||||||
|
"hover_bg": "#844a95",
|
||||||
|
"hover_border": "#844a95",
|
||||||
|
"hover_text": "#ffffff"
|
||||||
|
},
|
||||||
|
"secondary": {
|
||||||
|
"bg": "#f3ecf5",
|
||||||
|
"border": "#d1b4db",
|
||||||
|
"text": "#2d1332",
|
||||||
|
"hover_bg": "#e5d5ea",
|
||||||
|
"hover_border": "#b98dc8",
|
||||||
|
"hover_text": "#1a0a1d"
|
||||||
|
},
|
||||||
|
"ghost": {
|
||||||
|
"bg": "#f7f5f9",
|
||||||
|
"border": "#e5d5ea",
|
||||||
|
"text": "#844a95",
|
||||||
|
"hover_bg": "#f3ecf5",
|
||||||
|
"hover_border": "#d1b4db",
|
||||||
|
"hover_text": "#2d1332"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"bg": "#f9e7ea",
|
||||||
|
"border": "#f1d1d7",
|
||||||
|
"text": "#85384b",
|
||||||
|
"hover_bg": "#f1d1d7",
|
||||||
|
"hover_border": "#e4b0bc",
|
||||||
|
"hover_text": "#a1485d"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"info": {
|
||||||
|
"bg": "#e1ebf4",
|
||||||
|
"border": "#c9dceb",
|
||||||
|
"text": "#3c5978"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"bg": "#e0efe5",
|
||||||
|
"border": "#c3dfcb",
|
||||||
|
"text": "#355a40"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"bg": "#faeee5",
|
||||||
|
"border": "#f3daca",
|
||||||
|
"text": "#8d4e32"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"bg": "#f9e7ea",
|
||||||
|
"border": "#f1d1d7",
|
||||||
|
"text": "#85384b"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dark": {
|
||||||
|
"scale": {
|
||||||
|
"brand": {
|
||||||
|
"50": "#faf8fb",
|
||||||
|
"100": "#f3ecf5",
|
||||||
|
"200": "#e5d5ea",
|
||||||
|
"300": "#d1b4db",
|
||||||
|
"400": "#b98dc8",
|
||||||
|
"500": "#9e63b0",
|
||||||
|
"600": "#844a95",
|
||||||
|
"700": "#6d397a",
|
||||||
|
"800": "#5a3164",
|
||||||
|
"900": "#4a2a51",
|
||||||
|
"950": "#2d1332"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"50": "#f2f6fa",
|
||||||
|
"100": "#e1ebf4",
|
||||||
|
"200": "#c9dceb",
|
||||||
|
"300": "#a5c4df",
|
||||||
|
"400": "#7da8cc",
|
||||||
|
"500": "#5e8ab3",
|
||||||
|
"600": "#4a6e94",
|
||||||
|
"700": "#3c5978",
|
||||||
|
"800": "#334a63",
|
||||||
|
"900": "#2b3e51",
|
||||||
|
"950": "#1a2736"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"50": "#f2f8f4",
|
||||||
|
"100": "#e0efe5",
|
||||||
|
"200": "#c3dfcb",
|
||||||
|
"300": "#9cc8a8",
|
||||||
|
"400": "#74ad84",
|
||||||
|
"500": "#558e66",
|
||||||
|
"600": "#41704e",
|
||||||
|
"700": "#355a40",
|
||||||
|
"800": "#2d4935",
|
||||||
|
"900": "#253d2d",
|
||||||
|
"950": "#132218"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"50": "#fdf8f4",
|
||||||
|
"100": "#faeee5",
|
||||||
|
"200": "#f3daca",
|
||||||
|
"300": "#e8bea5",
|
||||||
|
"400": "#d99f7c",
|
||||||
|
"500": "#c68058",
|
||||||
|
"600": "#ab623e",
|
||||||
|
"700": "#8d4e32",
|
||||||
|
"800": "#73412b",
|
||||||
|
"900": "#5e3624",
|
||||||
|
"950": "#331b10"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"50": "#fdf5f6",
|
||||||
|
"100": "#f9e7ea",
|
||||||
|
"200": "#f1d1d7",
|
||||||
|
"300": "#e4b0bc",
|
||||||
|
"400": "#d3889a",
|
||||||
|
"500": "#be6379",
|
||||||
|
"600": "#a1485d",
|
||||||
|
"700": "#85384b",
|
||||||
|
"800": "#6f3140",
|
||||||
|
"900": "#5d2b37",
|
||||||
|
"950": "#33141c"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"surface": {
|
||||||
|
"app": "#27242a",
|
||||||
|
"nav": "#312d35",
|
||||||
|
"nav_active": "#6d397a",
|
||||||
|
"card": "#312d35",
|
||||||
|
"card_muted": "#3d3842",
|
||||||
|
"table": "#312d35",
|
||||||
|
"table_header": "#3d3842",
|
||||||
|
"input": "#1e1b21",
|
||||||
|
"overlay": "#141116"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"primary": "#e8e4e9",
|
||||||
|
"secondary": "#b6aebd",
|
||||||
|
"muted": "#8e8496",
|
||||||
|
"inverse": "#27242a",
|
||||||
|
"link": "#d1b4db"
|
||||||
|
},
|
||||||
|
"border": {
|
||||||
|
"default": "#4a4450",
|
||||||
|
"strong": "#5f5766",
|
||||||
|
"subtle": "#3d3842",
|
||||||
|
"interactive": "#9e63b0"
|
||||||
|
},
|
||||||
|
"focus": {
|
||||||
|
"ring": "#b98dc8",
|
||||||
|
"ring_offset": "#27242a"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"primary": {
|
||||||
|
"bg": "#9e63b0",
|
||||||
|
"border": "#9e63b0",
|
||||||
|
"text": "#141116",
|
||||||
|
"hover_bg": "#b98dc8",
|
||||||
|
"hover_border": "#b98dc8",
|
||||||
|
"hover_text": "#141116"
|
||||||
|
},
|
||||||
|
"secondary": {
|
||||||
|
"bg": "#4a4450",
|
||||||
|
"border": "#5f5766",
|
||||||
|
"text": "#e8e4e9",
|
||||||
|
"hover_bg": "#5f5766",
|
||||||
|
"hover_border": "#6d397a",
|
||||||
|
"hover_text": "#ffffff"
|
||||||
|
},
|
||||||
|
"ghost": {
|
||||||
|
"bg": "#27242a",
|
||||||
|
"border": "#4a4450",
|
||||||
|
"text": "#b6aebd",
|
||||||
|
"hover_bg": "#4a4450",
|
||||||
|
"hover_border": "#5f5766",
|
||||||
|
"hover_text": "#ffffff"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"bg": "#5d2b37",
|
||||||
|
"border": "#6f3140",
|
||||||
|
"text": "#f1d1d7",
|
||||||
|
"hover_bg": "#6f3140",
|
||||||
|
"hover_border": "#85384b",
|
||||||
|
"hover_text": "#f9e7ea"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"info": {
|
||||||
|
"bg": "#2b3e51",
|
||||||
|
"border": "#3c5978",
|
||||||
|
"text": "#c9dceb"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"bg": "#253d2d",
|
||||||
|
"border": "#355a40",
|
||||||
|
"text": "#c3dfcb"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"bg": "#5e3624",
|
||||||
|
"border": "#8d4e32",
|
||||||
|
"text": "#f3daca"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"bg": "#5d2b37",
|
||||||
|
"border": "#85384b",
|
||||||
|
"text": "#f1d1d7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
314
frontend/src/theme/presets/oatmeal.js
Normal file
314
frontend/src/theme/presets/oatmeal.js
Normal file
|
|
@ -0,0 +1,314 @@
|
||||||
|
export default {
|
||||||
|
"id": "oatmeal",
|
||||||
|
"label": "Oatmeal",
|
||||||
|
"description": "Warm beige foundation with muted sage green interactions.",
|
||||||
|
"modes": {
|
||||||
|
"light": {
|
||||||
|
"scale": {
|
||||||
|
"brand": {
|
||||||
|
"50": "#f4f7f5",
|
||||||
|
"100": "#e5ece7",
|
||||||
|
"200": "#ccd9cf",
|
||||||
|
"300": "#a8bdae",
|
||||||
|
"400": "#8ba390",
|
||||||
|
"500": "#6c8471",
|
||||||
|
"600": "#546958",
|
||||||
|
"700": "#435446",
|
||||||
|
"800": "#364338",
|
||||||
|
"900": "#2d372f",
|
||||||
|
"950": "#161d18"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"50": "#f0f5f6",
|
||||||
|
"100": "#d9e6e8",
|
||||||
|
"200": "#b6ced4",
|
||||||
|
"300": "#8aadb6",
|
||||||
|
"400": "#648d99",
|
||||||
|
"500": "#4a717e",
|
||||||
|
"600": "#3e5c68",
|
||||||
|
"700": "#344b55",
|
||||||
|
"800": "#2d3f47",
|
||||||
|
"900": "#27353d",
|
||||||
|
"950": "#151f25"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"50": "#f2f7f3",
|
||||||
|
"100": "#e0efe3",
|
||||||
|
"200": "#c3dfc9",
|
||||||
|
"300": "#9bc4a6",
|
||||||
|
"400": "#75a383",
|
||||||
|
"500": "#568465",
|
||||||
|
"600": "#42694f",
|
||||||
|
"700": "#365340",
|
||||||
|
"800": "#2d4234",
|
||||||
|
"900": "#25372b",
|
||||||
|
"950": "#121e16"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"50": "#fcf9f2",
|
||||||
|
"100": "#f6efe0",
|
||||||
|
"200": "#ecdeb8",
|
||||||
|
"300": "#dfc68a",
|
||||||
|
"400": "#cfa85e",
|
||||||
|
"500": "#c79f67",
|
||||||
|
"600": "#ac7f42",
|
||||||
|
"700": "#8f6336",
|
||||||
|
"800": "#755030",
|
||||||
|
"900": "#60422a",
|
||||||
|
"950": "#352213"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"50": "#fdf4f4",
|
||||||
|
"100": "#fae5e5",
|
||||||
|
"200": "#f3cccc",
|
||||||
|
"300": "#e8a8a8",
|
||||||
|
"400": "#da7b7b",
|
||||||
|
"500": "#c47575",
|
||||||
|
"600": "#a64d4d",
|
||||||
|
"700": "#8b3f3f",
|
||||||
|
"800": "#753737",
|
||||||
|
"900": "#623232",
|
||||||
|
"950": "#361818"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"surface": {
|
||||||
|
"app": "#f5f2eb",
|
||||||
|
"nav": "#fcfaf5",
|
||||||
|
"nav_active": "#e5e1d8",
|
||||||
|
"card": "#fcfaf5",
|
||||||
|
"card_muted": "#ece9e1",
|
||||||
|
"table": "#fcfaf5",
|
||||||
|
"table_header": "#f5f2eb",
|
||||||
|
"input": "#ffffff",
|
||||||
|
"overlay": "#3d3b38"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"primary": "#3d3b38",
|
||||||
|
"secondary": "#716d68",
|
||||||
|
"muted": "#96918a",
|
||||||
|
"inverse": "#fcfaf5",
|
||||||
|
"link": "#546958"
|
||||||
|
},
|
||||||
|
"border": {
|
||||||
|
"default": "#e5e1d8",
|
||||||
|
"strong": "#cecabf",
|
||||||
|
"subtle": "#f0ede6",
|
||||||
|
"interactive": "#a8bdae"
|
||||||
|
},
|
||||||
|
"focus": {
|
||||||
|
"ring": "#8ba390",
|
||||||
|
"ring_offset": "#f5f2eb"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"primary": {
|
||||||
|
"bg": "#8ba390",
|
||||||
|
"border": "#8ba390",
|
||||||
|
"text": "#fcfaf5",
|
||||||
|
"hover_bg": "#6c8471",
|
||||||
|
"hover_border": "#6c8471",
|
||||||
|
"hover_text": "#ffffff"
|
||||||
|
},
|
||||||
|
"secondary": {
|
||||||
|
"bg": "#ece9e1",
|
||||||
|
"border": "#cecabf",
|
||||||
|
"text": "#3d3b38",
|
||||||
|
"hover_bg": "#e5e1d8",
|
||||||
|
"hover_border": "#b8b3a8",
|
||||||
|
"hover_text": "#1a1918"
|
||||||
|
},
|
||||||
|
"ghost": {
|
||||||
|
"bg": "#f5f2eb",
|
||||||
|
"border": "#e5e1d8",
|
||||||
|
"text": "#546958",
|
||||||
|
"hover_bg": "#ece9e1",
|
||||||
|
"hover_border": "#cecabf",
|
||||||
|
"hover_text": "#3d3b38"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"bg": "#fae5e5",
|
||||||
|
"border": "#f3cccc",
|
||||||
|
"text": "#8b3f3f",
|
||||||
|
"hover_bg": "#f3cccc",
|
||||||
|
"hover_border": "#e8a8a8",
|
||||||
|
"hover_text": "#a64d4d"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"info": {
|
||||||
|
"bg": "#d9e6e8",
|
||||||
|
"border": "#b6ced4",
|
||||||
|
"text": "#344b55"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"bg": "#e0efe3",
|
||||||
|
"border": "#c3dfc9",
|
||||||
|
"text": "#365340"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"bg": "#f6efe0",
|
||||||
|
"border": "#ecdeb8",
|
||||||
|
"text": "#8f6336"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"bg": "#fae5e5",
|
||||||
|
"border": "#f3cccc",
|
||||||
|
"text": "#8b3f3f"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dark": {
|
||||||
|
"scale": {
|
||||||
|
"brand": {
|
||||||
|
"50": "#f4f7f5",
|
||||||
|
"100": "#e5ece7",
|
||||||
|
"200": "#ccd9cf",
|
||||||
|
"300": "#a8bdae",
|
||||||
|
"400": "#8ba390",
|
||||||
|
"500": "#6c8471",
|
||||||
|
"600": "#546958",
|
||||||
|
"700": "#435446",
|
||||||
|
"800": "#364338",
|
||||||
|
"900": "#2d372f",
|
||||||
|
"950": "#161d18"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"50": "#f0f5f6",
|
||||||
|
"100": "#d9e6e8",
|
||||||
|
"200": "#b6ced4",
|
||||||
|
"300": "#8aadb6",
|
||||||
|
"400": "#648d99",
|
||||||
|
"500": "#4a717e",
|
||||||
|
"600": "#3e5c68",
|
||||||
|
"700": "#344b55",
|
||||||
|
"800": "#2d3f47",
|
||||||
|
"900": "#27353d",
|
||||||
|
"950": "#151f25"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"50": "#f2f7f3",
|
||||||
|
"100": "#e0efe3",
|
||||||
|
"200": "#c3dfc9",
|
||||||
|
"300": "#9bc4a6",
|
||||||
|
"400": "#75a383",
|
||||||
|
"500": "#568465",
|
||||||
|
"600": "#42694f",
|
||||||
|
"700": "#365340",
|
||||||
|
"800": "#2d4234",
|
||||||
|
"900": "#25372b",
|
||||||
|
"950": "#121e16"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"50": "#fcf9f2",
|
||||||
|
"100": "#f6efe0",
|
||||||
|
"200": "#ecdeb8",
|
||||||
|
"300": "#dfc68a",
|
||||||
|
"400": "#cfa85e",
|
||||||
|
"500": "#c79f67",
|
||||||
|
"600": "#ac7f42",
|
||||||
|
"700": "#8f6336",
|
||||||
|
"800": "#755030",
|
||||||
|
"900": "#60422a",
|
||||||
|
"950": "#352213"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"50": "#fdf4f4",
|
||||||
|
"100": "#fae5e5",
|
||||||
|
"200": "#f3cccc",
|
||||||
|
"300": "#e8a8a8",
|
||||||
|
"400": "#da7b7b",
|
||||||
|
"500": "#c47575",
|
||||||
|
"600": "#a64d4d",
|
||||||
|
"700": "#8b3f3f",
|
||||||
|
"800": "#753737",
|
||||||
|
"900": "#623232",
|
||||||
|
"950": "#361818"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"surface": {
|
||||||
|
"app": "#3a3937",
|
||||||
|
"nav": "#454442",
|
||||||
|
"nav_active": "#546958",
|
||||||
|
"card": "#454442",
|
||||||
|
"card_muted": "#504e4c",
|
||||||
|
"table": "#454442",
|
||||||
|
"table_header": "#504e4c",
|
||||||
|
"input": "#2e2d2b",
|
||||||
|
"overlay": "#1a1918"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"primary": "#e8e6e1",
|
||||||
|
"secondary": "#bebcb8",
|
||||||
|
"muted": "#969490",
|
||||||
|
"inverse": "#3a3937",
|
||||||
|
"link": "#a8bdae"
|
||||||
|
},
|
||||||
|
"border": {
|
||||||
|
"default": "#504e4c",
|
||||||
|
"strong": "#62605d",
|
||||||
|
"subtle": "#403f3d",
|
||||||
|
"interactive": "#8ba390"
|
||||||
|
},
|
||||||
|
"focus": {
|
||||||
|
"ring": "#8ba390",
|
||||||
|
"ring_offset": "#3a3937"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"primary": {
|
||||||
|
"bg": "#8ba390",
|
||||||
|
"border": "#8ba390",
|
||||||
|
"text": "#1a1918",
|
||||||
|
"hover_bg": "#a8bdae",
|
||||||
|
"hover_border": "#a8bdae",
|
||||||
|
"hover_text": "#1a1918"
|
||||||
|
},
|
||||||
|
"secondary": {
|
||||||
|
"bg": "#504e4c",
|
||||||
|
"border": "#62605d",
|
||||||
|
"text": "#e8e6e1",
|
||||||
|
"hover_bg": "#62605d",
|
||||||
|
"hover_border": "#73716e",
|
||||||
|
"hover_text": "#ffffff"
|
||||||
|
},
|
||||||
|
"ghost": {
|
||||||
|
"bg": "#3a3937",
|
||||||
|
"border": "#504e4c",
|
||||||
|
"text": "#bebcb8",
|
||||||
|
"hover_bg": "#504e4c",
|
||||||
|
"hover_border": "#62605d",
|
||||||
|
"hover_text": "#ffffff"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"bg": "#623232",
|
||||||
|
"border": "#753737",
|
||||||
|
"text": "#f3cccc",
|
||||||
|
"hover_bg": "#753737",
|
||||||
|
"hover_border": "#8b3f3f",
|
||||||
|
"hover_text": "#fae5e5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"info": {
|
||||||
|
"bg": "#27353d",
|
||||||
|
"border": "#344b55",
|
||||||
|
"text": "#b6ced4"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"bg": "#25372b",
|
||||||
|
"border": "#365340",
|
||||||
|
"text": "#c3dfc9"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"bg": "#60422a",
|
||||||
|
"border": "#8f6336",
|
||||||
|
"text": "#ecdeb8"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"bg": "#623232",
|
||||||
|
"border": "#8b3f3f",
|
||||||
|
"text": "#f3cccc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
;
|
||||||
313
frontend/src/theme/presets/parchment.js
Normal file
313
frontend/src/theme/presets/parchment.js
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
export default {
|
||||||
|
"id": "parchment",
|
||||||
|
"label": "Parchment",
|
||||||
|
"description": "Dusty beige canvas paired with slate blue accents.",
|
||||||
|
"modes": {
|
||||||
|
"light": {
|
||||||
|
"scale": {
|
||||||
|
"brand": {
|
||||||
|
"50": "#f5f7f9",
|
||||||
|
"100": "#eaf0f4",
|
||||||
|
"200": "#d5e0e9",
|
||||||
|
"300": "#b3c9d7",
|
||||||
|
"400": "#8daabc",
|
||||||
|
"500": "#7d93a4",
|
||||||
|
"600": "#5c7487",
|
||||||
|
"700": "#495e6f",
|
||||||
|
"800": "#3f4d5b",
|
||||||
|
"900": "#36414e",
|
||||||
|
"950": "#232b35"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"50": "#f2f6fa",
|
||||||
|
"100": "#e0ebf4",
|
||||||
|
"200": "#c8dceb",
|
||||||
|
"300": "#a1c4de",
|
||||||
|
"400": "#73a5cc",
|
||||||
|
"500": "#538ab8",
|
||||||
|
"600": "#406d96",
|
||||||
|
"700": "#34577a",
|
||||||
|
"800": "#2d4965",
|
||||||
|
"900": "#273e55",
|
||||||
|
"950": "#192838"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"50": "#f1f8f5",
|
||||||
|
"100": "#def0e6",
|
||||||
|
"200": "#bee0ce",
|
||||||
|
"300": "#93c7b0",
|
||||||
|
"400": "#6a9982",
|
||||||
|
"500": "#4c8a6d",
|
||||||
|
"600": "#386b53",
|
||||||
|
"700": "#2e5543",
|
||||||
|
"800": "#274537",
|
||||||
|
"900": "#21392e",
|
||||||
|
"950": "#112019"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"50": "#fdf8f1",
|
||||||
|
"100": "#f9eedc",
|
||||||
|
"200": "#f3dbbc",
|
||||||
|
"300": "#ebc191",
|
||||||
|
"400": "#e0a163",
|
||||||
|
"500": "#d1a569",
|
||||||
|
"600": "#b37435",
|
||||||
|
"700": "#945c2d",
|
||||||
|
"800": "#784b29",
|
||||||
|
"900": "#613e24",
|
||||||
|
"950": "#36200f"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"50": "#fcf5f5",
|
||||||
|
"100": "#f9e8e8",
|
||||||
|
"200": "#f1d1d1",
|
||||||
|
"300": "#e5b1b1",
|
||||||
|
"400": "#d48888",
|
||||||
|
"500": "#c26b6b",
|
||||||
|
"600": "#a64f4f",
|
||||||
|
"700": "#8a4141",
|
||||||
|
"800": "#733838",
|
||||||
|
"900": "#603232",
|
||||||
|
"950": "#331818"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"surface": {
|
||||||
|
"app": "#f2eee9",
|
||||||
|
"nav": "#faf8f5",
|
||||||
|
"nav_active": "#d5e0e9",
|
||||||
|
"card": "#faf8f5",
|
||||||
|
"card_muted": "#eae5df",
|
||||||
|
"table": "#faf8f5",
|
||||||
|
"table_header": "#f2eee9",
|
||||||
|
"input": "#ffffff",
|
||||||
|
"overlay": "#333638"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"primary": "#333638",
|
||||||
|
"secondary": "#6b737c",
|
||||||
|
"muted": "#929ba5",
|
||||||
|
"inverse": "#faf8f5",
|
||||||
|
"link": "#5c7487"
|
||||||
|
},
|
||||||
|
"border": {
|
||||||
|
"default": "#eae5df",
|
||||||
|
"strong": "#d4cec6",
|
||||||
|
"subtle": "#f6f3ef",
|
||||||
|
"interactive": "#b3c9d7"
|
||||||
|
},
|
||||||
|
"focus": {
|
||||||
|
"ring": "#7d93a4",
|
||||||
|
"ring_offset": "#f2eee9"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"primary": {
|
||||||
|
"bg": "#7d93a4",
|
||||||
|
"border": "#7d93a4",
|
||||||
|
"text": "#faf8f5",
|
||||||
|
"hover_bg": "#5c7487",
|
||||||
|
"hover_border": "#5c7487",
|
||||||
|
"hover_text": "#ffffff"
|
||||||
|
},
|
||||||
|
"secondary": {
|
||||||
|
"bg": "#eae5df",
|
||||||
|
"border": "#d4cec6",
|
||||||
|
"text": "#333638",
|
||||||
|
"hover_bg": "#dcd6cd",
|
||||||
|
"hover_border": "#c2bba0",
|
||||||
|
"hover_text": "#1a1b1d"
|
||||||
|
},
|
||||||
|
"ghost": {
|
||||||
|
"bg": "#f2eee9",
|
||||||
|
"border": "#eae5df",
|
||||||
|
"text": "#5c7487",
|
||||||
|
"hover_bg": "#eae5df",
|
||||||
|
"hover_border": "#d4cec6",
|
||||||
|
"hover_text": "#333638"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"bg": "#f9e8e8",
|
||||||
|
"border": "#f1d1d1",
|
||||||
|
"text": "#8a4141",
|
||||||
|
"hover_bg": "#f1d1d1",
|
||||||
|
"hover_border": "#e5b1b1",
|
||||||
|
"hover_text": "#a64f4f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"info": {
|
||||||
|
"bg": "#e0ebf4",
|
||||||
|
"border": "#c8dceb",
|
||||||
|
"text": "#34577a"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"bg": "#def0e6",
|
||||||
|
"border": "#bee0ce",
|
||||||
|
"text": "#2e5543"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"bg": "#f9eedc",
|
||||||
|
"border": "#f3dbbc",
|
||||||
|
"text": "#945c2d"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"bg": "#f9e8e8",
|
||||||
|
"border": "#f1d1d1",
|
||||||
|
"text": "#8a4141"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dark": {
|
||||||
|
"scale": {
|
||||||
|
"brand": {
|
||||||
|
"50": "#f5f7f9",
|
||||||
|
"100": "#eaf0f4",
|
||||||
|
"200": "#d5e0e9",
|
||||||
|
"300": "#b3c9d7",
|
||||||
|
"400": "#8daabc",
|
||||||
|
"500": "#7d93a4",
|
||||||
|
"600": "#5c7487",
|
||||||
|
"700": "#495e6f",
|
||||||
|
"800": "#3f4d5b",
|
||||||
|
"900": "#36414e",
|
||||||
|
"950": "#232b35"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"50": "#f2f6fa",
|
||||||
|
"100": "#e0ebf4",
|
||||||
|
"200": "#c8dceb",
|
||||||
|
"300": "#a1c4de",
|
||||||
|
"400": "#73a5cc",
|
||||||
|
"500": "#538ab8",
|
||||||
|
"600": "#406d96",
|
||||||
|
"700": "#34577a",
|
||||||
|
"800": "#2d4965",
|
||||||
|
"900": "#273e55",
|
||||||
|
"950": "#192838"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"50": "#f1f8f5",
|
||||||
|
"100": "#def0e6",
|
||||||
|
"200": "#bee0ce",
|
||||||
|
"300": "#93c7b0",
|
||||||
|
"400": "#6a9982",
|
||||||
|
"500": "#4c8a6d",
|
||||||
|
"600": "#386b53",
|
||||||
|
"700": "#2e5543",
|
||||||
|
"800": "#274537",
|
||||||
|
"900": "#21392e",
|
||||||
|
"950": "#112019"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"50": "#fdf8f1",
|
||||||
|
"100": "#f9eedc",
|
||||||
|
"200": "#f3dbbc",
|
||||||
|
"300": "#ebc191",
|
||||||
|
"400": "#e0a163",
|
||||||
|
"500": "#d1a569",
|
||||||
|
"600": "#b37435",
|
||||||
|
"700": "#945c2d",
|
||||||
|
"800": "#784b29",
|
||||||
|
"900": "#613e24",
|
||||||
|
"950": "#36200f"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"50": "#fcf5f5",
|
||||||
|
"100": "#f9e8e8",
|
||||||
|
"200": "#f1d1d1",
|
||||||
|
"300": "#e5b1b1",
|
||||||
|
"400": "#d48888",
|
||||||
|
"500": "#c26b6b",
|
||||||
|
"600": "#a64f4f",
|
||||||
|
"700": "#8a4141",
|
||||||
|
"800": "#733838",
|
||||||
|
"900": "#603232",
|
||||||
|
"950": "#331818"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"surface": {
|
||||||
|
"app": "#35373a",
|
||||||
|
"nav": "#424549",
|
||||||
|
"nav_active": "#5c7487",
|
||||||
|
"card": "#424549",
|
||||||
|
"card_muted": "#4d5156",
|
||||||
|
"table": "#424549",
|
||||||
|
"table_header": "#4d5156",
|
||||||
|
"input": "#2a2c2e",
|
||||||
|
"overlay": "#1a1b1d"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"primary": "#e3e5e8",
|
||||||
|
"secondary": "#aeb5bc",
|
||||||
|
"muted": "#889098",
|
||||||
|
"inverse": "#35373a",
|
||||||
|
"link": "#b3c9d7"
|
||||||
|
},
|
||||||
|
"border": {
|
||||||
|
"default": "#4d5156",
|
||||||
|
"strong": "#5d6268",
|
||||||
|
"subtle": "#3e4145",
|
||||||
|
"interactive": "#7d93a4"
|
||||||
|
},
|
||||||
|
"focus": {
|
||||||
|
"ring": "#7d93a4",
|
||||||
|
"ring_offset": "#35373a"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"primary": {
|
||||||
|
"bg": "#7d93a4",
|
||||||
|
"border": "#7d93a4",
|
||||||
|
"text": "#1a1b1d",
|
||||||
|
"hover_bg": "#b3c9d7",
|
||||||
|
"hover_border": "#b3c9d7",
|
||||||
|
"hover_text": "#1a1b1d"
|
||||||
|
},
|
||||||
|
"secondary": {
|
||||||
|
"bg": "#4d5156",
|
||||||
|
"border": "#5d6268",
|
||||||
|
"text": "#e3e5e8",
|
||||||
|
"hover_bg": "#5d6268",
|
||||||
|
"hover_border": "#70767c",
|
||||||
|
"hover_text": "#ffffff"
|
||||||
|
},
|
||||||
|
"ghost": {
|
||||||
|
"bg": "#35373a",
|
||||||
|
"border": "#4d5156",
|
||||||
|
"text": "#aeb5bc",
|
||||||
|
"hover_bg": "#4d5156",
|
||||||
|
"hover_border": "#5d6268",
|
||||||
|
"hover_text": "#ffffff"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"bg": "#603232",
|
||||||
|
"border": "#733838",
|
||||||
|
"text": "#f1d1d1",
|
||||||
|
"hover_bg": "#733838",
|
||||||
|
"hover_border": "#8a4141",
|
||||||
|
"hover_text": "#f9e8e8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"info": {
|
||||||
|
"bg": "#273e55",
|
||||||
|
"border": "#34577a",
|
||||||
|
"text": "#c8dceb"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"bg": "#21392e",
|
||||||
|
"border": "#2e5543",
|
||||||
|
"text": "#bee0ce"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"bg": "#613e24",
|
||||||
|
"border": "#945c2d",
|
||||||
|
"text": "#f3dbbc"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"bg": "#603232",
|
||||||
|
"border": "#8a4141",
|
||||||
|
"text": "#f1d1d1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
313
frontend/src/theme/presets/scholarly.json
Normal file
313
frontend/src/theme/presets/scholarly.json
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
{
|
||||||
|
"id": "scholarly",
|
||||||
|
"label": "Scholarly",
|
||||||
|
"description": "Calm, academic blues with restrained contrast.",
|
||||||
|
"modes": {
|
||||||
|
"light": {
|
||||||
|
"scale": {
|
||||||
|
"brand": {
|
||||||
|
"50": "#f3f6fb",
|
||||||
|
"100": "#e7edf7",
|
||||||
|
"200": "#cedcee",
|
||||||
|
"300": "#afc6df",
|
||||||
|
"400": "#8eaac8",
|
||||||
|
"500": "#6f8fac",
|
||||||
|
"600": "#5a7490",
|
||||||
|
"700": "#495f77",
|
||||||
|
"800": "#3f5063",
|
||||||
|
"900": "#374554",
|
||||||
|
"950": "#242d38"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"50": "#f0f9ff",
|
||||||
|
"100": "#e0f2fe",
|
||||||
|
"200": "#bae6fd",
|
||||||
|
"300": "#7dd3fc",
|
||||||
|
"400": "#38bdf8",
|
||||||
|
"500": "#0ea5e9",
|
||||||
|
"600": "#0284c7",
|
||||||
|
"700": "#0369a1",
|
||||||
|
"800": "#075985",
|
||||||
|
"900": "#0c4a6e",
|
||||||
|
"950": "#082f49"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"50": "#ecfdf5",
|
||||||
|
"100": "#d1fae5",
|
||||||
|
"200": "#a7f3d0",
|
||||||
|
"300": "#6ee7b7",
|
||||||
|
"400": "#34d399",
|
||||||
|
"500": "#10b981",
|
||||||
|
"600": "#059669",
|
||||||
|
"700": "#047857",
|
||||||
|
"800": "#065f46",
|
||||||
|
"900": "#064e3b",
|
||||||
|
"950": "#022c22"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"50": "#fffbeb",
|
||||||
|
"100": "#fef3c7",
|
||||||
|
"200": "#fde68a",
|
||||||
|
"300": "#fcd34d",
|
||||||
|
"400": "#fbbf24",
|
||||||
|
"500": "#f59e0b",
|
||||||
|
"600": "#d97706",
|
||||||
|
"700": "#b45309",
|
||||||
|
"800": "#92400e",
|
||||||
|
"900": "#78350f",
|
||||||
|
"950": "#451a03"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"50": "#fff1f2",
|
||||||
|
"100": "#ffe4e6",
|
||||||
|
"200": "#fecdd3",
|
||||||
|
"300": "#fda4af",
|
||||||
|
"400": "#fb7185",
|
||||||
|
"500": "#f43f5e",
|
||||||
|
"600": "#e11d48",
|
||||||
|
"700": "#be123c",
|
||||||
|
"800": "#9f1239",
|
||||||
|
"900": "#881337",
|
||||||
|
"950": "#4c0519"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"surface": {
|
||||||
|
"app": "#f3f6fb",
|
||||||
|
"nav": "#ffffff",
|
||||||
|
"nav_active": "#495f77",
|
||||||
|
"card": "#ffffff",
|
||||||
|
"card_muted": "#e7edf7",
|
||||||
|
"table": "#ffffff",
|
||||||
|
"table_header": "#f3f6fb",
|
||||||
|
"input": "#ffffff",
|
||||||
|
"overlay": "#0f172a"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"primary": "#1f2937",
|
||||||
|
"secondary": "#475569",
|
||||||
|
"muted": "#64748b",
|
||||||
|
"inverse": "#f8fafc",
|
||||||
|
"link": "#495f77"
|
||||||
|
},
|
||||||
|
"border": {
|
||||||
|
"default": "#cedcee",
|
||||||
|
"strong": "#afc6df",
|
||||||
|
"subtle": "#e7edf7",
|
||||||
|
"interactive": "#8eaac8"
|
||||||
|
},
|
||||||
|
"focus": {
|
||||||
|
"ring": "#6f8fac",
|
||||||
|
"ring_offset": "#f3f6fb"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"primary": {
|
||||||
|
"bg": "#5a7490",
|
||||||
|
"border": "#5a7490",
|
||||||
|
"text": "#f8fafc",
|
||||||
|
"hover_bg": "#495f77",
|
||||||
|
"hover_border": "#495f77",
|
||||||
|
"hover_text": "#ffffff"
|
||||||
|
},
|
||||||
|
"secondary": {
|
||||||
|
"bg": "#eef2f7",
|
||||||
|
"border": "#cedcee",
|
||||||
|
"text": "#334155",
|
||||||
|
"hover_bg": "#e2e8f0",
|
||||||
|
"hover_border": "#afc6df",
|
||||||
|
"hover_text": "#1f2937"
|
||||||
|
},
|
||||||
|
"ghost": {
|
||||||
|
"bg": "#f3f6fb",
|
||||||
|
"border": "#cedcee",
|
||||||
|
"text": "#495f77",
|
||||||
|
"hover_bg": "#e7edf7",
|
||||||
|
"hover_border": "#afc6df",
|
||||||
|
"hover_text": "#374554"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"bg": "#ffe4e6",
|
||||||
|
"border": "#fecdd3",
|
||||||
|
"text": "#be123c",
|
||||||
|
"hover_bg": "#fecdd3",
|
||||||
|
"hover_border": "#fda4af",
|
||||||
|
"hover_text": "#9f1239"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"info": {
|
||||||
|
"bg": "#f0f9ff",
|
||||||
|
"border": "#bae6fd",
|
||||||
|
"text": "#075985"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"bg": "#ecfdf5",
|
||||||
|
"border": "#a7f3d0",
|
||||||
|
"text": "#047857"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"bg": "#fffbeb",
|
||||||
|
"border": "#fde68a",
|
||||||
|
"text": "#92400e"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"bg": "#fff1f2",
|
||||||
|
"border": "#fecdd3",
|
||||||
|
"text": "#be123c"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dark": {
|
||||||
|
"scale": {
|
||||||
|
"brand": {
|
||||||
|
"50": "#f3f6fb",
|
||||||
|
"100": "#e7edf7",
|
||||||
|
"200": "#cedcee",
|
||||||
|
"300": "#afc6df",
|
||||||
|
"400": "#8eaac8",
|
||||||
|
"500": "#6f8fac",
|
||||||
|
"600": "#5a7490",
|
||||||
|
"700": "#495f77",
|
||||||
|
"800": "#3f5063",
|
||||||
|
"900": "#374554",
|
||||||
|
"950": "#242d38"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"50": "#f0f9ff",
|
||||||
|
"100": "#e0f2fe",
|
||||||
|
"200": "#bae6fd",
|
||||||
|
"300": "#7dd3fc",
|
||||||
|
"400": "#38bdf8",
|
||||||
|
"500": "#0ea5e9",
|
||||||
|
"600": "#0284c7",
|
||||||
|
"700": "#0369a1",
|
||||||
|
"800": "#075985",
|
||||||
|
"900": "#0c4a6e",
|
||||||
|
"950": "#082f49"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"50": "#ecfdf5",
|
||||||
|
"100": "#d1fae5",
|
||||||
|
"200": "#a7f3d0",
|
||||||
|
"300": "#6ee7b7",
|
||||||
|
"400": "#34d399",
|
||||||
|
"500": "#10b981",
|
||||||
|
"600": "#059669",
|
||||||
|
"700": "#047857",
|
||||||
|
"800": "#065f46",
|
||||||
|
"900": "#064e3b",
|
||||||
|
"950": "#022c22"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"50": "#fffbeb",
|
||||||
|
"100": "#fef3c7",
|
||||||
|
"200": "#fde68a",
|
||||||
|
"300": "#fcd34d",
|
||||||
|
"400": "#fbbf24",
|
||||||
|
"500": "#f59e0b",
|
||||||
|
"600": "#d97706",
|
||||||
|
"700": "#b45309",
|
||||||
|
"800": "#92400e",
|
||||||
|
"900": "#78350f",
|
||||||
|
"950": "#451a03"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"50": "#fff1f2",
|
||||||
|
"100": "#ffe4e6",
|
||||||
|
"200": "#fecdd3",
|
||||||
|
"300": "#fda4af",
|
||||||
|
"400": "#fb7185",
|
||||||
|
"500": "#f43f5e",
|
||||||
|
"600": "#e11d48",
|
||||||
|
"700": "#be123c",
|
||||||
|
"800": "#9f1239",
|
||||||
|
"900": "#881337",
|
||||||
|
"950": "#4c0519"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"surface": {
|
||||||
|
"app": "#0b1220",
|
||||||
|
"nav": "#111827",
|
||||||
|
"nav_active": "#8eaac8",
|
||||||
|
"card": "#111827",
|
||||||
|
"card_muted": "#1f2937",
|
||||||
|
"table": "#111827",
|
||||||
|
"table_header": "#1f2937",
|
||||||
|
"input": "#0f172a",
|
||||||
|
"overlay": "#020617"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"primary": "#e5e7eb",
|
||||||
|
"secondary": "#cbd5e1",
|
||||||
|
"muted": "#94a3b8",
|
||||||
|
"inverse": "#0b1220",
|
||||||
|
"link": "#afc6df"
|
||||||
|
},
|
||||||
|
"border": {
|
||||||
|
"default": "#334155",
|
||||||
|
"strong": "#475569",
|
||||||
|
"subtle": "#1f2937",
|
||||||
|
"interactive": "#64748b"
|
||||||
|
},
|
||||||
|
"focus": {
|
||||||
|
"ring": "#8eaac8",
|
||||||
|
"ring_offset": "#0b1220"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"primary": {
|
||||||
|
"bg": "#8eaac8",
|
||||||
|
"border": "#8eaac8",
|
||||||
|
"text": "#0b1220",
|
||||||
|
"hover_bg": "#afc6df",
|
||||||
|
"hover_border": "#afc6df",
|
||||||
|
"hover_text": "#020617"
|
||||||
|
},
|
||||||
|
"secondary": {
|
||||||
|
"bg": "#1f2937",
|
||||||
|
"border": "#475569",
|
||||||
|
"text": "#e5e7eb",
|
||||||
|
"hover_bg": "#334155",
|
||||||
|
"hover_border": "#64748b",
|
||||||
|
"hover_text": "#f8fafc"
|
||||||
|
},
|
||||||
|
"ghost": {
|
||||||
|
"bg": "#0f172a",
|
||||||
|
"border": "#334155",
|
||||||
|
"text": "#cbd5e1",
|
||||||
|
"hover_bg": "#1f2937",
|
||||||
|
"hover_border": "#475569",
|
||||||
|
"hover_text": "#f8fafc"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"bg": "#4c0519",
|
||||||
|
"border": "#881337",
|
||||||
|
"text": "#fecdd3",
|
||||||
|
"hover_bg": "#9f1239",
|
||||||
|
"hover_border": "#be123c",
|
||||||
|
"hover_text": "#fff1f2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"info": {
|
||||||
|
"bg": "#082f49",
|
||||||
|
"border": "#0369a1",
|
||||||
|
"text": "#bae6fd"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"bg": "#022c22",
|
||||||
|
"border": "#047857",
|
||||||
|
"text": "#a7f3d0"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"bg": "#451a03",
|
||||||
|
"border": "#b45309",
|
||||||
|
"text": "#fde68a"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"bg": "#4c0519",
|
||||||
|
"border": "#be123c",
|
||||||
|
"text": "#fecdd3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
313
frontend/src/theme/presets/tide.json
Normal file
313
frontend/src/theme/presets/tide.json
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
{
|
||||||
|
"id": "tide",
|
||||||
|
"label": "Tide",
|
||||||
|
"description": "Ocean-inspired palette with teal emphasis.",
|
||||||
|
"modes": {
|
||||||
|
"light": {
|
||||||
|
"scale": {
|
||||||
|
"brand": {
|
||||||
|
"50": "#f0fdfa",
|
||||||
|
"100": "#ccfbf1",
|
||||||
|
"200": "#99f6e4",
|
||||||
|
"300": "#5eead4",
|
||||||
|
"400": "#2dd4bf",
|
||||||
|
"500": "#14b8a6",
|
||||||
|
"600": "#0d9488",
|
||||||
|
"700": "#0f766e",
|
||||||
|
"800": "#115e59",
|
||||||
|
"900": "#134e4a",
|
||||||
|
"950": "#042f2e"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"50": "#f0f9ff",
|
||||||
|
"100": "#e0f2fe",
|
||||||
|
"200": "#bae6fd",
|
||||||
|
"300": "#7dd3fc",
|
||||||
|
"400": "#38bdf8",
|
||||||
|
"500": "#0ea5e9",
|
||||||
|
"600": "#0284c7",
|
||||||
|
"700": "#0369a1",
|
||||||
|
"800": "#075985",
|
||||||
|
"900": "#0c4a6e",
|
||||||
|
"950": "#082f49"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"50": "#ecfdf5",
|
||||||
|
"100": "#d1fae5",
|
||||||
|
"200": "#a7f3d0",
|
||||||
|
"300": "#6ee7b7",
|
||||||
|
"400": "#34d399",
|
||||||
|
"500": "#10b981",
|
||||||
|
"600": "#059669",
|
||||||
|
"700": "#047857",
|
||||||
|
"800": "#065f46",
|
||||||
|
"900": "#064e3b",
|
||||||
|
"950": "#022c22"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"50": "#fffbeb",
|
||||||
|
"100": "#fef3c7",
|
||||||
|
"200": "#fde68a",
|
||||||
|
"300": "#fcd34d",
|
||||||
|
"400": "#fbbf24",
|
||||||
|
"500": "#f59e0b",
|
||||||
|
"600": "#d97706",
|
||||||
|
"700": "#b45309",
|
||||||
|
"800": "#92400e",
|
||||||
|
"900": "#78350f",
|
||||||
|
"950": "#451a03"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"50": "#fff1f2",
|
||||||
|
"100": "#ffe4e6",
|
||||||
|
"200": "#fecdd3",
|
||||||
|
"300": "#fda4af",
|
||||||
|
"400": "#fb7185",
|
||||||
|
"500": "#f43f5e",
|
||||||
|
"600": "#e11d48",
|
||||||
|
"700": "#be123c",
|
||||||
|
"800": "#9f1239",
|
||||||
|
"900": "#881337",
|
||||||
|
"950": "#4c0519"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"surface": {
|
||||||
|
"app": "#f0fdfa",
|
||||||
|
"nav": "#ffffff",
|
||||||
|
"nav_active": "#0f766e",
|
||||||
|
"card": "#ffffff",
|
||||||
|
"card_muted": "#ccfbf1",
|
||||||
|
"table": "#ffffff",
|
||||||
|
"table_header": "#f0fdfa",
|
||||||
|
"input": "#ffffff",
|
||||||
|
"overlay": "#0f172a"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"primary": "#134e4a",
|
||||||
|
"secondary": "#0f766e",
|
||||||
|
"muted": "#115e59",
|
||||||
|
"inverse": "#f8fafc",
|
||||||
|
"link": "#0d9488"
|
||||||
|
},
|
||||||
|
"border": {
|
||||||
|
"default": "#99f6e4",
|
||||||
|
"strong": "#5eead4",
|
||||||
|
"subtle": "#ccfbf1",
|
||||||
|
"interactive": "#2dd4bf"
|
||||||
|
},
|
||||||
|
"focus": {
|
||||||
|
"ring": "#14b8a6",
|
||||||
|
"ring_offset": "#f0fdfa"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"primary": {
|
||||||
|
"bg": "#0d9488",
|
||||||
|
"border": "#0d9488",
|
||||||
|
"text": "#f8fafc",
|
||||||
|
"hover_bg": "#0f766e",
|
||||||
|
"hover_border": "#0f766e",
|
||||||
|
"hover_text": "#ffffff"
|
||||||
|
},
|
||||||
|
"secondary": {
|
||||||
|
"bg": "#ecfeff",
|
||||||
|
"border": "#99f6e4",
|
||||||
|
"text": "#115e59",
|
||||||
|
"hover_bg": "#ccfbf1",
|
||||||
|
"hover_border": "#5eead4",
|
||||||
|
"hover_text": "#134e4a"
|
||||||
|
},
|
||||||
|
"ghost": {
|
||||||
|
"bg": "#f0fdfa",
|
||||||
|
"border": "#99f6e4",
|
||||||
|
"text": "#0f766e",
|
||||||
|
"hover_bg": "#ccfbf1",
|
||||||
|
"hover_border": "#5eead4",
|
||||||
|
"hover_text": "#115e59"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"bg": "#ffe4e6",
|
||||||
|
"border": "#fecdd3",
|
||||||
|
"text": "#be123c",
|
||||||
|
"hover_bg": "#fecdd3",
|
||||||
|
"hover_border": "#fda4af",
|
||||||
|
"hover_text": "#9f1239"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"info": {
|
||||||
|
"bg": "#f0f9ff",
|
||||||
|
"border": "#bae6fd",
|
||||||
|
"text": "#075985"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"bg": "#ecfdf5",
|
||||||
|
"border": "#a7f3d0",
|
||||||
|
"text": "#047857"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"bg": "#fffbeb",
|
||||||
|
"border": "#fde68a",
|
||||||
|
"text": "#92400e"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"bg": "#fff1f2",
|
||||||
|
"border": "#fecdd3",
|
||||||
|
"text": "#be123c"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dark": {
|
||||||
|
"scale": {
|
||||||
|
"brand": {
|
||||||
|
"50": "#f0fdfa",
|
||||||
|
"100": "#ccfbf1",
|
||||||
|
"200": "#99f6e4",
|
||||||
|
"300": "#5eead4",
|
||||||
|
"400": "#2dd4bf",
|
||||||
|
"500": "#14b8a6",
|
||||||
|
"600": "#0d9488",
|
||||||
|
"700": "#0f766e",
|
||||||
|
"800": "#115e59",
|
||||||
|
"900": "#134e4a",
|
||||||
|
"950": "#042f2e"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"50": "#f0f9ff",
|
||||||
|
"100": "#e0f2fe",
|
||||||
|
"200": "#bae6fd",
|
||||||
|
"300": "#7dd3fc",
|
||||||
|
"400": "#38bdf8",
|
||||||
|
"500": "#0ea5e9",
|
||||||
|
"600": "#0284c7",
|
||||||
|
"700": "#0369a1",
|
||||||
|
"800": "#075985",
|
||||||
|
"900": "#0c4a6e",
|
||||||
|
"950": "#082f49"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"50": "#ecfdf5",
|
||||||
|
"100": "#d1fae5",
|
||||||
|
"200": "#a7f3d0",
|
||||||
|
"300": "#6ee7b7",
|
||||||
|
"400": "#34d399",
|
||||||
|
"500": "#10b981",
|
||||||
|
"600": "#059669",
|
||||||
|
"700": "#047857",
|
||||||
|
"800": "#065f46",
|
||||||
|
"900": "#064e3b",
|
||||||
|
"950": "#022c22"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"50": "#fffbeb",
|
||||||
|
"100": "#fef3c7",
|
||||||
|
"200": "#fde68a",
|
||||||
|
"300": "#fcd34d",
|
||||||
|
"400": "#fbbf24",
|
||||||
|
"500": "#f59e0b",
|
||||||
|
"600": "#d97706",
|
||||||
|
"700": "#b45309",
|
||||||
|
"800": "#92400e",
|
||||||
|
"900": "#78350f",
|
||||||
|
"950": "#451a03"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"50": "#fff1f2",
|
||||||
|
"100": "#ffe4e6",
|
||||||
|
"200": "#fecdd3",
|
||||||
|
"300": "#fda4af",
|
||||||
|
"400": "#fb7185",
|
||||||
|
"500": "#f43f5e",
|
||||||
|
"600": "#e11d48",
|
||||||
|
"700": "#be123c",
|
||||||
|
"800": "#9f1239",
|
||||||
|
"900": "#881337",
|
||||||
|
"950": "#4c0519"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"surface": {
|
||||||
|
"app": "#041f1f",
|
||||||
|
"nav": "#042f2e",
|
||||||
|
"nav_active": "#5eead4",
|
||||||
|
"card": "#042f2e",
|
||||||
|
"card_muted": "#134e4a",
|
||||||
|
"table": "#042f2e",
|
||||||
|
"table_header": "#134e4a",
|
||||||
|
"input": "#063a38",
|
||||||
|
"overlay": "#020617"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"primary": "#e6fffb",
|
||||||
|
"secondary": "#c9fff3",
|
||||||
|
"muted": "#99f6e4",
|
||||||
|
"inverse": "#041f1f",
|
||||||
|
"link": "#5eead4"
|
||||||
|
},
|
||||||
|
"border": {
|
||||||
|
"default": "#115e59",
|
||||||
|
"strong": "#0f766e",
|
||||||
|
"subtle": "#134e4a",
|
||||||
|
"interactive": "#2dd4bf"
|
||||||
|
},
|
||||||
|
"focus": {
|
||||||
|
"ring": "#2dd4bf",
|
||||||
|
"ring_offset": "#041f1f"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"primary": {
|
||||||
|
"bg": "#2dd4bf",
|
||||||
|
"border": "#2dd4bf",
|
||||||
|
"text": "#042f2e",
|
||||||
|
"hover_bg": "#5eead4",
|
||||||
|
"hover_border": "#5eead4",
|
||||||
|
"hover_text": "#042f2e"
|
||||||
|
},
|
||||||
|
"secondary": {
|
||||||
|
"bg": "#0f766e",
|
||||||
|
"border": "#14b8a6",
|
||||||
|
"text": "#e6fffb",
|
||||||
|
"hover_bg": "#0d9488",
|
||||||
|
"hover_border": "#2dd4bf",
|
||||||
|
"hover_text": "#ffffff"
|
||||||
|
},
|
||||||
|
"ghost": {
|
||||||
|
"bg": "#042f2e",
|
||||||
|
"border": "#0f766e",
|
||||||
|
"text": "#c9fff3",
|
||||||
|
"hover_bg": "#115e59",
|
||||||
|
"hover_border": "#14b8a6",
|
||||||
|
"hover_text": "#ffffff"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"bg": "#4c0519",
|
||||||
|
"border": "#881337",
|
||||||
|
"text": "#fecdd3",
|
||||||
|
"hover_bg": "#9f1239",
|
||||||
|
"hover_border": "#be123c",
|
||||||
|
"hover_text": "#fff1f2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"info": {
|
||||||
|
"bg": "#082f49",
|
||||||
|
"border": "#0369a1",
|
||||||
|
"text": "#bae6fd"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"bg": "#022c22",
|
||||||
|
"border": "#047857",
|
||||||
|
"text": "#a7f3d0"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"bg": "#451a03",
|
||||||
|
"border": "#b45309",
|
||||||
|
"text": "#fde68a"
|
||||||
|
},
|
||||||
|
"danger": {
|
||||||
|
"bg": "#4c0519",
|
||||||
|
"border": "#be123c",
|
||||||
|
"text": "#fecdd3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,15 @@
|
||||||
|
const COLOR_SCALE_STEPS = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900", "950"];
|
||||||
|
|
||||||
|
function cssColorScale(token) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
COLOR_SCALE_STEPS.map((step) => [step, `rgb(var(--color-${token}-${step}) / <alpha-value>)`]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cssToken(token) {
|
||||||
|
return `rgb(var(--theme-${token}) / <alpha-value>)`;
|
||||||
|
}
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
darkMode: "class",
|
darkMode: "class",
|
||||||
|
|
@ -12,18 +24,94 @@ export default {
|
||||||
panel: "0 10px 30px -20px rgba(8, 15, 30, 0.45)",
|
panel: "0 10px 30px -20px rgba(8, 15, 30, 0.45)",
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
brand: {
|
brand: cssColorScale("brand"),
|
||||||
50: "#ecfeff",
|
info: cssColorScale("info"),
|
||||||
100: "#cffafe",
|
success: cssColorScale("success"),
|
||||||
200: "#a5f3fc",
|
warning: cssColorScale("warning"),
|
||||||
300: "#67e8f9",
|
danger: cssColorScale("danger"),
|
||||||
400: "#22d3ee",
|
surface: {
|
||||||
500: "#06b6d4",
|
app: cssToken("surface-app"),
|
||||||
600: "#0891b2",
|
nav: cssToken("surface-nav"),
|
||||||
700: "#0e7490",
|
"nav-active": cssToken("surface-nav-active"),
|
||||||
800: "#155e75",
|
card: cssToken("surface-card"),
|
||||||
900: "#164e63",
|
"card-muted": cssToken("surface-card-muted"),
|
||||||
950: "#083344",
|
table: cssToken("surface-table"),
|
||||||
|
"table-header": cssToken("surface-table-header"),
|
||||||
|
input: cssToken("surface-input"),
|
||||||
|
overlay: cssToken("surface-overlay"),
|
||||||
|
},
|
||||||
|
ink: {
|
||||||
|
primary: cssToken("text-primary"),
|
||||||
|
secondary: cssToken("text-secondary"),
|
||||||
|
muted: cssToken("text-muted"),
|
||||||
|
inverse: cssToken("text-inverse"),
|
||||||
|
link: cssToken("text-link"),
|
||||||
|
},
|
||||||
|
stroke: {
|
||||||
|
default: cssToken("border-default"),
|
||||||
|
strong: cssToken("border-strong"),
|
||||||
|
subtle: cssToken("border-subtle"),
|
||||||
|
interactive: cssToken("border-interactive"),
|
||||||
|
},
|
||||||
|
focus: {
|
||||||
|
ring: cssToken("focus-ring"),
|
||||||
|
offset: cssToken("focus-ring-offset"),
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
primary: {
|
||||||
|
bg: cssToken("action-primary-bg"),
|
||||||
|
border: cssToken("action-primary-border"),
|
||||||
|
text: cssToken("action-primary-text"),
|
||||||
|
"hover-bg": cssToken("action-primary-hover-bg"),
|
||||||
|
"hover-border": cssToken("action-primary-hover-border"),
|
||||||
|
"hover-text": cssToken("action-primary-hover-text"),
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
bg: cssToken("action-secondary-bg"),
|
||||||
|
border: cssToken("action-secondary-border"),
|
||||||
|
text: cssToken("action-secondary-text"),
|
||||||
|
"hover-bg": cssToken("action-secondary-hover-bg"),
|
||||||
|
"hover-border": cssToken("action-secondary-hover-border"),
|
||||||
|
"hover-text": cssToken("action-secondary-hover-text"),
|
||||||
|
},
|
||||||
|
ghost: {
|
||||||
|
bg: cssToken("action-ghost-bg"),
|
||||||
|
border: cssToken("action-ghost-border"),
|
||||||
|
text: cssToken("action-ghost-text"),
|
||||||
|
"hover-bg": cssToken("action-ghost-hover-bg"),
|
||||||
|
"hover-border": cssToken("action-ghost-hover-border"),
|
||||||
|
"hover-text": cssToken("action-ghost-hover-text"),
|
||||||
|
},
|
||||||
|
danger: {
|
||||||
|
bg: cssToken("action-danger-bg"),
|
||||||
|
border: cssToken("action-danger-border"),
|
||||||
|
text: cssToken("action-danger-text"),
|
||||||
|
"hover-bg": cssToken("action-danger-hover-bg"),
|
||||||
|
"hover-border": cssToken("action-danger-hover-border"),
|
||||||
|
"hover-text": cssToken("action-danger-hover-text"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
info: {
|
||||||
|
bg: cssToken("state-info-bg"),
|
||||||
|
border: cssToken("state-info-border"),
|
||||||
|
text: cssToken("state-info-text"),
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
bg: cssToken("state-success-bg"),
|
||||||
|
border: cssToken("state-success-border"),
|
||||||
|
text: cssToken("state-success-text"),
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
bg: cssToken("state-warning-bg"),
|
||||||
|
border: cssToken("state-warning-border"),
|
||||||
|
text: cssToken("state-warning-text"),
|
||||||
|
},
|
||||||
|
danger: {
|
||||||
|
bg: cssToken("state-danger-bg"),
|
||||||
|
border: cssToken("state-danger-border"),
|
||||||
|
text: cssToken("state-danger-text"),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
42
scripts/check_no_generated_artifacts.sh
Executable file
42
scripts/check_no_generated_artifacts.sh
Executable file
|
|
@ -0,0 +1,42 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
blocked_regexes=(
|
||||||
|
'(^|/)__pycache__/'
|
||||||
|
'\\.py[co]$'
|
||||||
|
'(^|/)\\.pytest_cache(/|$)'
|
||||||
|
'(^|/)\\.mypy_cache(/|$)'
|
||||||
|
'(^|/)\\.ruff_cache(/|$)'
|
||||||
|
'(^|/)\\.coverage$'
|
||||||
|
'(^|/)htmlcov(/|$)'
|
||||||
|
'(^|/)(dist|build)(/|$)'
|
||||||
|
'(^|/)frontend/(dist|node_modules|\\.vite)(/|$)'
|
||||||
|
'(^|/)[^/]+\\.egg-info(/|$)'
|
||||||
|
'(^|/)\\.DS_Store$'
|
||||||
|
'(^|/)planning(/|$)'
|
||||||
|
)
|
||||||
|
|
||||||
|
tracked_files=()
|
||||||
|
while IFS= read -r path; do
|
||||||
|
tracked_files+=("$path")
|
||||||
|
done < <(git ls-files)
|
||||||
|
|
||||||
|
offenders=()
|
||||||
|
for path in "${tracked_files[@]}"; do
|
||||||
|
for regex in "${blocked_regexes[@]}"; do
|
||||||
|
if [[ "$path" =~ $regex ]]; then
|
||||||
|
offenders+=("$path")
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
if (( ${#offenders[@]} > 0 )); then
|
||||||
|
{
|
||||||
|
echo "Tracked generated artifacts detected (remove from git tracking):"
|
||||||
|
printf ' - %s\n' "${offenders[@]}"
|
||||||
|
} >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Generated artifact guard passed (no tracked cache/build/probe files)."
|
||||||
|
|
@ -593,6 +593,7 @@ async def test_api_settings_get_and_update(db_session: AsyncSession) -> None:
|
||||||
get_response = client.get("/api/v1/settings")
|
get_response = client.get("/api/v1/settings")
|
||||||
assert get_response.status_code == 200
|
assert get_response.status_code == 200
|
||||||
assert "request_delay_seconds" in get_response.json()["data"]
|
assert "request_delay_seconds" in get_response.json()["data"]
|
||||||
|
assert "nav_visible_pages" in get_response.json()["data"]
|
||||||
|
|
||||||
update_response = client.put(
|
update_response = client.put(
|
||||||
"/api/v1/settings",
|
"/api/v1/settings",
|
||||||
|
|
@ -600,6 +601,7 @@ async def test_api_settings_get_and_update(db_session: AsyncSession) -> None:
|
||||||
"auto_run_enabled": True,
|
"auto_run_enabled": True,
|
||||||
"run_interval_minutes": 45,
|
"run_interval_minutes": 45,
|
||||||
"request_delay_seconds": 6,
|
"request_delay_seconds": 6,
|
||||||
|
"nav_visible_pages": ["dashboard", "scholars", "publications", "settings", "runs"],
|
||||||
},
|
},
|
||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
|
|
@ -608,6 +610,13 @@ async def test_api_settings_get_and_update(db_session: AsyncSession) -> None:
|
||||||
assert updated["auto_run_enabled"] is True
|
assert updated["auto_run_enabled"] is True
|
||||||
assert updated["run_interval_minutes"] == 45
|
assert updated["run_interval_minutes"] == 45
|
||||||
assert updated["request_delay_seconds"] == 6
|
assert updated["request_delay_seconds"] == 6
|
||||||
|
assert updated["nav_visible_pages"] == [
|
||||||
|
"dashboard",
|
||||||
|
"scholars",
|
||||||
|
"publications",
|
||||||
|
"settings",
|
||||||
|
"runs",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
|
|
||||||
182
tests/integration/test_fixture_probe_runs.py
Normal file
182
tests/integration/test_fixture_probe_runs.py
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db.models import RunStatus, RunTriggerType
|
||||||
|
from app.services.ingestion import ScholarIngestionService
|
||||||
|
from app.services.scholar_source import FetchResult
|
||||||
|
from tests.integration.helpers import insert_user
|
||||||
|
|
||||||
|
REGRESSION_FIXTURE_DIR = Path("tests/fixtures/scholar/regression")
|
||||||
|
|
||||||
|
|
||||||
|
def _fixture(name: str) -> str:
|
||||||
|
return (REGRESSION_FIXTURE_DIR / name).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_fetch(*, scholar_id: str, body: str) -> FetchResult:
|
||||||
|
return FetchResult(
|
||||||
|
requested_url=f"https://scholar.google.com/citations?hl=en&user={scholar_id}",
|
||||||
|
status_code=200,
|
||||||
|
final_url=f"https://scholar.google.com/citations?hl=en&user={scholar_id}",
|
||||||
|
body=body,
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _blocked_fetch(*, scholar_id: str, body: str) -> FetchResult:
|
||||||
|
return FetchResult(
|
||||||
|
requested_url=f"https://scholar.google.com/citations?hl=en&user={scholar_id}",
|
||||||
|
status_code=200,
|
||||||
|
final_url=(
|
||||||
|
"https://accounts.google.com/v3/signin/identifier"
|
||||||
|
"?continue=https%3A%2F%2Fscholar.google.com%2Fcitations"
|
||||||
|
),
|
||||||
|
body=body,
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _network_timeout_fetch(*, scholar_id: str) -> FetchResult:
|
||||||
|
return FetchResult(
|
||||||
|
requested_url=f"https://scholar.google.com/citations?hl=en&user={scholar_id}",
|
||||||
|
status_code=None,
|
||||||
|
final_url=None,
|
||||||
|
body="",
|
||||||
|
error="timed out",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FixtureScholarSource:
|
||||||
|
def __init__(self, responses: dict[tuple[str, int], list[FetchResult]]) -> None:
|
||||||
|
self._responses = responses
|
||||||
|
self._calls: defaultdict[tuple[str, int], int] = defaultdict(int)
|
||||||
|
|
||||||
|
async def fetch_profile_page_html(
|
||||||
|
self,
|
||||||
|
scholar_id: str,
|
||||||
|
*,
|
||||||
|
cstart: int,
|
||||||
|
pagesize: int,
|
||||||
|
) -> FetchResult:
|
||||||
|
_ = pagesize
|
||||||
|
key = (scholar_id, int(cstart))
|
||||||
|
fetches = self._responses.get(key)
|
||||||
|
if not fetches:
|
||||||
|
return _network_timeout_fetch(scholar_id=scholar_id)
|
||||||
|
|
||||||
|
call_index = self._calls[key]
|
||||||
|
self._calls[key] += 1
|
||||||
|
return fetches[min(call_index, len(fetches) - 1)]
|
||||||
|
|
||||||
|
async def fetch_profile_html(self, scholar_id: str) -> FetchResult:
|
||||||
|
return await self.fetch_profile_page_html(scholar_id, cstart=0, pagesize=100)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.db
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fixture_probe_run_emits_failure_and_retry_summary_metrics(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
user_id = await insert_user(
|
||||||
|
db_session,
|
||||||
|
email="fixture-probe@example.com",
|
||||||
|
password="fixture-probe-password",
|
||||||
|
)
|
||||||
|
|
||||||
|
scholar_ids = {
|
||||||
|
"ok": "abcDEF123456",
|
||||||
|
"blocked": "A1B2C3D4E5F6",
|
||||||
|
"retry": "RSTUVWX12345",
|
||||||
|
}
|
||||||
|
|
||||||
|
for label in ("ok", "blocked", "retry"):
|
||||||
|
await db_session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO scholar_profiles (user_id, scholar_id, display_name, is_enabled)
|
||||||
|
VALUES (:user_id, :scholar_id, :display_name, true)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"user_id": user_id,
|
||||||
|
"scholar_id": scholar_ids[label],
|
||||||
|
"display_name": f"fixture-{label}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
source = FixtureScholarSource(
|
||||||
|
{
|
||||||
|
(scholar_ids["ok"], 0): [
|
||||||
|
_profile_fetch(
|
||||||
|
scholar_id=scholar_ids["ok"],
|
||||||
|
body=_fixture("profile_P1RwlvoAAAAJ.html"),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
(scholar_ids["blocked"], 0): [
|
||||||
|
_blocked_fetch(
|
||||||
|
scholar_id=scholar_ids["blocked"],
|
||||||
|
body=_fixture("profile_AAAAAAAAAAAA.html"),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
(scholar_ids["retry"], 0): [
|
||||||
|
_network_timeout_fetch(scholar_id=scholar_ids["retry"]),
|
||||||
|
_profile_fetch(
|
||||||
|
scholar_id=scholar_ids["retry"],
|
||||||
|
body=_fixture("profile_LZ5D_p4AAAAJ.html"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ingestion = ScholarIngestionService(source=source)
|
||||||
|
|
||||||
|
summary = await ingestion.run_for_user(
|
||||||
|
db_session,
|
||||||
|
user_id=user_id,
|
||||||
|
trigger_type=RunTriggerType.MANUAL,
|
||||||
|
request_delay_seconds=0,
|
||||||
|
network_error_retries=1,
|
||||||
|
retry_backoff_seconds=0.0,
|
||||||
|
max_pages_per_scholar=1,
|
||||||
|
page_size=100,
|
||||||
|
auto_queue_continuations=False,
|
||||||
|
alert_blocked_failure_threshold=1,
|
||||||
|
alert_network_failure_threshold=1,
|
||||||
|
alert_retry_scheduled_threshold=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert summary.status == RunStatus.PARTIAL_FAILURE
|
||||||
|
assert summary.scholar_count == 3
|
||||||
|
assert summary.succeeded_count == 2
|
||||||
|
assert summary.failed_count == 1
|
||||||
|
assert summary.partial_count == 0
|
||||||
|
|
||||||
|
run_result = await db_session.execute(
|
||||||
|
text("SELECT error_log FROM crawl_runs WHERE id = :run_id"),
|
||||||
|
{"run_id": summary.crawl_run_id},
|
||||||
|
)
|
||||||
|
error_log = run_result.scalar_one()
|
||||||
|
run_summary = error_log["summary"]
|
||||||
|
|
||||||
|
assert run_summary["failed_state_counts"]["blocked_or_captcha"] == 1
|
||||||
|
assert run_summary["failed_reason_counts"]["blocked_accounts_redirect"] == 1
|
||||||
|
assert run_summary["scrape_failure_counts"]["blocked_or_captcha"] == 1
|
||||||
|
|
||||||
|
assert run_summary["retry_counts"]["retries_scheduled_count"] == 1
|
||||||
|
assert run_summary["retry_counts"]["scholars_with_retries_count"] == 1
|
||||||
|
assert run_summary["retry_counts"]["retry_exhausted_count"] == 0
|
||||||
|
|
||||||
|
assert run_summary["alert_thresholds"]["blocked_failure_threshold"] == 1
|
||||||
|
assert run_summary["alert_thresholds"]["network_failure_threshold"] == 1
|
||||||
|
assert run_summary["alert_thresholds"]["retry_scheduled_threshold"] == 1
|
||||||
|
|
||||||
|
assert run_summary["alert_flags"]["blocked_failure_threshold_exceeded"] is True
|
||||||
|
assert run_summary["alert_flags"]["network_failure_threshold_exceeded"] is False
|
||||||
|
assert run_summary["alert_flags"]["retry_scheduled_threshold_exceeded"] is True
|
||||||
|
|
@ -14,7 +14,7 @@ EXPECTED_TABLES = {
|
||||||
}
|
}
|
||||||
|
|
||||||
EXPECTED_ENUMS = {"run_status", "run_trigger_type"}
|
EXPECTED_ENUMS = {"run_status", "run_trigger_type"}
|
||||||
EXPECTED_REVISION = "20260217_0007"
|
EXPECTED_REVISION = "20260219_0008"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
|
@ -182,3 +182,20 @@ async def test_scholar_profiles_has_initial_page_snapshot_columns(db_session: As
|
||||||
"last_initial_page_fingerprint_sha256",
|
"last_initial_page_fingerprint_sha256",
|
||||||
"last_initial_page_checked_at",
|
"last_initial_page_checked_at",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@pytest.mark.db
|
||||||
|
@pytest.mark.migrations
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_user_settings_has_nav_visible_pages_column(db_session: AsyncSession) -> None:
|
||||||
|
result = await db_session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'user_settings' AND column_name = 'nav_visible_pages'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert result.scalar_one() == 1
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,24 @@
|
||||||
|
from alembic.config import Config
|
||||||
|
from alembic.script import ScriptDirectory
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
|
def expected_head_revision() -> str:
|
||||||
|
script = ScriptDirectory.from_config(Config("alembic.ini"))
|
||||||
|
head = script.get_current_head()
|
||||||
|
assert head is not None
|
||||||
|
return head
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@pytest.mark.smoke
|
@pytest.mark.smoke
|
||||||
@pytest.mark.db
|
@pytest.mark.db
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_schema_head_revision_is_available(db_session: AsyncSession) -> None:
|
async def test_schema_head_revision_is_available(db_session: AsyncSession) -> None:
|
||||||
result = await db_session.execute(text("SELECT version_num FROM alembic_version"))
|
result = await db_session.execute(text("SELECT version_num FROM alembic_version"))
|
||||||
assert result.scalar_one() == "20260217_0007"
|
assert result.scalar_one() == expected_head_revision()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
|
|
||||||
58
tests/unit/test_runs_summary.py
Normal file
58
tests/unit/test_runs_summary.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.services.runs import extract_run_summary
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_run_summary_includes_extended_metrics() -> None:
|
||||||
|
error_log = {
|
||||||
|
"summary": {
|
||||||
|
"succeeded_count": 3,
|
||||||
|
"failed_count": 1,
|
||||||
|
"partial_count": 2,
|
||||||
|
"failed_state_counts": {"network_error": 1},
|
||||||
|
"failed_reason_counts": {"network_timeout": 1},
|
||||||
|
"scrape_failure_counts": {"network_error": 1},
|
||||||
|
"retry_counts": {
|
||||||
|
"retries_scheduled_count": 4,
|
||||||
|
"scholars_with_retries_count": 2,
|
||||||
|
"retry_exhausted_count": 1,
|
||||||
|
},
|
||||||
|
"alert_thresholds": {
|
||||||
|
"blocked_failure_threshold": 1,
|
||||||
|
"network_failure_threshold": 2,
|
||||||
|
"retry_scheduled_threshold": 3,
|
||||||
|
},
|
||||||
|
"alert_flags": {
|
||||||
|
"blocked_failure_threshold_exceeded": False,
|
||||||
|
"network_failure_threshold_exceeded": True,
|
||||||
|
"retry_scheduled_threshold_exceeded": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
summary = extract_run_summary(error_log)
|
||||||
|
|
||||||
|
assert summary["succeeded_count"] == 3
|
||||||
|
assert summary["failed_count"] == 1
|
||||||
|
assert summary["partial_count"] == 2
|
||||||
|
assert summary["failed_state_counts"] == {"network_error": 1}
|
||||||
|
assert summary["failed_reason_counts"] == {"network_timeout": 1}
|
||||||
|
assert summary["scrape_failure_counts"] == {"network_error": 1}
|
||||||
|
assert summary["retry_counts"]["retries_scheduled_count"] == 4
|
||||||
|
assert summary["retry_counts"]["scholars_with_retries_count"] == 2
|
||||||
|
assert summary["retry_counts"]["retry_exhausted_count"] == 1
|
||||||
|
assert summary["alert_thresholds"]["retry_scheduled_threshold"] == 3
|
||||||
|
assert summary["alert_flags"]["network_failure_threshold_exceeded"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_run_summary_defaults_extended_metrics() -> None:
|
||||||
|
summary = extract_run_summary({})
|
||||||
|
|
||||||
|
assert summary["scrape_failure_counts"] == {}
|
||||||
|
assert summary["retry_counts"] == {
|
||||||
|
"retries_scheduled_count": 0,
|
||||||
|
"scholars_with_retries_count": 0,
|
||||||
|
"retry_exhausted_count": 0,
|
||||||
|
}
|
||||||
|
assert summary["alert_thresholds"] == {}
|
||||||
|
assert summary["alert_flags"] == {}
|
||||||
|
|
@ -63,6 +63,16 @@ def _blocked_author_search_fetch() -> FetchResult:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _network_timeout_fetch() -> FetchResult:
|
||||||
|
return FetchResult(
|
||||||
|
requested_url="https://scholar.google.com/citations?hl=en&view_op=search_authors&mauthors=ada",
|
||||||
|
status_code=None,
|
||||||
|
final_url=None,
|
||||||
|
body="",
|
||||||
|
error="timed out",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_search_author_candidates_serves_cached_response_for_same_query() -> None:
|
async def test_search_author_candidates_serves_cached_response_for_same_query() -> None:
|
||||||
source = StubScholarSource([_ok_author_search_fetch()])
|
source = StubScholarSource([_ok_author_search_fetch()])
|
||||||
|
|
@ -150,6 +160,79 @@ async def test_search_author_candidates_trips_cooldown_after_blocked_responses()
|
||||||
assert "author_search_cooldown_active" in second.warnings
|
assert "author_search_cooldown_active" in second.warnings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_author_candidates_emits_cooldown_alert_warning_after_threshold() -> None:
|
||||||
|
source = StubScholarSource([_blocked_author_search_fetch()])
|
||||||
|
|
||||||
|
_ = await scholar_service.search_author_candidates(
|
||||||
|
source=source,
|
||||||
|
query="Blocked Query",
|
||||||
|
limit=10,
|
||||||
|
network_error_retries=0,
|
||||||
|
retry_backoff_seconds=0.0,
|
||||||
|
search_enabled=True,
|
||||||
|
cache_ttl_seconds=0,
|
||||||
|
blocked_cache_ttl_seconds=0,
|
||||||
|
cache_max_entries=64,
|
||||||
|
min_interval_seconds=0.0,
|
||||||
|
interval_jitter_seconds=0.0,
|
||||||
|
cooldown_block_threshold=1,
|
||||||
|
cooldown_seconds=300,
|
||||||
|
cooldown_rejection_alert_threshold=1,
|
||||||
|
)
|
||||||
|
second = await scholar_service.search_author_candidates(
|
||||||
|
source=source,
|
||||||
|
query="Blocked Query",
|
||||||
|
limit=10,
|
||||||
|
network_error_retries=0,
|
||||||
|
retry_backoff_seconds=0.0,
|
||||||
|
search_enabled=True,
|
||||||
|
cache_ttl_seconds=0,
|
||||||
|
blocked_cache_ttl_seconds=0,
|
||||||
|
cache_max_entries=64,
|
||||||
|
min_interval_seconds=0.0,
|
||||||
|
interval_jitter_seconds=0.0,
|
||||||
|
cooldown_block_threshold=1,
|
||||||
|
cooldown_seconds=300,
|
||||||
|
cooldown_rejection_alert_threshold=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert second.state == ParseState.BLOCKED_OR_CAPTCHA
|
||||||
|
assert "author_search_cooldown_alert_threshold_exceeded" in second.warnings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_author_candidates_adds_retry_threshold_warning() -> None:
|
||||||
|
source = StubScholarSource(
|
||||||
|
[
|
||||||
|
_network_timeout_fetch(),
|
||||||
|
_network_timeout_fetch(),
|
||||||
|
_ok_author_search_fetch(),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await scholar_service.search_author_candidates(
|
||||||
|
source=source,
|
||||||
|
query="Ada Lovelace",
|
||||||
|
limit=10,
|
||||||
|
network_error_retries=2,
|
||||||
|
retry_backoff_seconds=0.0,
|
||||||
|
search_enabled=True,
|
||||||
|
cache_ttl_seconds=0,
|
||||||
|
blocked_cache_ttl_seconds=0,
|
||||||
|
cache_max_entries=64,
|
||||||
|
min_interval_seconds=0.0,
|
||||||
|
interval_jitter_seconds=0.0,
|
||||||
|
cooldown_block_threshold=3,
|
||||||
|
cooldown_seconds=300,
|
||||||
|
retry_alert_threshold=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.state == ParseState.OK
|
||||||
|
assert "author_search_retry_threshold_exceeded_2" in result.warnings
|
||||||
|
assert source.calls == 3
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_search_author_candidates_can_be_disabled_by_configuration() -> None:
|
async def test_search_author_candidates_can_be_disabled_by_configuration() -> None:
|
||||||
source = StubScholarSource([_ok_author_search_fetch()])
|
source = StubScholarSource([_ok_author_search_fetch()])
|
||||||
|
|
|
||||||
36
tests/unit/test_security_headers.py
Normal file
36
tests/unit/test_security_headers.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
def test_security_headers_are_set_for_api_responses(monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("app.main.check_database", AsyncMock(return_value=True))
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
response = client.get("/healthz")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["x-content-type-options"] == "nosniff"
|
||||||
|
assert response.headers["x-frame-options"] == "DENY"
|
||||||
|
assert response.headers["referrer-policy"] == "strict-origin-when-cross-origin"
|
||||||
|
assert response.headers["cross-origin-opener-policy"] == "same-origin"
|
||||||
|
assert response.headers["cross-origin-resource-policy"] == "same-origin"
|
||||||
|
assert "permissions-policy" in response.headers
|
||||||
|
assert "content-security-policy" in response.headers
|
||||||
|
assert "script-src 'self'" in response.headers["content-security-policy"]
|
||||||
|
assert "frame-ancestors 'none'" in response.headers["content-security-policy"]
|
||||||
|
assert "strict-transport-security" not in response.headers
|
||||||
|
|
||||||
|
|
||||||
|
def test_docs_route_uses_docs_csp_policy(monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("app.main.check_database", AsyncMock(return_value=True))
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
response = client.get("/docs")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
csp = response.headers["content-security-policy"]
|
||||||
|
assert "script-src 'self' 'unsafe-inline'" in csp
|
||||||
|
assert "style-src 'self' 'unsafe-inline'" in csp
|
||||||
70
tests/unit/test_user_settings.py
Normal file
70
tests/unit/test_user_settings.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.user_settings import (
|
||||||
|
DEFAULT_NAV_VISIBLE_PAGES,
|
||||||
|
UserSettingsServiceError,
|
||||||
|
parse_nav_visible_pages,
|
||||||
|
parse_request_delay_seconds,
|
||||||
|
parse_run_interval_minutes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_run_interval_minutes_accepts_valid_value() -> None:
|
||||||
|
assert parse_run_interval_minutes("30") == 30
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_run_interval_minutes_rejects_below_minimum() -> None:
|
||||||
|
with pytest.raises(
|
||||||
|
UserSettingsServiceError,
|
||||||
|
match="Check interval must be at least 15 minutes.",
|
||||||
|
):
|
||||||
|
parse_run_interval_minutes("14")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_request_delay_seconds_accepts_valid_value() -> None:
|
||||||
|
assert parse_request_delay_seconds("2") == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_request_delay_seconds_rejects_below_minimum() -> None:
|
||||||
|
with pytest.raises(
|
||||||
|
UserSettingsServiceError,
|
||||||
|
match="Request delay must be at least 2 seconds.",
|
||||||
|
):
|
||||||
|
parse_request_delay_seconds("1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_nav_visible_pages_accepts_valid_pages() -> None:
|
||||||
|
parsed = parse_nav_visible_pages(
|
||||||
|
[
|
||||||
|
"dashboard",
|
||||||
|
"scholars",
|
||||||
|
"publications",
|
||||||
|
"settings",
|
||||||
|
"runs",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert parsed == [
|
||||||
|
"dashboard",
|
||||||
|
"scholars",
|
||||||
|
"publications",
|
||||||
|
"settings",
|
||||||
|
"runs",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_nav_visible_pages_rejects_missing_required_pages() -> None:
|
||||||
|
with pytest.raises(
|
||||||
|
UserSettingsServiceError,
|
||||||
|
match="Dashboard, Scholars, and Settings must remain visible.",
|
||||||
|
):
|
||||||
|
parse_nav_visible_pages(["dashboard", "publications"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_nav_visible_pages_rejects_unknown_page() -> None:
|
||||||
|
with pytest.raises(
|
||||||
|
UserSettingsServiceError,
|
||||||
|
match="Unsupported navigation page id: reports",
|
||||||
|
):
|
||||||
|
parse_nav_visible_pages(DEFAULT_NAV_VISIBLE_PAGES + ["reports"])
|
||||||
Loading…
Add table
Add a link
Reference in a new issue