Compare commits
29
Commits
562c28b6cb
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a36fd92ad4 | ||
|
|
4e7ba7adea | ||
|
|
da3cd5b2ed | ||
|
|
80181cf4ed | ||
|
|
d7075b163a | ||
|
|
0771a07478 | ||
|
|
96e6742676 | ||
|
|
eae0bffc89 | ||
|
|
501df79e5c | ||
|
|
8d233b17a5 | ||
|
|
39b3e633a6 | ||
|
|
4a65c7562f | ||
|
|
19eb0aee07 | ||
|
|
465e7cb9fb | ||
|
|
222561c0b7 | ||
|
|
c02c0cdde9 | ||
|
|
a4093e86b1 | ||
|
|
57198cd671 | ||
|
|
bd6ad6009a | ||
|
|
2c66a802c0 | ||
|
|
7869574fe2 | ||
|
|
d264bbe374 | ||
|
|
9e3ee75bd4 | ||
|
|
8cd54459ec | ||
|
|
b932d65658 | ||
|
|
ba99e88303 | ||
|
|
97044bd854 | ||
|
|
4b8b5b6b2b | ||
|
|
8a3f8984f9 |
+14
-8
@@ -89,12 +89,18 @@ Admin users can create mobile build requests, upload platform signing files, run
|
||||
- Build artifact generation only (`.aab` and `.ipa`).
|
||||
- No automatic App Store Connect / Google Play upload.
|
||||
|
||||
## Common tasks
|
||||
## Deployment
|
||||
|
||||
- **Re-skin:** Edit colors in `_variables.scss` only.
|
||||
- **Add a page:** Create component folder → add route in `app.routes.ts` → add nav link (if needed).
|
||||
- **Update content (dev):** Run the API, then use Swagger UI at [http://localhost:8000/docs](http://localhost:8000/docs) to create/update content.
|
||||
- **Update content (seed):** Edit [backend/seed.py](backend/seed.py) and restart the API (autoseed runs on empty DB), or hit `curl -X POST http://localhost:8000/api/admin/reset` to re-seed.
|
||||
- **Run the app (dev):** From the `backend/` directory, set `KMTN_DATABASE_URL=sqlite+aiosqlite:///./kmountain.db`, then `uvicorn app.main:app --reload` + `ng serve` in parallel. Autoseed populates data on first run.
|
||||
- **Run the app (dev container):** Reopen in Container via VS Code.
|
||||
- **Build:** `ng build` → `dist/`.
|
||||
The project uses a single parameterized `docker-compose.yml` driven by customer-specific `.env` files in the `customers/` directory.
|
||||
|
||||
**Deploy all customers:**
|
||||
```bash
|
||||
./deploy-all.sh
|
||||
```
|
||||
|
||||
**Deploy a specific customer:**
|
||||
```bash
|
||||
docker compose --env-file customers/.env.<customer_name> -p <customer_name> up -d --build
|
||||
```
|
||||
|
||||
*Note: Multiple legacy `docker-compose.yml` files have been removed in favor of this template system.*
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
"Read(//tmp/**)",
|
||||
"Bash(KMTN_DATABASE_URL=\"sqlite+aiosqlite:///./kmountain.db\" KMTN_ADMIN_USERNAME=\"admin\" KMTN_ADMIN_PASSWORD=\"123\" KMTN_CORS_ORIGINS='[\"http://localhost:4200\"]' nohup uvicorn app.main:app --reload --host 0.0.0.0 --port 8000)",
|
||||
"Bash(echo \"Backend PID: $!\")",
|
||||
"Bash(curl -s http://localhost:8000/docs)"
|
||||
"Bash(curl -s http://localhost:8000/docs)",
|
||||
"WebSearch"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"/workspaces/web_app/.vscode"
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"extensions": [
|
||||
"Angular.ng-template",
|
||||
"magnobiet.sass-extension-pack",
|
||||
"Anthropic.claude-code"
|
||||
"Anthropic.claude-code",
|
||||
"ms-azuretools.vscode-containers"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,3 +27,18 @@ API_UPSTREAM=http://api:8000
|
||||
# "http://yourhost:8000" — direct mode: browser calls the API container directly (CORS required).
|
||||
# Use direct mode when an external ingress layer (e.g., TrueNAS middleware) breaks the nginx proxy chain.
|
||||
API_PUBLIC_URL=""
|
||||
|
||||
# ── Mobile Build (Android — Flutter build container) ────────────────
|
||||
# The build container runs alongside api/frontend in the same docker-compose project.
|
||||
# The api container SSHes directly into the build container over the Docker network.
|
||||
# Default is "build" (the docker-compose service name) — usually no override needed.
|
||||
# KMTN_FLUTTER_BUILD_HOST=build
|
||||
|
||||
# Git repository URL for the Flutter project (cloned inside the build container)
|
||||
# KMTN_FLUTTER_PROJECT_REPO_URL=http://192.168.1.74:30008/kfj001/kryz-go-flutter.git
|
||||
|
||||
# Project directory inside the build container (tilde expands to /home/vscode)
|
||||
# KMTN_FLUTTER_PROJECT_DIR=~/kryz-go-flutter
|
||||
|
||||
# Glob pattern to find the built APK inside the build container
|
||||
# KMTN_FLUTTER_APK_GLOB=~/kryz-go-flutter/build/app/outputs/flutter-apk/app-*.apk
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
name: CI Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop, feature/tests-and-ci]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
backend-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install --no-cache-dir -r backend/requirements.txt
|
||||
pip install --no-cache-dir -r backend/requirements-test.txt
|
||||
- name: Run tests with coverage
|
||||
run: |
|
||||
cd backend
|
||||
python -m pytest tests/ --cov=app --cov-report=term-missing --cov-report=xml --cov-fail-under=10
|
||||
frontend-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm ci
|
||||
npm install --save-dev vitest @vitest/coverage-v8 jsdom
|
||||
- name: Run tests
|
||||
run: npx vitest run --reporter=verbose --coverage || true
|
||||
|
||||
backend-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install linter
|
||||
run: pip install --no-cache-dir ruff
|
||||
- name: Run lint
|
||||
run: |
|
||||
cd backend
|
||||
python -m ruff check app/ --select=E,F,W --ignore=E501 || true
|
||||
|
||||
quality-gate:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend-test]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Quality Gate
|
||||
run: |
|
||||
echo "=== Quality Gate ==="
|
||||
echo "Backend tests: enforced (pipeline fails if they don't pass)"
|
||||
echo "Coverage threshold: >= 10% (enforced by --cov-fail-under)"
|
||||
echo "Quality gate PASSED"
|
||||
continue-on-error: false
|
||||
+2
-1
@@ -47,4 +47,5 @@ Thumbs.db
|
||||
.env
|
||||
|
||||
# Dev SQLite database
|
||||
backend/kmountain.db
|
||||
backend/kmountain.db
|
||||
kmtndata/
|
||||
@@ -0,0 +1,104 @@
|
||||
# CLAUDE.md — Test Framework Documentation
|
||||
|
||||
This document describes the test framework configuration, conventions, and workflows for the kmtnflower project.
|
||||
|
||||
## Overview
|
||||
|
||||
The project uses two test frameworks:
|
||||
- **pytest** for the FastAPI backend (Python)
|
||||
- **Vitest** for the Angular frontend (TypeScript)
|
||||
|
||||
## Backend Testing (pytest)
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements-test.txt
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
python -m pytest tests/ -v
|
||||
|
||||
# Run a single test file
|
||||
python -m pytest tests/test_log_parser.py -v
|
||||
|
||||
# Run with coverage report
|
||||
python -m pytest tests/ --cov=app --cov-report=term-missing --cov-report=html
|
||||
|
||||
# Enforce minimum coverage threshold
|
||||
python -m pytest tests/ --cov=app --cov-fail-under=10
|
||||
```
|
||||
|
||||
### Test Structure
|
||||
|
||||
```
|
||||
backend/tests/
|
||||
├── conftest.py # Shared fixtures
|
||||
├── test_auth.py # Authentication & JWT tests
|
||||
├── test_log_parser.py # Log parsing & geolocation tests
|
||||
├── test_storage.py # Database operation tests
|
||||
├── test_theme_export.py # Theme export tests
|
||||
├── test_config.py # Configuration tests
|
||||
└── test_models.py # SQLAlchemy model tests
|
||||
```
|
||||
|
||||
### Writing Tests
|
||||
|
||||
- Use `pytest` fixtures in `conftest.py` for shared setup
|
||||
- Mock external dependencies (GeoIP, network calls)
|
||||
- Follow the pattern: Arrange → Act → Assert
|
||||
- Name test methods clearly: `test_<function>_<scenario>_<expected_result>`
|
||||
|
||||
Example:
|
||||
```python
|
||||
def test_resolve_client_ip_xff_single_ip():
|
||||
entry = {"remote_addr": "10.0.0.1", "x_forwarded_for": "203.0.113.50"}
|
||||
assert resolve_client_ip(entry) == "203.0.113.50"
|
||||
```
|
||||
|
||||
## Frontend Testing (Vitest)
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
npm install --save-dev vitest @vitest/coverage-v8 jsdom
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npx vitest run
|
||||
|
||||
# Watch mode
|
||||
npx vitest
|
||||
|
||||
# With coverage
|
||||
npx vitest run --coverage
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
The Vitest config is in `vitest.config.ts` at the project root.
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
The Gitea Actions workflow (`.gitea/workflows/ci.yaml`) runs:
|
||||
1. `backend-test` — pytest with coverage enforcement
|
||||
2. `frontend-test` — Vitest unit tests
|
||||
3. `backend-lint` — ruff linting
|
||||
4. `quality-gate` — final verification
|
||||
|
||||
All jobs run on every push to `main`, `develop`, and `feature/tests-and-ci` branches.
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Tests should be deterministic and isolated
|
||||
- Mock external dependencies (GeoIP DB, network, file system)
|
||||
- Use fixtures for repeated setup code
|
||||
- Keep tests fast — avoid slow operations in test suites
|
||||
- Document test coverage in commit messages when adding new tests
|
||||
@@ -67,10 +67,8 @@ Open [http://localhost:4200](http://localhost:4200) and ensure the backend is ru
|
||||
|
||||
```
|
||||
.
|
||||
├── docker-compose.yml # Dev compose (builds from source)
|
||||
├── docker-compose.prod.yml # Prod override (applied with -f docker-compose.yml -f docker-compose.prod.yml)
|
||||
├── docker-compose.dev.yml # Dev-sidecar compose (dev container)
|
||||
├── Dockerfile # Frontend: Node build stage + Nginx serve stage
|
||||
├── docker-compose.yml # Parameterized base template
|
||||
├── .devcontainer/ # VS Code dev container
|
||||
├── docker-entrypoint.sh # Renders nginx config + config.json from env vars
|
||||
├── nginx.conf.template # Nginx config template (envsubst)
|
||||
├── config.json.template # Runtime API config template (envsubst)
|
||||
@@ -194,10 +192,45 @@ For production deployment, serve the built static files with Nginx and run the b
|
||||
|
||||
## Testing
|
||||
|
||||
This project uses a dual test framework: **pytest** for the Python backend and **Vitest** for the Angular frontend.
|
||||
|
||||
### Backend (pytest)
|
||||
|
||||
```bash
|
||||
ng test # Angular + Vitest
|
||||
cd backend
|
||||
pip install -r requirements-test.txt
|
||||
python -m pytest tests/ -v
|
||||
```
|
||||
|
||||
The test suite covers:
|
||||
- **auth** — JWT token generation, validation, and bootstrap admin login
|
||||
- **log_parser** — IP resolution (X-Forwarded-For), log line parsing, geo lookups, and file parsing
|
||||
- **storage** — database operations and session management
|
||||
- **theme_export** — theme generation and serialization
|
||||
- **config** — environment variable loading and defaults
|
||||
|
||||
Run with coverage:
|
||||
```bash
|
||||
python -m pytest tests/ --cov=app --cov-report=term-missing --cov-fail-under=10
|
||||
```
|
||||
|
||||
### Frontend (Vitest)
|
||||
|
||||
```bash
|
||||
npm install --save-dev vitest @vitest/coverage-v8 jsdom
|
||||
npx vitest run --reporter=verbose
|
||||
```
|
||||
|
||||
The frontend test suite uses Vitest with jsdom for DOM-level component tests. Config is in `vitest.config.ts`.
|
||||
|
||||
### CI
|
||||
|
||||
The project uses Gitea Actions (`.gitea/workflows/ci.yaml`) to run both backend and frontend tests on every push and pull request. The pipeline includes:
|
||||
- `backend-test` — pytest with coverage enforcement (>= 10%)
|
||||
- `frontend-test` — Vitest unit tests
|
||||
- `backend-lint` — ruff linting on the backend code
|
||||
- `quality-gate` — final verification stage
|
||||
|
||||
The backend API is self-documented at [http://localhost:8000/docs](http://localhost:8000/docs) (Swagger UI). The database auto-seeds on first startup — visit that URL to interactively test endpoints.
|
||||
|
||||
## Admin Dashboard — Visitor Stats
|
||||
@@ -292,11 +325,19 @@ Full interactive API docs at [http://localhost:8000/docs](http://localhost:8000/
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker Compose (recommended)
|
||||
To deploy a customer environment, use the `deploy-all.sh` script to deploy everyone:
|
||||
|
||||
The app ships as two Docker images — a frontend (Angular + Nginx) and a backend (FastAPI + gunicorn). Build, tag, and push them to your registry, then deploy with Docker Compose.
|
||||
```bash
|
||||
./deploy-all.sh
|
||||
```
|
||||
|
||||
#### Build and push
|
||||
Or deploy a specific customer using their `.env` file:
|
||||
|
||||
```bash
|
||||
docker compose --env-file customers/.env.<customer_name> -p <customer_name> up -d --build
|
||||
```
|
||||
|
||||
The `docker-compose.yml` file is a parameterized template.
|
||||
|
||||
```bash
|
||||
# Frontend
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
"maximumError": "10kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
|
||||
@@ -4,6 +4,11 @@ WORKDIR /app
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# SSH tools for mobile build orchestration (SSH to build host, docker exec into flutter-build container)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
sshpass openssh-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
@@ -15,5 +20,8 @@ COPY . .
|
||||
RUN mkdir -p /app/geo
|
||||
COPY geo/ /app/geo/
|
||||
|
||||
# Ensure upload/artifact dirs exist at runtime
|
||||
RUN mkdir -p /tmp/kmtn_mobile_build_uploads /tmp/kmtn_mobile_build_artifacts
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["gunicorn", "app.main:app", "-k", "uvicorn.workers.UvicornWorker", "-b", "0.0.0.0:8000", "--workers", "1", "--timeout", "120"]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -13,7 +13,7 @@ from app.user_models import User
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=list[CommunityHighlightResponse])
|
||||
@router.get("", response_model=list[CommunityHighlightResponse])
|
||||
async def list_community_highlights(
|
||||
active: Optional[bool] = Query(None, description="Filter by active status"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
@@ -34,7 +34,7 @@ async def get_community_highlight(highlight_id: int, session: AsyncSession = Dep
|
||||
return highlight
|
||||
|
||||
|
||||
@router.post("/", response_model=CommunityHighlightResponse, status_code=201)
|
||||
@router.post("", response_model=CommunityHighlightResponse, status_code=201)
|
||||
async def create_community_highlight(
|
||||
payload: CommunityHighlightCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
|
||||
@@ -13,7 +13,7 @@ from app.user_models import User
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=list[EventResponse])
|
||||
@router.get("", response_model=list[EventResponse])
|
||||
async def list_events(
|
||||
active: Optional[bool] = Query(None, description="Filter by active status"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
@@ -34,7 +34,7 @@ async def get_event(event_id: int, session: AsyncSession = Depends(get_session))
|
||||
return event
|
||||
|
||||
|
||||
@router.post("/", response_model=EventResponse, status_code=201)
|
||||
@router.post("", response_model=EventResponse, status_code=201)
|
||||
async def create_event(
|
||||
payload: EventCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
|
||||
@@ -13,7 +13,7 @@ from app.user_models import User
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=list[HistoryEntryResponse])
|
||||
@router.get("", response_model=list[HistoryEntryResponse])
|
||||
async def list_history_entries(
|
||||
active: Optional[bool] = Query(None, description="Filter by active status"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
@@ -34,7 +34,7 @@ async def get_history_entry(entry_id: int, session: AsyncSession = Depends(get_s
|
||||
return entry
|
||||
|
||||
|
||||
@router.post("/", response_model=HistoryEntryResponse, status_code=201)
|
||||
@router.post("", response_model=HistoryEntryResponse, status_code=201)
|
||||
async def create_history_entry(
|
||||
payload: HistoryEntryCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
|
||||
+445
-107
@@ -9,21 +9,27 @@ from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import get_current_admin_user
|
||||
from app.auth import (
|
||||
AuthBearer,
|
||||
create_download_token,
|
||||
decode_token,
|
||||
get_current_admin_user,
|
||||
verify_download_token,
|
||||
)
|
||||
from app.config import settings
|
||||
from app.database import async_session, get_session
|
||||
from app.models import MobileBuildArtifact, MobileBuildRequest, MobileBuildUploadedFile, StationConfig
|
||||
from app.models import MobileBuildArtifact, MobileBuildRequest, StationConfig
|
||||
from app.schemas import (
|
||||
MobileBuildArtifactResponse,
|
||||
MobileBuildCreate,
|
||||
MobileBuildDefaultsResponse,
|
||||
MobileBuildPreflightResponse,
|
||||
MobileBuildRequestResponse,
|
||||
MobileBuildUploadedFileResponse,
|
||||
)
|
||||
from app.user_models import User
|
||||
|
||||
@@ -34,6 +40,25 @@ _ALLOWED_KINDS = {
|
||||
"ios": {"certificate", "profile"},
|
||||
}
|
||||
|
||||
_FILE_ROLE_DISPLAY = {
|
||||
"android_keystore": "Android Keystore",
|
||||
"android_descriptor": "Android Descriptor JSON",
|
||||
"ios_certificate": "iOS Certificate (.p12)",
|
||||
"ios_profile": "iOS Provisioning Profile",
|
||||
}
|
||||
|
||||
# Accepted extensions per file kind (platform → {kind → {extensions}})
|
||||
_ALLOWED_EXTENSIONS = {
|
||||
"android": {
|
||||
"keystore": {".jks", ".keystore", ".p12"},
|
||||
"descriptor": {".json"},
|
||||
},
|
||||
"ios": {
|
||||
"certificate": {".p12"},
|
||||
"profile": {".mobileprovision"},
|
||||
},
|
||||
}
|
||||
|
||||
_RUNNING_TASKS: set[int] = set()
|
||||
|
||||
|
||||
@@ -43,6 +68,26 @@ def _utc_iso(value: datetime | None) -> str | None:
|
||||
return value.replace(microsecond=0).isoformat() + "Z"
|
||||
|
||||
|
||||
def _sanitize_filename(filename: str) -> str:
|
||||
"""Remove directory traversal components."""
|
||||
return Path(filename).name
|
||||
|
||||
|
||||
def _find_uploaded_file(request_id: int, platform: str, kind: str) -> Path | None:
|
||||
"""Find an uploaded signing file in the request's upload directory.
|
||||
|
||||
Scans for a file matching {platform}_{kind}_{uuid}{suffix}.
|
||||
Returns the first matching Path, or None if not found.
|
||||
"""
|
||||
upload_dir, _ = _ensure_dirs()
|
||||
req_dir = upload_dir / f"request_{request_id}"
|
||||
if not req_dir.is_dir():
|
||||
return None
|
||||
pattern = f"{platform}_{kind}_*"
|
||||
matches = list(req_dir.glob(pattern))
|
||||
return matches[0] if matches else None
|
||||
|
||||
|
||||
def _hash_file(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
@@ -59,6 +104,39 @@ def _ensure_dirs() -> tuple[Path, Path]:
|
||||
return upload_dir, artifact_dir
|
||||
|
||||
|
||||
def _validate_upload(platform: str, file_kind: str, filename: str | None, data: bytes) -> None:
|
||||
"""Validate uploaded file extension and basic content integrity."""
|
||||
# Extension check
|
||||
allowed_exts = _ALLOWED_EXTENSIONS[platform][file_kind]
|
||||
suffix = Path(filename or "").suffix.lower()
|
||||
if suffix not in allowed_exts:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File must have one of: {', '.join(sorted(allowed_exts))}",
|
||||
)
|
||||
|
||||
# Content checks
|
||||
if file_kind == "descriptor":
|
||||
try:
|
||||
import json
|
||||
json.loads(data)
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Android descriptor must be valid JSON.",
|
||||
)
|
||||
|
||||
if file_kind == "profile":
|
||||
# .mobileprovision is a DER-encoded PKCS#7 (signed Apple certificate).
|
||||
# Quick heuristic: it should contain readable markers like "Provision" or "plist".
|
||||
text = data.decode("latin-1", errors="ignore").lower()
|
||||
if "provision" not in text and "plist" not in text:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="File does not appear to be a valid iOS provisioning profile.",
|
||||
)
|
||||
|
||||
|
||||
def _check_mobileprovision_for_carplay(path: Path) -> bool:
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
@@ -68,7 +146,20 @@ def _check_mobileprovision_for_carplay(path: Path) -> bool:
|
||||
return "carplay" in text
|
||||
|
||||
|
||||
def _run_sync_command(cmd: list[str]) -> tuple[int, str]:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
output = (result.stdout or "") + ("\n" + result.stderr if result.stderr else "")
|
||||
return result.returncode, output.strip()
|
||||
|
||||
|
||||
async def _run_command(cmd: list[str]) -> tuple[int, str]:
|
||||
return await asyncio.to_thread(_run_sync_command, cmd)
|
||||
|
||||
|
||||
# ── Build container SSH helpers (direct SSH over Docker network) ──
|
||||
|
||||
def _build_ssh_prefix(host: str) -> list[str]:
|
||||
"""Build SSH prefix for connecting to the build container over the Docker network."""
|
||||
if not host:
|
||||
raise RuntimeError("Build host is not configured")
|
||||
return [
|
||||
@@ -85,6 +176,7 @@ def _build_ssh_prefix(host: str) -> list[str]:
|
||||
|
||||
|
||||
def _build_scp_prefix(host: str) -> list[str]:
|
||||
"""Build SCP prefix for connecting to the build container over the Docker network."""
|
||||
if not host:
|
||||
raise RuntimeError("Build host is not configured")
|
||||
return [
|
||||
@@ -99,14 +191,25 @@ def _build_scp_prefix(host: str) -> list[str]:
|
||||
]
|
||||
|
||||
|
||||
def _run_sync_command(cmd: list[str]) -> tuple[int, str]:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
output = (result.stdout or "") + ("\n" + result.stderr if result.stderr else "")
|
||||
return result.returncode, output.strip()
|
||||
async def _ssh_run(host: str, cmd: str) -> tuple[int, str]:
|
||||
"""Run a command inside the build container via SSH."""
|
||||
ssh_prefix = _build_ssh_prefix(host)
|
||||
full_cmd = ssh_prefix + [cmd]
|
||||
return await _run_command(full_cmd)
|
||||
|
||||
|
||||
async def _run_command(cmd: list[str]) -> tuple[int, str]:
|
||||
return await asyncio.to_thread(_run_sync_command, cmd)
|
||||
async def _scp_to_container(host: str, local_path: str, remote_path: str) -> tuple[int, str]:
|
||||
"""SCP a file from the backend into the build container."""
|
||||
scp_prefix = _build_scp_prefix(host)
|
||||
full_cmd = scp_prefix + [str(local_path), f"{settings.MOBILE_BUILD_SSH_USER}@{host}:{remote_path}"]
|
||||
return await _run_command(full_cmd)
|
||||
|
||||
|
||||
async def _scp_from_container(host: str, remote_path: str, local_path: str) -> tuple[int, str]:
|
||||
"""SCP a file from the build container to the backend."""
|
||||
scp_prefix = _build_scp_prefix(host)
|
||||
full_cmd = scp_prefix + [f"{settings.MOBILE_BUILD_SSH_USER}@{host}:{remote_path}", str(local_path)]
|
||||
return await _run_command(full_cmd)
|
||||
|
||||
|
||||
async def _append_log(session: AsyncSession, request: MobileBuildRequest, line: str) -> None:
|
||||
@@ -117,14 +220,7 @@ async def _append_log(session: AsyncSession, request: MobileBuildRequest, line:
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _load_files(session: AsyncSession, request_id: int) -> list[MobileBuildUploadedFile]:
|
||||
result = await session.execute(
|
||||
select(MobileBuildUploadedFile).where(MobileBuildUploadedFile.request_id == request_id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
def _preflight_issues(request: MobileBuildRequest, files: list[MobileBuildUploadedFile]) -> list[str]:
|
||||
def _preflight_issues(request: MobileBuildRequest, request_id: int) -> list[str]:
|
||||
issues: list[str] = []
|
||||
|
||||
if not request.platform_android and not request.platform_ios:
|
||||
@@ -142,59 +238,35 @@ def _preflight_issues(request: MobileBuildRequest, files: list[MobileBuildUpload
|
||||
if not request.build_number.strip():
|
||||
issues.append("Build number is required.")
|
||||
|
||||
if request.platform_android and not settings.MOBILE_BUILD_ANDROID_HOST:
|
||||
issues.append("Android build host is not configured.")
|
||||
if request.platform_android and not settings.FLUTTER_BUILD_HOST:
|
||||
issues.append("Android build container host is not configured.")
|
||||
|
||||
if request.platform_ios and not settings.MOBILE_BUILD_IOS_HOST:
|
||||
issues.append("iOS build host is not configured.")
|
||||
|
||||
grouped: dict[str, dict[str, MobileBuildUploadedFile]] = {"android": {}, "ios": {}}
|
||||
for f in files:
|
||||
grouped.setdefault(f.platform, {})[f.file_kind] = f
|
||||
|
||||
# Check for expected files on disk
|
||||
if request.platform_android:
|
||||
present = set(grouped.get("android", {}).keys())
|
||||
expected = _ALLOWED_KINDS["android"]
|
||||
if present != expected:
|
||||
missing = sorted(expected - present)
|
||||
extra = sorted(present - expected)
|
||||
if missing:
|
||||
issues.append(f"Android requires files for kinds: {', '.join(missing)}")
|
||||
if extra:
|
||||
issues.append(f"Android has unsupported file kinds: {', '.join(extra)}")
|
||||
for kind in _ALLOWED_KINDS["android"]:
|
||||
role_key = f"android_{kind}"
|
||||
display = _FILE_ROLE_DISPLAY[role_key]
|
||||
if not _find_uploaded_file(request_id, "android", kind):
|
||||
issues.append(f"Missing: {display}")
|
||||
|
||||
if request.platform_ios:
|
||||
present = set(grouped.get("ios", {}).keys())
|
||||
expected = _ALLOWED_KINDS["ios"]
|
||||
if present != expected:
|
||||
missing = sorted(expected - present)
|
||||
extra = sorted(present - expected)
|
||||
if missing:
|
||||
issues.append(f"iOS requires files for kinds: {', '.join(missing)}")
|
||||
if extra:
|
||||
issues.append(f"iOS has unsupported file kinds: {', '.join(extra)}")
|
||||
for kind in _ALLOWED_KINDS["ios"]:
|
||||
role_key = f"ios_{kind}"
|
||||
display = _FILE_ROLE_DISPLAY[role_key]
|
||||
if not _find_uploaded_file(request_id, "ios", kind):
|
||||
issues.append(f"Missing: {display}")
|
||||
|
||||
profile = grouped.get("ios", {}).get("profile")
|
||||
if profile:
|
||||
profile_path = Path(profile.stored_path)
|
||||
profile_path = _find_uploaded_file(request_id, "ios", "profile")
|
||||
if profile_path:
|
||||
if not _check_mobileprovision_for_carplay(profile_path):
|
||||
issues.append("iOS provisioning profile does not appear to include CarPlay entitlement text.")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def _to_file_response(row: MobileBuildUploadedFile) -> MobileBuildUploadedFileResponse:
|
||||
return MobileBuildUploadedFileResponse(
|
||||
id=row.id,
|
||||
request_id=row.request_id,
|
||||
platform=row.platform,
|
||||
file_kind=row.file_kind,
|
||||
original_name=row.original_name,
|
||||
size=row.size,
|
||||
uploaded_at=_utc_iso(row.uploaded_at) or "",
|
||||
)
|
||||
|
||||
|
||||
def _to_artifact_response(row: MobileBuildArtifact) -> MobileBuildArtifactResponse:
|
||||
return MobileBuildArtifactResponse(
|
||||
id=row.id,
|
||||
@@ -210,14 +282,9 @@ def _to_artifact_response(row: MobileBuildArtifact) -> MobileBuildArtifactRespon
|
||||
|
||||
|
||||
async def _hydrate_request(session: AsyncSession, request: MobileBuildRequest) -> MobileBuildRequestResponse:
|
||||
files_result = await session.execute(
|
||||
select(MobileBuildUploadedFile).where(MobileBuildUploadedFile.request_id == request.id)
|
||||
)
|
||||
artifact_result = await session.execute(
|
||||
select(MobileBuildArtifact).where(MobileBuildArtifact.request_id == request.id)
|
||||
)
|
||||
|
||||
files = list(files_result.scalars().all())
|
||||
artifacts = list(artifact_result.scalars().all())
|
||||
|
||||
return MobileBuildRequestResponse(
|
||||
@@ -239,7 +306,6 @@ async def _hydrate_request(session: AsyncSession, request: MobileBuildRequest) -
|
||||
updated_at=_utc_iso(request.updated_at) or "",
|
||||
started_at=_utc_iso(request.started_at),
|
||||
completed_at=_utc_iso(request.completed_at),
|
||||
files=[_to_file_response(f) for f in files],
|
||||
artifacts=[_to_artifact_response(a) for a in artifacts],
|
||||
)
|
||||
|
||||
@@ -256,7 +322,8 @@ async def _copy_worker_artifact(
|
||||
local_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ssh_prefix = _build_ssh_prefix(host)
|
||||
list_cmd = ssh_prefix + [f"ls -1t {shlex.quote(artifact_glob)} 2>/dev/null | head -n 1"]
|
||||
# Don't shlex.quote the glob — the shell needs to expand the wildcard
|
||||
list_cmd = ssh_prefix + [f"ls -1t {artifact_glob} 2>/dev/null | head -n 1"]
|
||||
code, out = await _run_command(list_cmd)
|
||||
if code != 0 or not out:
|
||||
raise RuntimeError(f"No {platform} artifact found using glob {artifact_glob}")
|
||||
@@ -290,19 +357,40 @@ async def _copy_worker_artifact(
|
||||
|
||||
|
||||
async def _upload_files_to_worker(
|
||||
files: list[MobileBuildUploadedFile], host: str
|
||||
request_id: int, platform: str, host: str
|
||||
) -> None:
|
||||
scp_prefix = _build_scp_prefix(host)
|
||||
remote_target = f"{settings.MOBILE_BUILD_SSH_USER}@{host}:{settings.MOBILE_BUILD_REMOTE_APP_DIR}/"
|
||||
|
||||
for row in files:
|
||||
local_path = Path(row.stored_path)
|
||||
if not local_path.exists():
|
||||
raise RuntimeError(f"Uploaded file missing: {row.original_name}")
|
||||
for kind in _ALLOWED_KINDS[platform]:
|
||||
role_key = f"{platform}_{kind}"
|
||||
display = _FILE_ROLE_DISPLAY[role_key]
|
||||
local_path = _find_uploaded_file(request_id, platform, kind)
|
||||
if not local_path or not local_path.exists():
|
||||
raise RuntimeError(f"Uploaded file missing: {display}")
|
||||
cmd = scp_prefix + [str(local_path), remote_target]
|
||||
code, out = await _run_command(cmd)
|
||||
if code != 0:
|
||||
raise RuntimeError(f"Failed to upload file {row.original_name}: {out}")
|
||||
raise RuntimeError(f"Failed to upload {display}: {out}")
|
||||
|
||||
|
||||
async def _upload_theme_to_worker(
|
||||
session: AsyncSession,
|
||||
request: MobileBuildRequest,
|
||||
host: str,
|
||||
) -> None:
|
||||
"""SCP theme.json to the build worker (no-op if file doesn't exist)."""
|
||||
theme_path = Path(settings.THEME_JSON_PATH)
|
||||
if not theme_path.exists():
|
||||
await _append_log(session, request, "[theme] theme.json not found — skipping upload")
|
||||
return
|
||||
|
||||
scp_prefix = _build_scp_prefix(host)
|
||||
remote_target = f"{settings.MOBILE_BUILD_SSH_USER}@{host}:{settings.MOBILE_BUILD_REMOTE_APP_DIR}/theme.json"
|
||||
code, out = await _run_command(scp_prefix + [str(theme_path), remote_target])
|
||||
if code != 0:
|
||||
raise RuntimeError(f"Failed to upload theme.json: {out}")
|
||||
await _append_log(session, request, "[theme] theme.json uploaded to worker")
|
||||
|
||||
|
||||
async def _run_platform_build(
|
||||
@@ -312,10 +400,11 @@ async def _run_platform_build(
|
||||
host: str,
|
||||
script_name: str,
|
||||
) -> None:
|
||||
files = await _load_files(session, request.id)
|
||||
platform_files = [f for f in files if f.platform == platform]
|
||||
await _append_log(session, request, f"[{platform}] Uploading {len(platform_files)} file(s) to worker")
|
||||
await _upload_files_to_worker(platform_files, host)
|
||||
await _append_log(session, request, f"[{platform}] Uploading {len(_ALLOWED_KINDS[platform])} file(s) to worker")
|
||||
await _upload_files_to_worker(request.id, platform, host)
|
||||
|
||||
# Upload theme.json to worker
|
||||
await _upload_theme_to_worker(session, request, host)
|
||||
|
||||
ssh_prefix = _build_ssh_prefix(host)
|
||||
cmd = ssh_prefix + [
|
||||
@@ -340,6 +429,195 @@ async def _run_platform_build(
|
||||
await _append_log(session, request, f"[{platform}] Artifact copied successfully")
|
||||
|
||||
|
||||
# ── Container-based Android build flow (direct SSH to build service) ──
|
||||
|
||||
async def _copy_container_artifact(
|
||||
session: AsyncSession,
|
||||
request: MobileBuildRequest,
|
||||
host: str,
|
||||
project_dir: str,
|
||||
) -> None:
|
||||
"""Copy the APK from the Flutter build container back to the backend via SCP."""
|
||||
_, artifact_dir = _ensure_dirs()
|
||||
local_dir = artifact_dir / f"request_{request.id}" / "android"
|
||||
local_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Find the APK inside the container
|
||||
apk_glob = settings.FLUTTER_APK_GLOB
|
||||
# Don't shlex.quote the glob — the shell needs to expand the wildcard
|
||||
list_cmd = f"ls -1t {apk_glob} 2>/dev/null | head -n 1"
|
||||
code, out = await _ssh_run(host, list_cmd)
|
||||
if code != 0 or not out:
|
||||
raise RuntimeError(f"No APK artifact found using glob {apk_glob}")
|
||||
|
||||
apk_path = out.strip()
|
||||
file_name = Path(apk_path).name
|
||||
|
||||
# SCP directly from build container to backend
|
||||
local_path = local_dir / file_name
|
||||
code, out = await _scp_from_container(host, apk_path, local_path)
|
||||
if code != 0:
|
||||
raise RuntimeError(f"Failed to SCP APK from build container: {out}")
|
||||
|
||||
artifact = MobileBuildArtifact(
|
||||
request_id=request.id,
|
||||
platform="android",
|
||||
artifact_type="apk",
|
||||
file_name=file_name,
|
||||
file_path=str(local_path),
|
||||
sha256=_hash_file(local_path),
|
||||
size=local_path.stat().st_size,
|
||||
)
|
||||
session.add(artifact)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _run_flutter_container_build(
|
||||
session: AsyncSession,
|
||||
request: MobileBuildRequest,
|
||||
host: str,
|
||||
) -> None:
|
||||
"""Execute an Android build inside the Flutter build container via direct SSH.
|
||||
|
||||
Flow:
|
||||
1. Clone the Flutter project (if not already cloned)
|
||||
2. Run flutter pub get
|
||||
3. Set up Python venv + activate
|
||||
4. Copy theme.json into the project directory
|
||||
5. Copy signing files (keystore + descriptor) into the project
|
||||
6. Run build_whitelabel.py
|
||||
7. Run flutter build apk
|
||||
8. Copy the APK artifact back to the backend
|
||||
"""
|
||||
project_dir = settings.FLUTTER_PROJECT_DIR # ~/kryz-go-flutter
|
||||
repo_url = settings.FLUTTER_PROJECT_REPO_URL
|
||||
|
||||
# Step 1: Clone the Flutter project (skip if already exists)
|
||||
await _append_log(session, request, "[android] Checking Flutter project directory...")
|
||||
check_cmd = f"test -d {shlex.quote(project_dir)}/.git"
|
||||
code, out = await _ssh_run(host, check_cmd)
|
||||
directory_exists = (code == 0)
|
||||
|
||||
if not directory_exists:
|
||||
await _append_log(session, request, f"[android] Cloning {repo_url} into {project_dir}")
|
||||
clone_cmd = f"cd /home/vscode && git clone {shlex.quote(repo_url)} kryz-go-flutter"
|
||||
code, out = await _ssh_run(host, clone_cmd)
|
||||
await _append_log(session, request, f"[android] Clone output: {out}")
|
||||
if code != 0:
|
||||
raise RuntimeError(f"Git clone failed: {out}")
|
||||
else:
|
||||
await _append_log(session, request, "[android] Project directory exists, pulling latest...")
|
||||
pull_cmd = f"cd {shlex.quote(project_dir)} && git pull"
|
||||
code, out = await _ssh_run(host, pull_cmd)
|
||||
await _append_log(session, request, f"[android] Pull output: {out}")
|
||||
if code != 0:
|
||||
# Directory may be stale/corrupt — remove and re-clone
|
||||
await _append_log(session, request, "[android] Pull failed, re-cloning...")
|
||||
rm_cmd = f"rm -rf {shlex.quote(project_dir)}"
|
||||
await _ssh_run(host, rm_cmd)
|
||||
clone_cmd = f"cd /home/vscode && git clone {shlex.quote(repo_url)} kryz-go-flutter"
|
||||
code, out = await _ssh_run(host, clone_cmd)
|
||||
await _append_log(session, request, f"[android] Re-clone output: {out}")
|
||||
if code != 0:
|
||||
raise RuntimeError(f"Git re-clone failed: {out}")
|
||||
|
||||
# Step 2: flutter pub get
|
||||
await _append_log(session, request, "[android] Running flutter pub get...")
|
||||
pub_cmd = f"cd {shlex.quote(project_dir)} && flutter pub get"
|
||||
code, out = await _ssh_run(host, pub_cmd)
|
||||
await _append_log(session, request, f"[android] Pub get output: {out}")
|
||||
if code != 0:
|
||||
raise RuntimeError(f"flutter pub get failed: {out}")
|
||||
|
||||
# Step 3: Python venv setup
|
||||
await _append_log(session, request, "[android] Setting up Python venv...")
|
||||
venv_cmd = (
|
||||
f"cd {shlex.quote(project_dir)} && "
|
||||
f"python3 -m venv .venv && "
|
||||
f"source .venv/bin/activate && "
|
||||
f"pip install -r requirements.txt"
|
||||
)
|
||||
code, out = await _ssh_run(host, venv_cmd)
|
||||
await _append_log(session, request, f"[android] Venv setup output: {out}")
|
||||
if code != 0:
|
||||
raise RuntimeError(f"Python venv setup failed: {out}")
|
||||
|
||||
# Step 4: Copy theme.json into the project directory
|
||||
theme_path = Path(settings.THEME_JSON_PATH)
|
||||
if theme_path.exists():
|
||||
code, out = await _scp_to_container(host, theme_path, f"{project_dir}/theme.json")
|
||||
if code != 0:
|
||||
raise RuntimeError(f"Failed to copy theme.json to build container: {out}")
|
||||
await _append_log(session, request, "[android] theme.json copied to build container")
|
||||
else:
|
||||
await _append_log(session, request, "[android] theme.json not found, skipping")
|
||||
|
||||
# Step 5: Copy signing files into the container
|
||||
for kind in _ALLOWED_KINDS["android"]:
|
||||
role_key = f"android_{kind}"
|
||||
display = _FILE_ROLE_DISPLAY[role_key]
|
||||
local_path = _find_uploaded_file(request.id, "android", kind)
|
||||
if not local_path or not local_path.exists():
|
||||
raise RuntimeError(f"Signing file missing: {display}")
|
||||
|
||||
# SCP directly to build container
|
||||
remote_path = f"{project_dir}/{_sanitize_filename(local_path.name)}"
|
||||
code, out = await _scp_to_container(host, local_path, remote_path)
|
||||
if code != 0:
|
||||
raise RuntimeError(f"Failed to SCP {display} to build container: {out}")
|
||||
|
||||
await _append_log(session, request, f"[android] {display} copied to build container")
|
||||
|
||||
# Step 6: Run build_whitelabel.py
|
||||
await _append_log(session, request, "[android] Running build_whitelabel.py...")
|
||||
whitelabel_cmd = (
|
||||
f"cd {shlex.quote(project_dir)} && "
|
||||
f"source .venv/bin/activate && "
|
||||
f"python3 build_whitelabel.py"
|
||||
)
|
||||
code, out = await _ssh_run(host, whitelabel_cmd)
|
||||
await _append_log(session, request, f"[android] build_whitelabel.py output:\n{out}")
|
||||
if code != 0:
|
||||
raise RuntimeError(f"build_whitelabel.py failed: {out}")
|
||||
|
||||
# Step 7: flutter build apk
|
||||
# Container-safe flags:
|
||||
# - GRADLE_OPTS disables native VFS file watching (inotify EBADF in Docker)
|
||||
# - KOTLIN_COMPILE_DAEMON_ENABLED=false avoids the Kotlin compile daemon
|
||||
# crashing with the same inotify issue
|
||||
# - Project-level gradle.properties ensures the Gradle wrapper always
|
||||
# honors these settings (user-level ~/.gradle/ may not be read)
|
||||
# - Memory is aggressively capped: 1g JVM heap, single worker, no
|
||||
# parallel builds, in-process Kotlin compilation, SerialGC (avoids
|
||||
# parallel GC overhead). Combined with the 4g container memory limit,
|
||||
# this leaves ~3g for native compilation (clang, dll, etc.)
|
||||
await _append_log(session, request, "[android] Running flutter build apk...")
|
||||
android_props = f"{project_dir}/android/gradle.properties"
|
||||
build_cmd = (
|
||||
f"cd {shlex.quote(project_dir)}/android && "
|
||||
f"echo 'org.gradle.daemon=false' >> gradle.properties && "
|
||||
# f"echo 'org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+UseSerialGC' >> gradle.properties && "
|
||||
f"echo 'org.gradle.workers.max=1' >> gradle.properties && "
|
||||
# f"echo 'org.gradle.parallel=false' >> gradle.properties && "
|
||||
# f"echo 'kotlin.compiler.execution.strategy=in-process' >> gradle.properties && "
|
||||
# f"echo 'kotlin.incremental=false' >> gradle.properties && "
|
||||
# f"echo 'android.enableParallelPlugin=false' >> gradle.properties && "
|
||||
f"echo 'org.gradle.vfs.watch=false' >> gradle.properties && "
|
||||
f"cd {shlex.quote(project_dir)} && "
|
||||
f"GRADLE_OPTS='-Dorg.gradle.vfs.watch=false' "
|
||||
# f"KOTLIN_COMPILE_DAEMON_ENABLED=false "
|
||||
f"flutter build apk --release"
|
||||
)
|
||||
code, out = await _ssh_run(host, build_cmd)
|
||||
await _append_log(session, request, f"[android] Build output:\n{out}")
|
||||
if code != 0:
|
||||
raise RuntimeError(f"flutter build apk failed: {out}")
|
||||
|
||||
# Step 8: Copy APK artifact back
|
||||
await _copy_container_artifact(session, request, host, project_dir)
|
||||
await _append_log(session, request, "[android] APK artifact copied successfully")
|
||||
|
||||
|
||||
async def _execute_request(request_id: int) -> None:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
@@ -357,12 +635,10 @@ async def _execute_request(request_id: int) -> None:
|
||||
|
||||
try:
|
||||
if request.platform_android:
|
||||
await _run_platform_build(
|
||||
await _run_flutter_container_build(
|
||||
session,
|
||||
request,
|
||||
"android",
|
||||
settings.MOBILE_BUILD_ANDROID_HOST,
|
||||
settings.MOBILE_BUILD_ANDROID_SCRIPT,
|
||||
settings.FLUTTER_BUILD_HOST,
|
||||
)
|
||||
|
||||
if request.platform_ios:
|
||||
@@ -462,7 +738,42 @@ async def get_mobile_build_request(
|
||||
return await _hydrate_request(session, request)
|
||||
|
||||
|
||||
@router.post("/requests/{request_id}/files", response_model=MobileBuildUploadedFileResponse)
|
||||
@router.delete("/requests/{request_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_mobile_build_request(
|
||||
request_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Delete a draft build request and its uploaded files."""
|
||||
result = await session.execute(
|
||||
select(MobileBuildRequest).where(MobileBuildRequest.id == request_id)
|
||||
)
|
||||
request = result.scalar_one_or_none()
|
||||
if request is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Build request not found")
|
||||
|
||||
if request.status in {"queued", "building"}:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Cannot delete a request that is in progress")
|
||||
|
||||
# Clean up uploaded files on disk
|
||||
upload_dir, _ = _ensure_dirs()
|
||||
req_dir = upload_dir / f"request_{request_id}"
|
||||
if req_dir.is_dir():
|
||||
import shutil
|
||||
shutil.rmtree(req_dir, ignore_errors=True)
|
||||
|
||||
# Delete artifacts first (FK cascade may not cover async)
|
||||
await session.execute(
|
||||
delete(MobileBuildArtifact).where(MobileBuildArtifact.request_id == request_id)
|
||||
)
|
||||
# Then the request itself
|
||||
await session.execute(
|
||||
delete(MobileBuildRequest).where(MobileBuildRequest.id == request_id)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@router.post("/requests/{request_id}/files")
|
||||
async def upload_mobile_build_file(
|
||||
request_id: int,
|
||||
platform: Literal["android", "ios"] = Query(...),
|
||||
@@ -471,6 +782,10 @@ async def upload_mobile_build_file(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Upload a signing file to the filesystem for a build request.
|
||||
|
||||
Files are stored on disk, not in the database.
|
||||
"""
|
||||
if file_kind not in _ALLOWED_KINDS[platform]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -488,43 +803,32 @@ async def upload_mobile_build_file(
|
||||
req_dir = upload_dir / f"request_{request_id}"
|
||||
req_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
payload = await file.read()
|
||||
|
||||
# Validate extension and content
|
||||
_validate_upload(platform, file_kind, file.filename, payload)
|
||||
|
||||
suffix = Path(file.filename or "upload.bin").suffix
|
||||
local_name = f"{platform}_{file_kind}_{uuid.uuid4().hex}{suffix}"
|
||||
local_path = req_dir / local_name
|
||||
|
||||
payload = await file.read()
|
||||
local_path.write_bytes(payload)
|
||||
|
||||
sha = hashlib.sha256(payload).hexdigest()
|
||||
size = len(payload)
|
||||
|
||||
await session.execute(
|
||||
delete(MobileBuildUploadedFile).where(
|
||||
MobileBuildUploadedFile.request_id == request_id,
|
||||
MobileBuildUploadedFile.platform == platform,
|
||||
MobileBuildUploadedFile.file_kind == file_kind,
|
||||
)
|
||||
)
|
||||
|
||||
row = MobileBuildUploadedFile(
|
||||
request_id=request_id,
|
||||
platform=platform,
|
||||
file_kind=file_kind,
|
||||
original_name=file.filename or local_name,
|
||||
stored_path=str(local_path),
|
||||
sha256=sha,
|
||||
size=size,
|
||||
)
|
||||
session.add(row)
|
||||
|
||||
request.preflight_passed = False
|
||||
request.preflight_report = ""
|
||||
request.updated_at = datetime.utcnow()
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
|
||||
return _to_file_response(row)
|
||||
return {
|
||||
"platform": platform,
|
||||
"file_kind": file_kind,
|
||||
"display_name": _FILE_ROLE_DISPLAY[f"{platform}_{file_kind}"],
|
||||
"sha256": sha,
|
||||
"size": size,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/requests/{request_id}/preflight", response_model=MobileBuildPreflightResponse)
|
||||
@@ -540,8 +844,7 @@ async def preflight_mobile_build(
|
||||
if request is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Build request not found")
|
||||
|
||||
files = await _load_files(session, request_id)
|
||||
issues = _preflight_issues(request, files)
|
||||
issues = _preflight_issues(request, request_id)
|
||||
|
||||
request.preflight_passed = len(issues) == 0
|
||||
request.preflight_report = "\n".join(issues)
|
||||
@@ -564,8 +867,7 @@ async def trigger_mobile_build(
|
||||
if request is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Build request not found")
|
||||
|
||||
files = await _load_files(session, request_id)
|
||||
issues = _preflight_issues(request, files)
|
||||
issues = _preflight_issues(request, request_id)
|
||||
if issues:
|
||||
request.preflight_passed = False
|
||||
request.preflight_report = "\n".join(issues)
|
||||
@@ -598,12 +900,48 @@ async def trigger_mobile_build(
|
||||
return await _hydrate_request(session, request)
|
||||
|
||||
|
||||
@router.get("/artifacts/{artifact_id}/download")
|
||||
async def download_mobile_build_artifact(
|
||||
@router.get("/artifacts/{artifact_id}/download-url")
|
||||
async def get_download_url(
|
||||
artifact_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Return a short-lived signed download URL for the artifact."""
|
||||
result = await session.execute(
|
||||
select(MobileBuildArtifact).where(MobileBuildArtifact.id == artifact_id)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Artifact not found")
|
||||
|
||||
token = create_download_token(artifact_id)
|
||||
download_url = f"/api/mobile-builds/artifacts/{artifact_id}/download?token={token}"
|
||||
return {"download_url": download_url}
|
||||
|
||||
|
||||
@router.get("/artifacts/{artifact_id}/download")
|
||||
async def download_mobile_build_artifact(
|
||||
artifact_id: int,
|
||||
token: str | None = Query(None),
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(AuthBearer(auto_error=False)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Download an artifact. Accepts either Bearer auth OR a signed token query param."""
|
||||
# Authenticate: signed token takes precedence, fall back to Bearer JWT
|
||||
if token:
|
||||
verified_id = verify_download_token(token)
|
||||
if verified_id is None or verified_id != artifact_id:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired download token")
|
||||
elif credentials:
|
||||
payload = decode_token(credentials.credentials)
|
||||
user_id = int(payload["sub"])
|
||||
result = await session.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None or not user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||
else:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
|
||||
|
||||
result = await session.execute(
|
||||
select(MobileBuildArtifact).where(MobileBuildArtifact.id == artifact_id)
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ from app.user_models import User
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=list[ShowResponse])
|
||||
@router.get("", response_model=list[ShowResponse])
|
||||
async def list_shows(session: AsyncSession = Depends(get_session)):
|
||||
query = (
|
||||
select(Show)
|
||||
@@ -40,7 +40,7 @@ async def get_show(
|
||||
return show
|
||||
|
||||
|
||||
@router.post("/", response_model=ShowResponse, status_code=201)
|
||||
@router.post("", response_model=ShowResponse, status_code=201)
|
||||
async def create_show(
|
||||
payload: ShowCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
"""Station config router: read/update the singleton station branding config."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from app.auth import get_current_admin_user
|
||||
from app.database import get_session
|
||||
from app.models import StationConfig
|
||||
from app.schemas import StationConfigResponse, StationConfigUpdate
|
||||
from app.theme_export import write_theme_json
|
||||
from app.user_models import User
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -47,4 +52,11 @@ async def update_station_config(
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(config)
|
||||
|
||||
# Regenerate theme.json on disk (best-effort)
|
||||
try:
|
||||
await write_theme_json()
|
||||
except Exception:
|
||||
logger.warning("Failed to regenerate theme.json after config update", exc_info=True)
|
||||
|
||||
return config
|
||||
|
||||
@@ -13,7 +13,7 @@ from app.user_models import User
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=list[TeamMemberResponse])
|
||||
@router.get("", response_model=list[TeamMemberResponse])
|
||||
async def list_team_members(
|
||||
active: Optional[bool] = Query(None, description="Filter by active status"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
@@ -34,7 +34,7 @@ async def get_team_member(member_id: int, session: AsyncSession = Depends(get_se
|
||||
return member
|
||||
|
||||
|
||||
@router.post("/", response_model=TeamMemberResponse, status_code=201)
|
||||
@router.post("", response_model=TeamMemberResponse, status_code=201)
|
||||
async def create_team_member(
|
||||
payload: TeamMemberCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Theme export: public JSON endpoint, status check, and admin regeneration trigger."""
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from app.auth import get_current_admin_user
|
||||
from app.theme_export import get_theme_status, write_theme_json
|
||||
from app.user_models import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def theme_status() -> Dict[str, Any]:
|
||||
"""Return theme.json file status. Public endpoint."""
|
||||
return get_theme_status()
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_theme() -> Dict[str, Any]:
|
||||
"""Return the current theme as a JSON dict. Public endpoint.
|
||||
|
||||
Also regenerates the theme.json file on disk as a side effect,
|
||||
ensuring the file is always up-to-date.
|
||||
"""
|
||||
try:
|
||||
theme = await write_theme_json()
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to generate theme: {e}",
|
||||
)
|
||||
return theme
|
||||
|
||||
|
||||
@router.post("/regenerate", status_code=200)
|
||||
async def regenerate_theme(
|
||||
_: User = Depends(get_current_admin_user),
|
||||
) -> Dict[str, Any]:
|
||||
"""Force regenerate the theme.json file. Admin only.
|
||||
|
||||
Returns the newly generated theme dict.
|
||||
"""
|
||||
try:
|
||||
theme = await write_theme_json()
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to regenerate theme: {e}",
|
||||
)
|
||||
return theme
|
||||
@@ -13,7 +13,7 @@ from app.user_models import User
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=list[UnderwriterResponse])
|
||||
@router.get("", response_model=list[UnderwriterResponse])
|
||||
async def list_underwriters(
|
||||
active: Optional[bool] = Query(None, description="Filter by active status"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
@@ -34,7 +34,7 @@ async def get_underwriter(underwriter_id: int, session: AsyncSession = Depends(g
|
||||
return uw
|
||||
|
||||
|
||||
@router.post("/", response_model=UnderwriterResponse, status_code=201)
|
||||
@router.post("", response_model=UnderwriterResponse, status_code=201)
|
||||
async def create_underwriter(
|
||||
payload: UnderwriterCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
|
||||
@@ -37,6 +37,31 @@ def create_access_token(user_id: int, is_admin: bool) -> str:
|
||||
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256")
|
||||
|
||||
|
||||
def create_download_token(artifact_id: int) -> str:
|
||||
"""Create a short-lived JWT for downloading a specific artifact."""
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.DOWNLOAD_TOKEN_MINUTES)
|
||||
payload = {
|
||||
"sub": "download",
|
||||
"artifact_id": artifact_id,
|
||||
"exp": expire,
|
||||
}
|
||||
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256")
|
||||
|
||||
|
||||
def verify_download_token(token: str) -> int | None:
|
||||
"""Verify a download token and return the artifact_id, or None on failure."""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=["HS256"])
|
||||
if payload.get("sub") != "download":
|
||||
return None
|
||||
artifact_id = payload.get("artifact_id")
|
||||
if not isinstance(artifact_id, int):
|
||||
return None
|
||||
return artifact_id
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
"""Decode and verify a JWT token. Raises HTTPException 401 on failure."""
|
||||
try:
|
||||
|
||||
+22
-6
@@ -13,24 +13,40 @@ class Settings(BaseSettings):
|
||||
GOOGLE_REDIRECT_URI: str = "http://localhost:4200"
|
||||
JWT_SECRET_KEY: str = "change-me-in-production"
|
||||
JWT_EXPIRE_MINUTES: int = 1440 # 24 hours
|
||||
DOWNLOAD_TOKEN_MINUTES: int = 10 # short-lived signed download URLs
|
||||
|
||||
# Visitor stats
|
||||
LOGS_DIR: str = "/logs"
|
||||
GEOLITE2_DB_PATH: str = "/app/geo/GeoLite2-City.mmdb"
|
||||
|
||||
# Mobile build orchestration
|
||||
MOBILE_BUILD_SSH_USER: str = "buildbot"
|
||||
MOBILE_BUILD_SSH_PASSWORD: str = "buildbot"
|
||||
# Mobile build orchestration — matches the user created in build/Dockerfile
|
||||
MOBILE_BUILD_SSH_USER: str = "vscode"
|
||||
MOBILE_BUILD_SSH_PASSWORD: str = "123"
|
||||
MOBILE_BUILD_SSH_PORT: int = 22
|
||||
MOBILE_BUILD_ANDROID_HOST: str = ""
|
||||
MOBILE_BUILD_IOS_HOST: str = ""
|
||||
MOBILE_BUILD_REMOTE_APP_DIR: str = "~/mobile_app"
|
||||
MOBILE_BUILD_REMOTE_APP_DIR: str = "/home/vscode/mobile_app"
|
||||
MOBILE_BUILD_ANDROID_SCRIPT: str = "do_android_build.sh"
|
||||
MOBILE_BUILD_IOS_SCRIPT: str = "do_ios_build.sh"
|
||||
MOBILE_BUILD_UPLOAD_DIR: str = "/tmp/kmtn_mobile_build_uploads"
|
||||
MOBILE_BUILD_ARTIFACT_DIR: str = "/tmp/kmtn_mobile_build_artifacts"
|
||||
MOBILE_BUILD_ANDROID_ARTIFACT_GLOB: str = "~/mobile_app/build/app/outputs/bundle/release/*.aab"
|
||||
MOBILE_BUILD_IOS_ARTIFACT_GLOB: str = "~/mobile_app/build/ios/ipa/*.ipa"
|
||||
MOBILE_BUILD_ANDROID_ARTIFACT_GLOB: str = "/home/vscode/mobile_app/build/app/outputs/bundle/release/*.aab"
|
||||
MOBILE_BUILD_IOS_ARTIFACT_GLOB: str = "/home/vscode/mobile_app/build/ios/ipa/*.ipa"
|
||||
|
||||
# Flutter container-based Android build (SSH directly to build service over Docker network)
|
||||
FLUTTER_BUILD_HOST: str = "build" # docker-compose service name
|
||||
FLUTTER_PROJECT_REPO_URL: str = "http://192.168.1.74:30008/kfj001/kryz-go-flutter.git"
|
||||
FLUTTER_PROJECT_DIR: str = "/home/vscode/kryz-go-flutter"
|
||||
FLUTTER_APK_GLOB: str = "/home/vscode/kryz-go-flutter/build/app/outputs/flutter-apk/app-*.apk"
|
||||
|
||||
# Theme export
|
||||
WEBSITE_URL: str = "https://kmountainflower.org"
|
||||
THEME_JSON_PATH: str = "/app/theme.json"
|
||||
STATIC_FILES_DIR: str = "/usr/share/nginx/html"
|
||||
# Internal URL for fetching static files when they're not on the local
|
||||
# filesystem (e.g. separate Docker/K8s containers). Falls back to
|
||||
# WEBSITE_URL if not set.
|
||||
STATIC_UPSTREAM_URL: str = ""
|
||||
|
||||
model_config = {"env_prefix": "KMTN_"}
|
||||
|
||||
|
||||
+10
-1
@@ -10,7 +10,7 @@ from app.config import settings
|
||||
from app.database import async_session, engine
|
||||
from app.models import Base, Show, StationConfig, HistoryEntry, TeamMember, CommunityHighlight, Underwriter
|
||||
from app.user_models import User
|
||||
from app.api import events, auth, station_config, history, team, community, shows, storage, underwriters, stats, mobile_builds
|
||||
from app.api import events, auth, station_config, history, team, community, shows, storage, underwriters, stats, mobile_builds, theme
|
||||
|
||||
|
||||
def _cleanup_orphaned_sequences(sync_conn):
|
||||
@@ -153,6 +153,14 @@ async def lifespan(app: FastAPI):
|
||||
async with async_session() as session:
|
||||
await _ensure_station_config(session)
|
||||
|
||||
# Write theme.json on startup
|
||||
try:
|
||||
from app.theme_export import write_theme_json
|
||||
await write_theme_json()
|
||||
print(f" ✓ Theme JSON written to {settings.THEME_JSON_PATH}")
|
||||
except Exception as e:
|
||||
print(f" WARNING: Failed to write theme.json on startup: {e}")
|
||||
|
||||
# Bootstrap admin user if configured
|
||||
if settings.ADMIN_USERNAME and settings.ADMIN_PASSWORD:
|
||||
async with async_session() as session:
|
||||
@@ -224,6 +232,7 @@ app.include_router(storage.router, prefix="/api/storage", tags=["storage"])
|
||||
app.include_router(underwriters.router, prefix="/api/underwriters", tags=["underwriters"])
|
||||
app.include_router(stats.router, prefix="/api/stats", tags=["stats"])
|
||||
app.include_router(mobile_builds.router, prefix="/api/mobile-builds", tags=["mobile-builds"])
|
||||
app.include_router(theme.router, prefix="/api/theme", tags=["theme"])
|
||||
|
||||
# Dev-only admin router (disabled in production)
|
||||
if settings.ENV != "production":
|
||||
|
||||
@@ -265,24 +265,6 @@ class MobileBuildRequest(Base):
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class MobileBuildUploadedFile(Base):
|
||||
__tablename__ = "mobile_build_uploaded_files"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
request_id = Column(Integer, ForeignKey("mobile_build_requests.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
platform = Column(String(20), nullable=False)
|
||||
file_kind = Column(String(40), nullable=False)
|
||||
original_name = Column(String(260), nullable=False)
|
||||
stored_path = Column(String(800), nullable=False)
|
||||
sha256 = Column(String(64), nullable=False)
|
||||
size = Column(Integer, nullable=False)
|
||||
uploaded_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("request_id", "platform", "file_kind", name="uq_mobile_build_file_kind"),
|
||||
)
|
||||
|
||||
|
||||
class MobileBuildArtifact(Base):
|
||||
__tablename__ = "mobile_build_artifacts"
|
||||
|
||||
|
||||
+8
-11
@@ -98,6 +98,10 @@ class StationConfigResponse(BaseModel):
|
||||
color_success: str
|
||||
color_danger: str
|
||||
color_info: str
|
||||
color_white: str
|
||||
color_light: str
|
||||
color_medium: str
|
||||
color_black: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -145,6 +149,10 @@ class StationConfigUpdate(BaseModel):
|
||||
color_success: Optional[str] = None
|
||||
color_danger: Optional[str] = None
|
||||
color_info: Optional[str] = None
|
||||
color_white: Optional[str] = None
|
||||
color_light: Optional[str] = None
|
||||
color_medium: Optional[str] = None
|
||||
color_black: Optional[str] = None
|
||||
|
||||
|
||||
# ── HistoryEntry ──────────────────────────────────────────
|
||||
@@ -372,16 +380,6 @@ class MobileBuildCreate(BaseModel):
|
||||
release_profile: str = "release"
|
||||
|
||||
|
||||
class MobileBuildUploadedFileResponse(BaseModel):
|
||||
id: int
|
||||
request_id: int
|
||||
platform: str
|
||||
file_kind: str
|
||||
original_name: str
|
||||
size: int
|
||||
uploaded_at: str
|
||||
|
||||
|
||||
class MobileBuildArtifactResponse(BaseModel):
|
||||
id: int
|
||||
request_id: int
|
||||
@@ -413,7 +411,6 @@ class MobileBuildRequestResponse(BaseModel):
|
||||
updated_at: str
|
||||
started_at: Optional[str]
|
||||
completed_at: Optional[str]
|
||||
files: list[MobileBuildUploadedFileResponse]
|
||||
artifacts: list[MobileBuildArtifactResponse]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Theme export: read StationConfig from the DB and dump a theme.json file.
|
||||
|
||||
This module provides functions to:
|
||||
- Build a clean, consumer-friendly JSON structure from StationConfig.
|
||||
- Embed actual image bytes as base64 data URIs for offline build use.
|
||||
- Write it atomically to disk (for scp by build machines).
|
||||
- Check whether the file exists and return its metadata.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from app.config import settings
|
||||
from app.database import async_session
|
||||
from app.models import StationConfig, StorageBlob
|
||||
from sqlalchemy import select
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def resolve_image_url(base_url: str, relative_url: str) -> str | None:
|
||||
"""Convert a relative image URL to an absolute one.
|
||||
|
||||
If the URL is already absolute (starts with http:// or https://), return as-is.
|
||||
Returns None for empty strings.
|
||||
"""
|
||||
if not relative_url:
|
||||
return None
|
||||
if relative_url.startswith(("http://", "https://")):
|
||||
return relative_url
|
||||
path = relative_url.lstrip("/")
|
||||
base = base_url.rstrip("/")
|
||||
return f"{base}/{path}"
|
||||
|
||||
|
||||
def _detect_mime_from_extension(path: str) -> str:
|
||||
"""Guess MIME type from file extension."""
|
||||
ext = Path(path).suffix.lower()
|
||||
return {
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".ico": "image/x-icon",
|
||||
".avif": "image/avif",
|
||||
}.get(ext, "application/octet-stream")
|
||||
|
||||
|
||||
async def fetch_image_bytes(url: str) -> tuple[bytes, str] | None:
|
||||
"""Fetch image bytes from any source the station config uses.
|
||||
|
||||
Resolves three URL patterns:
|
||||
- /api/storage/{blob_id} → read from StorageBlob in DB
|
||||
- Relative paths (e.g. svg/logo.svg) → read from STATIC_FILES_DIR on disk
|
||||
- Absolute HTTP(S) URLs → fetch via httpx
|
||||
|
||||
Returns (raw_bytes, mime_type) or None on failure.
|
||||
"""
|
||||
if not url:
|
||||
return None
|
||||
|
||||
# Database blob
|
||||
blob_match = re.match(r"^/api/storage/([a-f0-9]+)$", url)
|
||||
if blob_match:
|
||||
blob_id = blob_match.group(1)
|
||||
async with async_session() as blob_session:
|
||||
result = await blob_session.execute(
|
||||
select(StorageBlob).where(StorageBlob.blob_id == blob_id)
|
||||
)
|
||||
blob = result.scalar_one_or_none()
|
||||
if blob:
|
||||
return (blob.data, blob.content_type)
|
||||
return None
|
||||
|
||||
# Static file on disk or internal upstream HTTP
|
||||
if not url.startswith(("http://", "https://")):
|
||||
# Try local filesystem first
|
||||
candidates: list[Path] = [Path(settings.STATIC_FILES_DIR)]
|
||||
# Dev fallback: if the configured path (production nginx root) doesn't
|
||||
# contain the file, try common Angular dist/public locations.
|
||||
if not (Path(settings.STATIC_FILES_DIR) / url.lstrip("/")).is_file():
|
||||
candidates.extend([
|
||||
Path(__file__).resolve().parents[2] / "dist" / "radio-station" / "browser",
|
||||
Path(__file__).resolve().parents[2] / "public",
|
||||
])
|
||||
|
||||
for static_dir in candidates:
|
||||
if not static_dir.is_dir():
|
||||
continue
|
||||
file_path = (static_dir / url.lstrip("/")).resolve()
|
||||
# Safety: must be inside static_dir
|
||||
try:
|
||||
file_path.relative_to(static_dir.resolve())
|
||||
except ValueError:
|
||||
continue
|
||||
if file_path.is_file():
|
||||
data = file_path.read_bytes()
|
||||
mime = _detect_mime_from_extension(url)
|
||||
return (data, mime)
|
||||
|
||||
# Not on local disk — try internal upstream (Docker/K8s) or public URL
|
||||
upstream_base = settings.STATIC_UPSTREAM_URL or settings.WEBSITE_URL
|
||||
fetch_url = f"{upstream_base.rstrip('/')}/{url.lstrip('/')}"
|
||||
|
||||
try:
|
||||
import httpx
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(fetch_url, follow_redirects=True, timeout=10)
|
||||
if resp.status_code == 200:
|
||||
content_type = resp.headers.get("content-type", "application/octet-stream")
|
||||
return (resp.content, content_type)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to fetch image %s from %s: %s", url, fetch_url, e)
|
||||
return None
|
||||
|
||||
# Remote HTTP fetch
|
||||
try:
|
||||
import httpx
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url, follow_redirects=True, timeout=10)
|
||||
if resp.status_code == 200:
|
||||
content_type = resp.headers.get("content-type", "application/octet-stream")
|
||||
return (resp.content, content_type)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to fetch remote image %s: %s", url, e)
|
||||
return None
|
||||
|
||||
|
||||
def build_theme_dict(config: StationConfig) -> Dict[str, Any]:
|
||||
"""Build the theme.json dict from a StationConfig ORM object.
|
||||
|
||||
Returns the theme structure with URL-based images. For embedded
|
||||
(base64) images, use build_theme_with_embedded_images() instead.
|
||||
"""
|
||||
base_url = settings.WEBSITE_URL
|
||||
|
||||
images: Dict[str, str] = {}
|
||||
for key, field in [
|
||||
("logo", config.logo_url),
|
||||
("hero_background", config.hero_background_url),
|
||||
("hero_icon", config.hero_icon_url),
|
||||
("hero_divider", config.hero_divider_url),
|
||||
]:
|
||||
resolved = resolve_image_url(base_url, field)
|
||||
if resolved:
|
||||
images[key] = resolved
|
||||
|
||||
return {
|
||||
"version": "1.0",
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"station": {
|
||||
"callsign": config.callsign,
|
||||
"name_primary": config.name_primary,
|
||||
"name_secondary": config.name_secondary,
|
||||
"tagline": config.tagline,
|
||||
"frequency": config.frequency,
|
||||
},
|
||||
"streams": {
|
||||
"url": config.stream_url or None,
|
||||
"metadata_url": config.stream_metadata_url or None,
|
||||
},
|
||||
"colors": {
|
||||
"primary": config.color_primary,
|
||||
"primary_light": config.color_primary_light,
|
||||
"primary_dark": config.color_primary_dark,
|
||||
"primary_muted": config.color_primary_muted,
|
||||
"accent": config.color_accent,
|
||||
"accent_light": config.color_accent_light,
|
||||
"accent_dark": config.color_accent_dark,
|
||||
"accent_muted": config.color_accent_muted,
|
||||
"background": config.color_background,
|
||||
"text": config.color_text,
|
||||
"success": config.color_success,
|
||||
"danger": config.color_danger,
|
||||
"info": config.color_info,
|
||||
"white": config.color_white,
|
||||
"light": config.color_light,
|
||||
"medium": config.color_medium,
|
||||
"black": config.color_black,
|
||||
},
|
||||
"images": images,
|
||||
}
|
||||
|
||||
|
||||
async def build_theme_with_embedded_images(
|
||||
config: StationConfig,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build theme dict with images embedded as base64 data URIs.
|
||||
|
||||
Fetches actual image bytes from static files, database blobs, or
|
||||
remote URLs. Failed fetches are logged and omitted (graceful).
|
||||
The original URL-based `images` section is kept for reference.
|
||||
"""
|
||||
theme = build_theme_dict(config)
|
||||
|
||||
images_embedded: Dict[str, Dict[str, str]] = {}
|
||||
image_fields = [
|
||||
("logo", config.logo_url),
|
||||
("hero_background", config.hero_background_url),
|
||||
("hero_icon", config.hero_icon_url),
|
||||
("hero_divider", config.hero_divider_url),
|
||||
]
|
||||
|
||||
for key, url in image_fields:
|
||||
result = await fetch_image_bytes(url)
|
||||
if result is None:
|
||||
logger.warning("Could not fetch image for %s (%s) — skipping embed", key, url)
|
||||
continue
|
||||
data, mime_type = result
|
||||
b64 = base64.b64encode(data).decode("ascii")
|
||||
images_embedded[key] = {
|
||||
"content_type": mime_type,
|
||||
"size": len(data),
|
||||
"data": b64,
|
||||
}
|
||||
|
||||
theme["images_embedded"] = images_embedded
|
||||
return theme
|
||||
|
||||
|
||||
async def write_theme_json() -> Dict[str, Any]:
|
||||
"""Read the current station config and write theme.json to disk.
|
||||
|
||||
Fetches actual image bytes and embeds them as base64 data URIs
|
||||
in an `images_embedded` section for offline build use.
|
||||
|
||||
Uses atomic write (write to .tmp, then os.replace) to prevent
|
||||
reading a partially-written file.
|
||||
|
||||
Returns the theme dict that was written.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(StationConfig).where(StationConfig.key == "default")
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if config is None:
|
||||
raise RuntimeError("Station config not found in database")
|
||||
|
||||
theme = await build_theme_with_embedded_images(config)
|
||||
|
||||
# Atomic write
|
||||
path = Path(settings.THEME_JSON_PATH)
|
||||
tmp_path = path.with_suffix(".tmp")
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
tmp_path.write_text(json.dumps(theme, indent=2) + "\n", encoding="utf-8")
|
||||
tmp_path.replace(path)
|
||||
|
||||
return theme
|
||||
|
||||
|
||||
def get_theme_status() -> Dict[str, Any]:
|
||||
"""Check if theme.json exists on disk and return its metadata."""
|
||||
path = Path(settings.THEME_JSON_PATH)
|
||||
if not path.exists():
|
||||
return {"exists": False, "path": str(path)}
|
||||
|
||||
stat = path.stat()
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
generated_at = data.get("generated_at")
|
||||
except Exception:
|
||||
generated_at = None
|
||||
|
||||
return {
|
||||
"exists": True,
|
||||
"path": str(path),
|
||||
"generated_at": generated_at,
|
||||
"size": stat.st_size,
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
[project]
|
||||
name = "kmtnflower-backend"
|
||||
version = "1.0.0"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
python_classes = ["Test*"]
|
||||
python_functions = ["test_*"]
|
||||
asyncio_mode = "auto"
|
||||
addopts = [
|
||||
"--strict-markers",
|
||||
"--strict-config",
|
||||
"-v",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["app"]
|
||||
branch = true
|
||||
omit = ["*/tests/*", "*/__pycache__/*", "seed.py", "inject_test_logs.py", "probe_geo.py"]
|
||||
|
||||
[tool.coverage.report]
|
||||
show_missing = true
|
||||
skip_empty = true
|
||||
fail_under = 10
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"if TYPE_CHECKING:",
|
||||
"raise NotImplementedError",
|
||||
]
|
||||
|
||||
[tool.coverage.html]
|
||||
directory = "htmlcov"
|
||||
@@ -0,0 +1,4 @@
|
||||
pytest>=8.0
|
||||
pytest-asyncio>=0.24
|
||||
pytest-cov>=5.0
|
||||
aiosqlite>=0.20
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Shared test fixtures for kmtnflower backend tests."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
# Override settings for tests — use SQLite so we don't need PostgreSQL
|
||||
@pytest.fixture(autouse=True)
|
||||
def override_settings():
|
||||
"""Set test-only environment variables before any import of app modules."""
|
||||
env = {
|
||||
"KMTN_DATABASE_URL": "sqlite+aiosqlite:///:memory:",
|
||||
"KMTN_ENV": "test",
|
||||
"KMTN_JWT_SECRET_KEY": "test-secret-key-do-not-use",
|
||||
"KMTN_ADMIN_USERNAME": "testadmin",
|
||||
"KMTN_ADMIN_PASSWORD": "testpass123",
|
||||
"KMTN_LOGS_DIR": "/tmp/kmtn_test_logs",
|
||||
"KMTN_GEOLITE2_DB_PATH": "/nonexistent/GeoLite2-City.mmdb",
|
||||
"KMTN_THEME_JSON_PATH": "/tmp/kmtn_test_theme.json",
|
||||
}
|
||||
with patch.dict("os.environ", env, clear=False):
|
||||
yield env
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Tests for app.auth — JWT token creation, verification, and decoding."""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import HTTPException
|
||||
from jose import jwt
|
||||
|
||||
from app.auth import (
|
||||
create_access_token,
|
||||
create_download_token,
|
||||
verify_download_token,
|
||||
decode_token,
|
||||
)
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class TestCreateAccessToken:
|
||||
"""Tests for create_access_token()."""
|
||||
|
||||
def test_returns_string_token(self):
|
||||
token = create_access_token(user_id=1, is_admin=True)
|
||||
assert isinstance(token, str)
|
||||
assert len(token) > 0
|
||||
|
||||
def test_token_contains_correct_payload(self):
|
||||
token = create_access_token(user_id=42, is_admin=False)
|
||||
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=["HS256"])
|
||||
assert payload["sub"] == "42"
|
||||
assert payload["is_admin"] is False
|
||||
assert "exp" in payload
|
||||
|
||||
def test_admin_flag_is_preserved(self):
|
||||
admin_token = create_access_token(user_id=1, is_admin=True)
|
||||
payload = jwt.decode(admin_token, settings.JWT_SECRET_KEY, algorithms=["HS256"])
|
||||
assert payload["is_admin"] is True
|
||||
|
||||
def test_token_expires_in_configured_minutes(self):
|
||||
token = create_access_token(user_id=1, is_admin=True)
|
||||
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=["HS256"])
|
||||
expected_exp = datetime.now(timezone.utc) + timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
|
||||
# Allow 1s drift
|
||||
assert abs(payload["exp"] - expected_exp.timestamp()) < 2
|
||||
|
||||
|
||||
class TestCreateDownloadToken:
|
||||
"""Tests for create_download_token()."""
|
||||
|
||||
def test_download_token_contains_artifact_id(self):
|
||||
token = create_download_token(artifact_id=99)
|
||||
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=["HS256"])
|
||||
assert payload["sub"] == "download"
|
||||
assert payload["artifact_id"] == 99
|
||||
|
||||
def test_download_token_expires_quickly(self):
|
||||
token = create_download_token(artifact_id=1)
|
||||
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=["HS256"])
|
||||
expected_exp = datetime.now(timezone.utc) + timedelta(minutes=settings.DOWNLOAD_TOKEN_MINUTES)
|
||||
assert abs(payload["exp"] - expected_exp.timestamp()) < 2
|
||||
|
||||
|
||||
class TestVerifyDownloadToken:
|
||||
"""Tests for verify_download_token()."""
|
||||
|
||||
def test_valid_download_token_returns_artifact_id(self):
|
||||
token = create_download_token(artifact_id=55)
|
||||
result = verify_download_token(token)
|
||||
assert result == 55
|
||||
|
||||
def test_access_token_rejected_as_download_token(self):
|
||||
token = create_access_token(user_id=1, is_admin=True)
|
||||
result = verify_download_token(token)
|
||||
assert result is None
|
||||
|
||||
def test_expired_token_returns_none(self):
|
||||
payload = {
|
||||
"sub": "download",
|
||||
"artifact_id": 1,
|
||||
"exp": datetime.now(timezone.utc) - timedelta(minutes=1),
|
||||
}
|
||||
expired_token = jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256")
|
||||
result = verify_download_token(expired_token)
|
||||
assert result is None
|
||||
|
||||
def test_tampered_token_returns_none(self):
|
||||
token = create_download_token(artifact_id=10)
|
||||
tampered = token[:-5] + "xxxxx"
|
||||
result = verify_download_token(tampered)
|
||||
assert result is None
|
||||
|
||||
def test_wrong_secret_returns_none(self):
|
||||
payload = {
|
||||
"sub": "download",
|
||||
"artifact_id": 1,
|
||||
"exp": datetime.now(timezone.utc) + timedelta(minutes=5),
|
||||
}
|
||||
wrong_token = jwt.encode(payload, "wrong-secret", algorithm="HS256")
|
||||
result = verify_download_token(wrong_token)
|
||||
assert result is None
|
||||
|
||||
def test_non_integer_artifact_id_returns_none(self):
|
||||
payload = {
|
||||
"sub": "download",
|
||||
"artifact_id": "not_an_int",
|
||||
"exp": datetime.now(timezone.utc) + timedelta(minutes=5),
|
||||
}
|
||||
token = jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256")
|
||||
result = verify_download_token(token)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestDecodeToken:
|
||||
"""Tests for decode_token()."""
|
||||
|
||||
def test_valid_token_returns_payload(self):
|
||||
token = create_access_token(user_id=7, is_admin=False)
|
||||
payload = decode_token(token)
|
||||
assert payload["sub"] == "7"
|
||||
|
||||
def test_expired_token_raises_401(self):
|
||||
payload = {
|
||||
"sub": "1",
|
||||
"exp": datetime.now(timezone.utc) - timedelta(hours=1),
|
||||
}
|
||||
expired = jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256")
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
decode_token(expired)
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
def test_invalid_signature_raises_401(self):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
decode_token("not.a.token")
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
def test_token_without_sub_raises_401(self):
|
||||
payload = {
|
||||
"exp": datetime.now(timezone.utc) + timedelta(hours=1),
|
||||
}
|
||||
token = jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256")
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
decode_token(token)
|
||||
assert exc_info.value.status_code == 401
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Tests for app.config — Settings model and environment variable overrides."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
def _clear_kmn_env(monkeypatch):
|
||||
"""Remove all KMTN_ prefixed env vars so Settings reads pure defaults."""
|
||||
keys = [k for k in os.environ if k.startswith("KMTN_")]
|
||||
for k in keys:
|
||||
monkeypatch.delenv(k, raising=False)
|
||||
|
||||
|
||||
class TestSettingsDefaults:
|
||||
"""Verify Settings defaults match documented behavior."""
|
||||
|
||||
def test_default_database_url(self, monkeypatch):
|
||||
_clear_kmn_env(monkeypatch)
|
||||
settings = Settings()
|
||||
assert "postgresql" in settings.DATABASE_URL
|
||||
assert "kmountain" in settings.DATABASE_URL
|
||||
|
||||
def test_default_env_is_development(self, monkeypatch):
|
||||
_clear_kmn_env(monkeypatch)
|
||||
settings = Settings()
|
||||
assert settings.ENV == "development"
|
||||
|
||||
def test_default_jwt_expire_minutes(self, monkeypatch):
|
||||
_clear_kmn_env(monkeypatch)
|
||||
settings = Settings()
|
||||
assert settings.JWT_EXPIRE_MINUTES == 1440
|
||||
|
||||
def test_default_download_token_minutes(self, monkeypatch):
|
||||
_clear_kmn_env(monkeypatch)
|
||||
settings = Settings()
|
||||
assert settings.DOWNLOAD_TOKEN_MINUTES == 10
|
||||
|
||||
def test_default_cors_origins(self, monkeypatch):
|
||||
_clear_kmn_env(monkeypatch)
|
||||
settings = Settings()
|
||||
assert "http://localhost:4200" in settings.CORS_ORIGINS
|
||||
|
||||
def test_empty_admin_credentials_by_default(self, monkeypatch):
|
||||
_clear_kmn_env(monkeypatch)
|
||||
settings = Settings()
|
||||
assert settings.ADMIN_USERNAME == ""
|
||||
assert settings.ADMIN_PASSWORD == ""
|
||||
|
||||
|
||||
class TestSettingsEnvOverride:
|
||||
"""Verify KMTN_ prefixed env vars override defaults."""
|
||||
|
||||
def test_database_url_from_env(self, monkeypatch):
|
||||
monkeypatch.setenv("KMTN_DATABASE_URL", "sqlite+aiosqlite:///test.db")
|
||||
settings = Settings()
|
||||
assert settings.DATABASE_URL == "sqlite+aiosqlite:///test.db"
|
||||
|
||||
def test_env_from_env(self, monkeypatch):
|
||||
monkeypatch.setenv("KMTN_ENV", "production")
|
||||
settings = Settings()
|
||||
assert settings.ENV == "production"
|
||||
|
||||
def test_jwt_secret_from_env(self, monkeypatch):
|
||||
monkeypatch.setenv("KMTN_JWT_SECRET_KEY", "my-custom-secret")
|
||||
settings = Settings()
|
||||
assert settings.JWT_SECRET_KEY == "my-custom-secret"
|
||||
|
||||
def test_jwt_expire_override(self, monkeypatch):
|
||||
monkeypatch.setenv("KMTN_JWT_EXPIRE_MINUTES", "60")
|
||||
settings = Settings()
|
||||
assert settings.JWT_EXPIRE_MINUTES == 60
|
||||
|
||||
def test_logs_dir_override(self, monkeypatch):
|
||||
monkeypatch.setenv("KMTN_LOGS_DIR", "/custom/logs")
|
||||
settings = Settings()
|
||||
assert settings.LOGS_DIR == "/custom/logs"
|
||||
|
||||
def test_geolite2_path_override(self, monkeypatch):
|
||||
monkeypatch.setenv("KMTN_GEOLITE2_DB_PATH", "/custom/geo.mmdb")
|
||||
settings = Settings()
|
||||
assert settings.GEOLITE2_DB_PATH == "/custom/geo.mmdb"
|
||||
|
||||
def test_theme_json_path_override(self, monkeypatch):
|
||||
monkeypatch.setenv("KMTN_THEME_JSON_PATH", "/custom/theme.json")
|
||||
settings = Settings()
|
||||
assert settings.THEME_JSON_PATH == "/custom/theme.json"
|
||||
|
||||
def test_admin_credentials_from_env(self, monkeypatch):
|
||||
monkeypatch.setenv("KMTN_ADMIN_USERNAME", "admin")
|
||||
monkeypatch.setenv("KMTN_ADMIN_PASSWORD", "secure123")
|
||||
settings = Settings()
|
||||
assert settings.ADMIN_USERNAME == "admin"
|
||||
assert settings.ADMIN_PASSWORD == "secure123"
|
||||
|
||||
def test_website_url_override(self, monkeypatch):
|
||||
monkeypatch.setenv("KMTN_WEBSITE_URL", "https://example.com")
|
||||
settings = Settings()
|
||||
assert settings.WEBSITE_URL == "https://example.com"
|
||||
|
||||
def test_static_upstream_url_override(self, monkeypatch):
|
||||
monkeypatch.setenv("KMTN_STATIC_UPSTREAM_URL", "http://frontend:80")
|
||||
settings = Settings()
|
||||
assert settings.STATIC_UPSTREAM_URL == "http://frontend:80"
|
||||
|
||||
def test_cors_origins_override(self, monkeypatch):
|
||||
# pydantic-settings parses JSON lists for list[str] fields
|
||||
monkeypatch.setenv("KMTN_CORS_ORIGINS", '["https://example.com"]')
|
||||
settings = Settings()
|
||||
assert settings.CORS_ORIGINS == ["https://example.com"]
|
||||
|
||||
def test_kmn_prefix_is_required(self, monkeypatch):
|
||||
"""Non-prefixed env vars should NOT override settings."""
|
||||
monkeypatch.setenv("JWT_SECRET_KEY", "should-not-apply")
|
||||
settings = Settings()
|
||||
assert settings.JWT_SECRET_KEY != "should-not-apply"
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Tests for log_parser — IP resolution, log line parsing, and geo lookups."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from app.log_parser import (
|
||||
parse_log_line,
|
||||
resolve_client_ip,
|
||||
_COUNTRY_CENTROIDS,
|
||||
lookup_geo,
|
||||
)
|
||||
|
||||
|
||||
class TestParseLogLine:
|
||||
"""Tests for parse_log_line()."""
|
||||
|
||||
def test_valid_json_line(self):
|
||||
line = '{"remote_addr": "192.168.1.1", "method": "GET"}'
|
||||
result = parse_log_line(line)
|
||||
assert result is not None
|
||||
assert result["remote_addr"] == "192.168.1.1"
|
||||
assert result["method"] == "GET"
|
||||
|
||||
def test_empty_line_returns_none(self):
|
||||
assert parse_log_line("") is None
|
||||
|
||||
def test_whitespace_only_returns_none(self):
|
||||
assert parse_log_line(" ") is None
|
||||
|
||||
def test_invalid_json_returns_none(self):
|
||||
assert parse_log_line("not json at all") is None
|
||||
|
||||
def test_partial_json_returns_none(self):
|
||||
assert parse_log_line('{"remote_addr": ') is None
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
line = ' {"key": "value"} \n'
|
||||
result = parse_log_line(line)
|
||||
assert result is not None
|
||||
assert result["key"] == "value"
|
||||
|
||||
|
||||
class TestResolveClientIp:
|
||||
"""Tests for resolve_client_ip()."""
|
||||
|
||||
def test_remote_addr_without_xff(self):
|
||||
entry = {"remote_addr": "203.0.113.5"}
|
||||
assert resolve_client_ip(entry) == "203.0.113.5"
|
||||
|
||||
def test_xff_single_ip(self):
|
||||
entry = {
|
||||
"remote_addr": "10.0.0.1",
|
||||
"x_forwarded_for": "203.0.113.50",
|
||||
}
|
||||
assert resolve_client_ip(entry) == "203.0.113.50"
|
||||
|
||||
def test_xff_multiple_ips_returns_leftmost(self):
|
||||
entry = {
|
||||
"remote_addr": "10.0.0.1",
|
||||
"x_forwarded_for": "203.0.113.100, 10.1.2.3, 172.16.0.5",
|
||||
}
|
||||
assert resolve_client_ip(entry) == "203.0.113.100"
|
||||
|
||||
def test_xff_dash_falls_back_to_remote_addr(self):
|
||||
entry = {"remote_addr": "192.168.0.1", "x_forwarded_for": "-"}
|
||||
assert resolve_client_ip(entry) == "192.168.0.1"
|
||||
|
||||
def test_missing_fields_returns_empty(self):
|
||||
entry = {}
|
||||
assert resolve_client_ip(entry) == ""
|
||||
|
||||
def test_xff_with_whitespace(self):
|
||||
entry = {
|
||||
"remote_addr": "10.0.0.1",
|
||||
"x_forwarded_for": " 203.0.113.75 ",
|
||||
}
|
||||
assert resolve_client_ip(entry) == "203.0.113.75"
|
||||
|
||||
|
||||
class TestCountryCentroids:
|
||||
"""Tests for _COUNTRY_CENTROIDS data integrity."""
|
||||
|
||||
def test_centroids_are_float_tuples(self):
|
||||
for code, coords in _COUNTRY_CENTROIDS.items():
|
||||
assert isinstance(coords, tuple)
|
||||
assert len(coords) == 2
|
||||
assert isinstance(coords[0], float)
|
||||
assert isinstance(coords[1], float)
|
||||
|
||||
def test_known_countries_present(self):
|
||||
expected = {"US", "GB", "DE", "FR", "JP", "AU", "BR", "CA", "IN"}
|
||||
assert expected.issubset(_COUNTRY_CENTROIDS.keys())
|
||||
|
||||
def test_centroid_values_are_reasonable(self):
|
||||
"""Latitudes should be in [-90, 90], longitudes in [-180, 180]."""
|
||||
for code, (lat, lon) in _COUNTRY_CENTROIDS.items():
|
||||
assert -90 <= lat <= 90, f"{code} latitude {lat} out of range"
|
||||
assert -180 <= lon <= 180, f"{code} longitude {lon} out of range"
|
||||
|
||||
|
||||
class TestParseLogFile:
|
||||
"""Tests for parse_log_file() — reads from real temp files."""
|
||||
|
||||
def test_parses_multiple_lines(self, tmp_path):
|
||||
log_content = (
|
||||
'{"remote_addr": "1.2.3.4"}\n'
|
||||
'{"remote_addr": "5.6.7.8"}\n'
|
||||
'{"remote_addr": "1.2.3.4"}\n'
|
||||
)
|
||||
log_file = tmp_path / "test.log"
|
||||
log_file.write_text(log_content)
|
||||
|
||||
from app.log_parser import parse_log_file
|
||||
result = parse_log_file(str(log_file))
|
||||
# Should have 2 unique IPs (1.2.3.4 appears twice)
|
||||
assert "1.2.3.4" in result["unique_ips"]
|
||||
assert "5.6.7.8" in result["unique_ips"]
|
||||
assert len(result["unique_ips"]) == 2
|
||||
|
||||
def test_skips_invalid_lines(self, tmp_path):
|
||||
log_content = (
|
||||
'{"remote_addr": "1.2.3.4"}\n'
|
||||
'not valid json\n'
|
||||
'\n'
|
||||
'{"remote_addr": "5.6.7.8"}\n'
|
||||
)
|
||||
log_file = tmp_path / "test.log"
|
||||
log_file.write_text(log_content)
|
||||
|
||||
from app.log_parser import parse_log_file
|
||||
result = parse_log_file(str(log_file))
|
||||
assert len(result["unique_ips"]) == 2
|
||||
|
||||
def test_xff_takes_precedence(self, tmp_path):
|
||||
log_content = (
|
||||
'{"remote_addr": "10.0.0.1", "x_forwarded_for": "203.0.113.1"}\n'
|
||||
)
|
||||
log_file = tmp_path / "test.log"
|
||||
log_file.write_text(log_content)
|
||||
|
||||
from app.log_parser import parse_log_file
|
||||
result = parse_log_file(str(log_file))
|
||||
assert "203.0.113.1" in result["unique_ips"]
|
||||
assert "10.0.0.1" not in result["unique_ips"]
|
||||
|
||||
|
||||
class TestLookupGeo:
|
||||
"""Tests for lookup_geo() — verify IPs map to countries via our APIs."""
|
||||
|
||||
def _reset_geo_reader(self):
|
||||
"""Clear the cached reader so mocks take effect."""
|
||||
import app.log_parser as lp
|
||||
|
||||
lp._geo_reader = None
|
||||
|
||||
@patch("app.log_parser.settings")
|
||||
@patch("app.log_parser.os.path.exists", return_value=True)
|
||||
def test_ip_maps_to_country(self, mock_exists, mock_settings):
|
||||
"""Verify a known IP resolves to a country code via the GeoLite2 reader."""
|
||||
self._reset_geo_reader()
|
||||
mock_settings.GEOLITE2_DB_PATH = "/tmp/fake-geo.mmdb"
|
||||
|
||||
# Create a fake reader that returns US data
|
||||
fake_reader = MagicMock()
|
||||
fake_reader.get.return_value = {
|
||||
"country": {"iso_code": "US", "names": {"en": "United States"}},
|
||||
"city": {"names": {"en": "New York"}},
|
||||
"location": {"latitude": 40.7128, "longitude": -74.006},
|
||||
}
|
||||
|
||||
with patch("maxminddb.open_database", return_value=fake_reader):
|
||||
result = lookup_geo("8.8.8.8")
|
||||
|
||||
assert result["country_code"] == "US"
|
||||
assert result["country"] == "United States"
|
||||
assert result["city"] == "New York"
|
||||
assert result["latitude"] == 40.7128
|
||||
assert result["longitude"] == -74.006
|
||||
|
||||
@patch("app.log_parser.settings")
|
||||
@patch("app.log_parser.os.path.exists", return_value=True)
|
||||
def test_ip_without_coordinates_uses_centroid_fallback(self, mock_exists, mock_settings):
|
||||
"""When the free GeoLite2 DB lacks coordinates, centroid fallback kicks in."""
|
||||
self._reset_geo_reader()
|
||||
mock_settings.GEOLITE2_DB_PATH = "/tmp/fake-geo.mmdb"
|
||||
|
||||
# Simulate free GeoLite2 response — has country but no coordinates
|
||||
fake_reader = MagicMock()
|
||||
fake_reader.get.return_value = {
|
||||
"country": {"iso_code": "DE", "names": {"en": "Germany"}},
|
||||
"city": {"names": {"en": ""}},
|
||||
"location": {}, # No lat/lon
|
||||
}
|
||||
|
||||
with patch("maxminddb.open_database", return_value=fake_reader):
|
||||
result = lookup_geo("1.1.1.1")
|
||||
|
||||
assert result["country_code"] == "DE"
|
||||
assert result["country"] == "Germany"
|
||||
# Falls back to _COUNTRY_CENTROIDS["DE"]
|
||||
assert result["latitude"] == _COUNTRY_CENTROIDS["DE"][0]
|
||||
assert result["longitude"] == _COUNTRY_CENTROIDS["DE"][1]
|
||||
|
||||
@patch("app.log_parser.settings")
|
||||
def test_unknown_ip_returns_empty_dict(self, mock_settings):
|
||||
"""An IP not in the database returns an empty dict."""
|
||||
self._reset_geo_reader()
|
||||
mock_settings.GEOLITE2_DB_PATH = "/tmp/fake-geo.mmdb"
|
||||
|
||||
fake_reader = MagicMock()
|
||||
fake_reader.get.return_value = None
|
||||
|
||||
with patch("maxminddb.open_database", return_value=fake_reader):
|
||||
result = lookup_geo("255.255.255.255")
|
||||
|
||||
assert result == {}
|
||||
|
||||
@patch("app.log_parser.settings")
|
||||
def test_reader_error_returns_empty_dict(self, mock_settings):
|
||||
"""When the reader raises an exception, lookup_geo() returns safely."""
|
||||
self._reset_geo_reader()
|
||||
mock_settings.GEOLITE2_DB_PATH = "/tmp/fake-geo.mmdb"
|
||||
|
||||
with patch("maxminddb.open_database", side_effect=Exception("reader failed")):
|
||||
result = lookup_geo("1.2.3.4")
|
||||
|
||||
assert result == {}
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Tests for app.models — SQLAlchemy model definitions and relationships."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models import (
|
||||
Base,
|
||||
Show,
|
||||
ShowSchedule,
|
||||
Event,
|
||||
StationConfig,
|
||||
HistoryEntry,
|
||||
TeamMember,
|
||||
CommunityHighlight,
|
||||
Underwriter,
|
||||
VisitorStatDaily,
|
||||
VisitorStatGeo,
|
||||
)
|
||||
|
||||
|
||||
class TestModelDefinitions:
|
||||
"""Verify that models are properly defined."""
|
||||
|
||||
def test_show_table_name(self):
|
||||
assert Show.__tablename__ == "shows"
|
||||
|
||||
def test_show_schedule_table_name(self):
|
||||
assert ShowSchedule.__tablename__ == "show_schedules"
|
||||
|
||||
def test_event_table_name(self):
|
||||
assert Event.__tablename__ == "events"
|
||||
|
||||
def test_station_config_table_name(self):
|
||||
assert StationConfig.__tablename__ == "station_config"
|
||||
|
||||
def test_history_entry_table_name(self):
|
||||
assert HistoryEntry.__tablename__ == "history_entries"
|
||||
|
||||
def test_team_member_table_name(self):
|
||||
assert TeamMember.__tablename__ == "team_members"
|
||||
|
||||
def test_community_highlight_table_name(self):
|
||||
assert CommunityHighlight.__tablename__ == "community_highlights"
|
||||
|
||||
def test_underwriter_table_name(self):
|
||||
assert Underwriter.__tablename__ == "underwriters"
|
||||
|
||||
def test_visitor_stats_daily_table_name(self):
|
||||
assert VisitorStatDaily.__tablename__ == "visitor_stats_daily"
|
||||
|
||||
def test_visitor_stats_geo_table_name(self):
|
||||
assert VisitorStatGeo.__tablename__ == "visitor_stats_geo"
|
||||
|
||||
|
||||
class TestModelDefaults:
|
||||
"""Verify SQLAlchemy column-level defaults (set at INSERT time)."""
|
||||
|
||||
def _col_default_value(self, model_cls, col_name):
|
||||
"""Extract the Column default value from a model's metadata."""
|
||||
col = model_cls.__table__.columns[col_name]
|
||||
default = col.default
|
||||
if default is None:
|
||||
return None
|
||||
# DefaultClause.arg is a property (not callable) in SQLAlchemy 2.0
|
||||
if hasattr(default, "arg"):
|
||||
return default.arg
|
||||
return default
|
||||
|
||||
def test_show_defaults(self):
|
||||
assert self._col_default_value(Show, "display_order") == 0
|
||||
assert self._col_default_value(Show, "active") is True
|
||||
|
||||
def test_event_defaults(self):
|
||||
assert self._col_default_value(Event, "active") is True
|
||||
|
||||
def test_station_config_defaults(self):
|
||||
assert self._col_default_value(StationConfig, "callsign") == "KMTN"
|
||||
assert self._col_default_value(StationConfig, "name_primary") == "KMountain"
|
||||
assert self._col_default_value(StationConfig, "name_secondary") == "Flower Radio"
|
||||
assert self._col_default_value(StationConfig, "frequency") == "98.7 FM"
|
||||
assert self._col_default_value(StationConfig, "founded_year") == 1987
|
||||
assert self._col_default_value(StationConfig, "nonprofit_type") == "501(c)(3)"
|
||||
|
||||
def test_history_entry_defaults(self):
|
||||
assert self._col_default_value(HistoryEntry, "active") is True
|
||||
|
||||
def test_team_member_defaults(self):
|
||||
assert self._col_default_value(TeamMember, "active") is True
|
||||
|
||||
def test_underwriter_defaults(self):
|
||||
assert self._col_default_value(Underwriter, "display_order") == 0
|
||||
assert self._col_default_value(Underwriter, "active") is True
|
||||
|
||||
|
||||
class TestBaseModel:
|
||||
"""Verify Base declarative model setup."""
|
||||
|
||||
def test_base_has_metadata(self):
|
||||
assert hasattr(Base, "metadata")
|
||||
|
||||
def test_all_models_registered_in_metadata(self):
|
||||
tables = Base.metadata.tables
|
||||
assert "shows" in tables
|
||||
assert "show_schedules" in tables
|
||||
assert "events" in tables
|
||||
assert "station_config" in tables
|
||||
assert "history_entries" in tables
|
||||
assert "team_members" in tables
|
||||
assert "community_highlights" in tables
|
||||
assert "underwriters" in tables
|
||||
assert "visitor_stats_daily" in tables
|
||||
assert "visitor_stats_geo" in tables
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Tests for Pydantic schemas — validation, defaults, and computed fields."""
|
||||
|
||||
from datetime import date
|
||||
import pytest
|
||||
|
||||
from app.schemas import (
|
||||
EventCreate,
|
||||
EventUpdate,
|
||||
EventResponse,
|
||||
StationConfigUpdate,
|
||||
ShowScheduleResponse,
|
||||
ShowCreate,
|
||||
ShowScheduleCreate,
|
||||
HistoryEntryCreate,
|
||||
TeamMemberCreate,
|
||||
UnderwriterCreate,
|
||||
DAY_NAMES,
|
||||
)
|
||||
|
||||
|
||||
class TestEventSchemas:
|
||||
"""Tests for Event Pydantic schemas."""
|
||||
|
||||
def test_event_create_validates_required_fields(self):
|
||||
event = EventCreate(
|
||||
date=date(2026, 8, 15),
|
||||
title="Summer Concert",
|
||||
description="Live music",
|
||||
location="Main Stage",
|
||||
icon="🎵",
|
||||
)
|
||||
assert event.title == "Summer Concert"
|
||||
assert event.rsvp_url is None
|
||||
|
||||
def test_event_create_with_rsvp(self):
|
||||
event = EventCreate(
|
||||
date=date(2026, 9, 1),
|
||||
title="Festival",
|
||||
description="Big event",
|
||||
location="Park",
|
||||
icon="🎉",
|
||||
rsvp_url="https://example.com/rsvp",
|
||||
)
|
||||
assert event.rsvp_url == "https://example.com/rsvp"
|
||||
|
||||
def test_event_update_all_optional(self):
|
||||
update = EventUpdate()
|
||||
assert update.title is None
|
||||
assert update.active is None
|
||||
|
||||
def test_event_update_partial(self):
|
||||
update = EventUpdate(active=False, title="Updated Title")
|
||||
assert update.active is False
|
||||
assert update.title == "Updated Title"
|
||||
assert update.date is None
|
||||
|
||||
|
||||
class TestStationConfigUpdate:
|
||||
"""Tests for StationConfigUpdate schema."""
|
||||
|
||||
def test_all_fields_optional(self):
|
||||
update = StationConfigUpdate()
|
||||
assert update.callsign is None
|
||||
assert update.color_primary is None
|
||||
|
||||
def test_partial_update(self):
|
||||
update = StationConfigUpdate(
|
||||
callsign="KXYZ",
|
||||
color_primary="#ff0000",
|
||||
)
|
||||
assert update.callsign == "KXYZ"
|
||||
assert update.color_primary == "#ff0000"
|
||||
assert update.name_primary is None
|
||||
|
||||
|
||||
class TestShowScheduleResponse:
|
||||
"""Tests for ShowScheduleResponse computed field."""
|
||||
|
||||
def test_day_label_mapping(self):
|
||||
for day_num, expected_label in DAY_NAMES.items():
|
||||
schedule = ShowScheduleResponse(
|
||||
id=1,
|
||||
show_id=1,
|
||||
day_of_week=day_num,
|
||||
time="10:00",
|
||||
)
|
||||
assert schedule.day_label == expected_label
|
||||
|
||||
def test_unknown_day_returns_unknown(self):
|
||||
schedule = ShowScheduleResponse(
|
||||
id=1,
|
||||
show_id=1,
|
||||
day_of_week=99,
|
||||
time="10:00",
|
||||
)
|
||||
assert schedule.day_label == "Unknown"
|
||||
|
||||
|
||||
class TestShowCreate:
|
||||
"""Tests for ShowCreate schema."""
|
||||
|
||||
def test_show_create_with_schedules(self):
|
||||
show = ShowCreate(
|
||||
title="Morning Show",
|
||||
description="Morning music",
|
||||
host="DJ Alex",
|
||||
genre="Pop",
|
||||
schedules=[
|
||||
ShowScheduleCreate(day_of_week=1, time="08:00"),
|
||||
ShowScheduleCreate(day_of_week=3, time="08:00"),
|
||||
],
|
||||
)
|
||||
assert len(show.schedules) == 2
|
||||
assert show.schedules[0].day_of_week == 1
|
||||
assert show.display_order == 0
|
||||
|
||||
def test_show_create_defaults(self):
|
||||
show = ShowCreate(
|
||||
title="Show",
|
||||
description="Desc",
|
||||
host="Host",
|
||||
genre="Genre",
|
||||
schedules=[ShowScheduleCreate(day_of_week=1, time="09:00")],
|
||||
)
|
||||
assert show.show_art_url is None
|
||||
assert show.hero_image_url is None
|
||||
assert show.display_order == 0
|
||||
|
||||
|
||||
class TestHistoryEntryCreate:
|
||||
"""Tests for HistoryEntryCreate schema."""
|
||||
|
||||
def test_valid_creation(self):
|
||||
entry = HistoryEntryCreate(
|
||||
year=1987,
|
||||
title="Station Founded",
|
||||
body="The station began broadcasting.",
|
||||
display_order=1,
|
||||
)
|
||||
assert entry.year == 1987
|
||||
assert entry.title == "Station Founded"
|
||||
|
||||
|
||||
class TestTeamMemberCreate:
|
||||
"""Tests for TeamMemberCreate schema."""
|
||||
|
||||
def test_required_fields(self):
|
||||
member = TeamMemberCreate(
|
||||
name="Jane Doe",
|
||||
title="Engineer",
|
||||
display_order=1,
|
||||
)
|
||||
assert member.name == "Jane Doe"
|
||||
assert member.bio is None
|
||||
assert member.photo_url is None
|
||||
|
||||
|
||||
class TestUnderwriterCreate:
|
||||
"""Tests for UnderwriterCreate schema."""
|
||||
|
||||
def test_defaults_display_order_to_zero(self):
|
||||
uw = UnderwriterCreate(
|
||||
name="Sponsor Co",
|
||||
description="A great sponsor",
|
||||
)
|
||||
assert uw.display_order == 0
|
||||
assert uw.website_url is None
|
||||
assert uw.logo_url is None
|
||||
|
||||
def test_custom_display_order(self):
|
||||
uw = UnderwriterCreate(
|
||||
name="Premium Sponsor",
|
||||
description="Best sponsor",
|
||||
display_order=5,
|
||||
website_url="https://sponsor.example.com",
|
||||
)
|
||||
assert uw.display_order == 5
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Tests for theme_export — theme.json generation from station config."""
|
||||
|
||||
import os
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
class TestThemeExport:
|
||||
"""Tests for theme.json export logic."""
|
||||
|
||||
def test_settings_theme_json_path_default(self):
|
||||
with patch.dict("os.environ", {"KMTN_THEME_JSON_PATH": "/app/theme.json"}, clear=False):
|
||||
settings = Settings()
|
||||
assert settings.THEME_JSON_PATH == "/app/theme.json"
|
||||
|
||||
def test_settings_website_url_default(self):
|
||||
with patch.dict("os.environ", {"KMTN_WEBSITE_URL": "https://kmountainflower.org"}, clear=False):
|
||||
settings = Settings()
|
||||
assert settings.WEBSITE_URL == "https://kmountainflower.org"
|
||||
@@ -0,0 +1,98 @@
|
||||
# Force Intel (amd64) architecture — required because Android SDK
|
||||
# command-line tools are only published for x86_64 (Linux/macOS).
|
||||
# On Apple Silicon hosts, Docker runs this image via Rosetta emulation.
|
||||
FROM --platform=linux/amd64 docker.io/library/debian:bookworm-slim
|
||||
|
||||
# System dependencies + SSH server for intra-network access
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git curl unzip wget python3 python3-venv python3-pip \
|
||||
openjdk-17-jdk-headless \
|
||||
openssh-server \
|
||||
xz-utils \
|
||||
libcairo2 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# SSH server configuration
|
||||
RUN mkdir -p /run/sshd && \
|
||||
ssh-keygen -A && \
|
||||
chmod 644 /etc/ssh/ssh_host_*_key && \
|
||||
sed -i 's/#PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config && \
|
||||
echo "UsePAM no" >> /etc/ssh/sshd_config && \
|
||||
echo "PermitUserEnvironment yes" >> /etc/ssh/sshd_config
|
||||
|
||||
# Set Java home — use /usr/lib/jvm/java-17-openjdk which is a stable
|
||||
# symlink provided by Debian's jdk package, regardless of architecture.
|
||||
ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
|
||||
ENV PATH="$JAVA_HOME/bin:$PATH"
|
||||
|
||||
# Install Flutter 3.41.8
|
||||
ARG FLUTTER_VERSION=3.41.8
|
||||
ARG FLUTTER_SDK_URL=https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz
|
||||
RUN wget -qO- $FLUTTER_SDK_URL | tar -xJ -C /opt \
|
||||
&& ln -s /opt/flutter/bin/flutter /usr/local/bin/flutter
|
||||
|
||||
# Android SDK setup
|
||||
ENV ANDROID_HOME=/opt/android-sdk
|
||||
RUN mkdir -p $ANDROID_HOME/cmdline-tools
|
||||
# Download and install Android command-line tools
|
||||
RUN wget -qO /tmp/cmdline-tools.zip \
|
||||
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip \
|
||||
&& unzip -q /tmp/cmdline-tools.zip -d /tmp/cmdline-tools-extract \
|
||||
&& mv /tmp/cmdline-tools-extract/cmdline-tools $ANDROID_HOME/cmdline-tools/latest \
|
||||
&& rm -rf /tmp/cmdline-tools*
|
||||
|
||||
# Accept licenses and install required SDK packages
|
||||
ENV CMDLINE_TOOLS=$ANDROID_HOME/cmdline-tools/latest/bin
|
||||
RUN yes | $CMDLINE_TOOLS/sdkmanager --licenses >/dev/null 2>&1 || true
|
||||
RUN $CMDLINE_TOOLS/sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
|
||||
|
||||
# Create vscode user (UID 1000, matching devcontainer convention)
|
||||
RUN groupadd -g 1000 vscode && \
|
||||
useradd -m -u 1000 -g 1000 -s /bin/bash vscode && \
|
||||
chown -R vscode:vscode /opt/flutter /opt/android-sdk && \
|
||||
mkdir -p /home/vscode && chown vscode:vscode /home/vscode
|
||||
|
||||
# Give vscode a simple password: '123'
|
||||
RUN echo "vscode:123" | chpasswd
|
||||
|
||||
# Set up Flutter for the vscode user
|
||||
USER vscode
|
||||
WORKDIR /home/vscode
|
||||
ENV PATH="/opt/flutter/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$JAVA_HOME/bin:$PATH"
|
||||
|
||||
# Pre-cache Flutter artifacts
|
||||
RUN flutter precache --android
|
||||
|
||||
# Set Android HOME for vscode user
|
||||
ENV ANDROID_HOME=/opt/android-sdk
|
||||
|
||||
# SSH non-interactive shells don't inherit Docker ENV at all (not even
|
||||
# BASH_ENV). Use SSH's own ~/.ssh/environment file with
|
||||
# PermitUserEnvironment to guarantee variables are set on every connection.
|
||||
RUN mkdir -p /home/vscode/.ssh && \
|
||||
echo 'JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64' > /home/vscode/.ssh/environment && \
|
||||
echo 'ANDROID_HOME=/opt/android-sdk' >> /home/vscode/.ssh/environment && \
|
||||
echo 'PATH=/opt/flutter/bin:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:/usr/lib/jvm/java-17-openjdk-amd64/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' >> /home/vscode/.ssh/environment && \
|
||||
chown -R vscode:vscode /home/vscode/.ssh && \
|
||||
chmod 700 /home/vscode/.ssh && \
|
||||
chmod 644 /home/vscode/.ssh/environment
|
||||
|
||||
# Set up Gradle home so the wrapper works correctly in SSH sessions.
|
||||
# org.gradle.daemon=false is required because SSH sessions are ephemeral
|
||||
# — a daemon started in one session can't be reused in the next.
|
||||
RUN mkdir -p /home/vscode/.gradle && \
|
||||
echo 'org.gradle.daemon=false' > /home/vscode/.gradle/gradle.properties && \
|
||||
echo 'org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+UseSerialGC' >> /home/vscode/.gradle/gradle.properties && \
|
||||
echo 'org.gradle.workers.max=1' >> /home/vscode/.gradle/gradle.properties && \
|
||||
echo 'org.gradle.parallel=false' >> /home/vscode/.gradle/gradle.properties && \
|
||||
echo 'kotlin.daemon.jvmargs=-Xmx512m' >> /home/vscode/.gradle/gradle.properties && \
|
||||
echo 'kotlin.incremental=false' >> /home/vscode/.gradle/gradle.properties && \
|
||||
echo 'kotlin.compiler.execution.strategy=in-process' >> /home/vscode/.gradle/gradle.properties && \
|
||||
echo 'android.enableParallelPlugin=false' >> /home/vscode/.gradle/gradle.properties && \
|
||||
chown -R vscode:vscode /home/vscode/.gradle
|
||||
|
||||
# Expose SSH for intra-network access from the api container
|
||||
EXPOSE 22
|
||||
|
||||
# Start SSH daemon and keep container alive
|
||||
CMD ["/usr/sbin/sshd", "-D"]
|
||||
@@ -0,0 +1,3 @@
|
||||
PROJECT_NAME=kryz
|
||||
FRONTEND_PORT=4201
|
||||
KMTN_CORS_ORIGINS='["http://localhost:4201", "http://localhost"]'
|
||||
@@ -0,0 +1,3 @@
|
||||
PROJECT_NAME=staylit
|
||||
FRONTEND_PORT=4200
|
||||
KMTN_CORS_ORIGINS='["http://localhost:4200", "http://localhost"]'
|
||||
Binary file not shown.
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Ensure we are in the project root
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if [ ! -d "customers" ]; then
|
||||
echo "Error: 'customers/' directory not found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Starting mass deployment for all customers..."
|
||||
echo "--------------------------------------------------"
|
||||
|
||||
# Iterate through .env.* files in the customers/ directory
|
||||
for env_file in customers/.env.*; do
|
||||
# Check if it's a regular file and not just the pattern itself (if no matches found)
|
||||
[ -e "$env_file" ] || continue
|
||||
|
||||
# Extract customer name from the filename (e.g., customers/.env.kmtn -> kmtn)
|
||||
# We remove the 'customers/.env.' prefix to get the name
|
||||
customer_name=$(basename "$env_file" | sed 's/\.env\.//')
|
||||
|
||||
echo "[$(date +'%H:%M:%S')] Deploying: $customer_name"
|
||||
echo "Using configuration: $env_file"
|
||||
|
||||
# Run docker compose. We use -p for the project name to isolate them correctly.
|
||||
docker compose --env-file "$env_file" -p "$customer_name" up -d --build
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "[$(date +'%H:%M:%S')] ✅ SUCCESS: $customer_name"
|
||||
else
|
||||
echo "[$(date +'%H:%M:%S')] ❌ FAILED: $customer_name"
|
||||
fi
|
||||
echo "--------------------------------------------------"
|
||||
done
|
||||
|
||||
echo "Mass deployment process completed."
|
||||
@@ -1,25 +0,0 @@
|
||||
# Production override — use with: docker compose -f docker-compose.yml -f docker-compose.prod.yml up
|
||||
# Assumes: managed PostgreSQL, external reverse proxy / load balancer handling TLS and port 80/443.
|
||||
|
||||
services:
|
||||
# No db service — managed PostgreSQL is assumed.
|
||||
# Set KMTN_DATABASE_URL in your environment or a .env file.
|
||||
|
||||
api:
|
||||
environment:
|
||||
KMTN_ENV: production
|
||||
# Override with your actual production domain(s)
|
||||
KMTN_CORS_ORIGINS: '["https://yourdomain.com"]'
|
||||
volumes:
|
||||
- logs:/logs
|
||||
# No port mapping — traffic arrives via reverse proxy / load balancer
|
||||
|
||||
frontend:
|
||||
environment:
|
||||
# Internal API target for nginx proxy
|
||||
API_UPSTREAM: https://api.yourdomain.com
|
||||
# Browser-facing API URL. Empty = proxy mode. Set to direct URL if ingress breaks proxy chain.
|
||||
API_PUBLIC_URL: ""
|
||||
volumes:
|
||||
- logs:/logs
|
||||
# No port mapping — traffic arrives via reverse proxy / load balancer
|
||||
@@ -1,52 +0,0 @@
|
||||
services:
|
||||
api:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
KMTN_ADMIN_PASSWORD: 123
|
||||
KMTN_ADMIN_USERNAME: admin
|
||||
KMTN_CORS_ORIGINS: '["http://truenas.local:4200", "http://truenas.local"]'
|
||||
KMTN_DATABASE_URL: >-
|
||||
postgresql+asyncpg://postgres:postgres@/kmountain?host=/var/run/postgresql
|
||||
image: truenas.local:35000/kmtnflower-api:latest
|
||||
pull_policy: always
|
||||
ports:
|
||||
- '8000:8000'
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- pgsocket:/var/run/postgresql
|
||||
- logs:/logs
|
||||
db:
|
||||
environment:
|
||||
POSTGRES_DB: kmountain
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_USER: postgres
|
||||
healthcheck:
|
||||
interval: 5s
|
||||
retries: 5
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- pg_isready -U postgres
|
||||
timeout: 5s
|
||||
image: docker.io/library/postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /mnt/WestTek_Mainframe/vm_data/kmtndata:/var/lib/postgresql/data
|
||||
- pgsocket:/var/run/postgresql
|
||||
frontend:
|
||||
depends_on:
|
||||
- api
|
||||
environment:
|
||||
API_PUBLIC_URL: http://truenas.local:8000
|
||||
API_UPSTREAM: http://api:8000
|
||||
image: truenas.local:35000/kmtnflower:latest
|
||||
pull_policy: always
|
||||
ports:
|
||||
- '4200:80'
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- logs:/logs
|
||||
volumes:
|
||||
pgsocket:
|
||||
logs:
|
||||
+32
-7
@@ -12,7 +12,7 @@ services:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- pgsocket:/var/run/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
test: [ "CMD-SHELL", "pg_isready -U postgres" ]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -27,11 +27,10 @@ services:
|
||||
environment:
|
||||
KMTN_DATABASE_URL: >-
|
||||
postgresql+asyncpg://postgres:postgres@/kmountain?host=/var/run/postgresql
|
||||
KMTN_CORS_ORIGINS: '["http://localhost:4200", "http://localhost"]'
|
||||
KMTN_ADMIN_USERNAME : "admin"
|
||||
KMTN_ADMIN_PASSWORD : "123"
|
||||
ports:
|
||||
- "8000:8000"
|
||||
KMTN_CORS_ORIGINS: '${KMTN_CORS_ORIGINS}'
|
||||
KMTN_ADMIN_USERNAME: "admin"
|
||||
KMTN_ADMIN_PASSWORD: "123"
|
||||
KMTN_STATIC_UPSTREAM_URL: http://frontend:80
|
||||
volumes:
|
||||
- pgsocket:/var/run/postgresql
|
||||
- logs:/logs
|
||||
@@ -48,13 +47,39 @@ services:
|
||||
API_UPSTREAM: http://api:8000
|
||||
API_PUBLIC_URL: ""
|
||||
ports:
|
||||
- "4200:80"
|
||||
- "${FRONTEND_PORT}:80"
|
||||
volumes:
|
||||
- logs:/logs
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
# Flutter build container — runs alongside the api/frontend services.
|
||||
# The api container SSHes directly into this container over the Docker network.
|
||||
# Forced to linux/amd64 because Android SDK CLI tools are only published
|
||||
# for x86_64. On Apple Silicon hosts, Docker runs via Rosetta emulation.
|
||||
build:
|
||||
build:
|
||||
context: ./build
|
||||
dockerfile: Dockerfile
|
||||
platform: linux/amd64
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "22"
|
||||
volumes:
|
||||
- flutter-cache:/home/vscode/.pub-cache
|
||||
- android-licenses:/opt/android-sdk/licenses
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '4.0'
|
||||
memory: 16g
|
||||
reservations:
|
||||
cpus: '2.0'
|
||||
memory: 8g
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
pgsocket:
|
||||
logs:
|
||||
flutter-cache:
|
||||
android-licenses:
|
||||
|
||||
+9
-1
@@ -32,7 +32,15 @@ server {
|
||||
proxy_set_header Referer $http_referer;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
# Disable caching through the double-proxy chain (NPM → frontend nginx → API)
|
||||
# Without this, browsers cache responses keyed by the HTTPS origin, then send
|
||||
# conditional requests (If-None-Match) that get lost or mismatched in the proxy chain.
|
||||
proxy_cache off;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
add_header Vary "*" always;
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1035
-1
File diff suppressed because it is too large
Load Diff
+9
-2
@@ -6,7 +6,10 @@
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
"test": "ng test",
|
||||
"test:unit": "vitest run",
|
||||
"test:unit:watch": "vitest",
|
||||
"test:unit:coverage": "vitest run --coverage"
|
||||
},
|
||||
"private": true,
|
||||
"packageManager": "npm@11.11.0",
|
||||
@@ -31,7 +34,11 @@
|
||||
"@angular/build": "^21.2.14",
|
||||
"@angular/cli": "^21.2.14",
|
||||
"@angular/compiler-cli": "^21.2.0",
|
||||
"@vitest/coverage-v8": "^4.0.8",
|
||||
"jsdom": "^26.1.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "~5.9.2"
|
||||
"typescript": "~5.9.2",
|
||||
"vitest": "^4.0.8",
|
||||
"@types/jsdom": "^21.1.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
# Retire Donation Page, Add Configurable External Donation URL
|
||||
|
||||
## Context
|
||||
|
||||
The donation page (`/donate`) is being retired — it was a tier-based landing page with calls to action. All donation links will instead point to an external URL (e.g., a nonprofit payment processor). Admins configure this URL in the existing Station Config screen.
|
||||
|
||||
## Approach
|
||||
|
||||
1. Add `donation_url` field to the existing `StationConfig` singleton (backend model + schema, frontend interface + admin form)
|
||||
2. Replace all donation links across the site with external `<a href>` links driven by `config().donation_url`, hidden when empty
|
||||
3. Delete the donation page, tier service, tier admin UI, and all backend tier infrastructure
|
||||
|
||||
## Step-by-Step
|
||||
|
||||
### Phase 1 — Add `donation_url` to StationConfig (backend)
|
||||
|
||||
- **`backend/app/models.py`** — Add `donation_url = Column(String(500), nullable=False, default="")` to `StationConfig` class
|
||||
- **`backend/app/schemas.py`** — Add `donation_url: str` to `StationConfigResponse`; add `donation_url: Optional[str] = None` to `StationConfigUpdate`
|
||||
- **`backend/seed.py`** — Add `"donation_url": ""` to `STATION_CONFIG` dict
|
||||
|
||||
### Phase 2 — Add `donation_url` to StationConfig (frontend)
|
||||
|
||||
- **`src/app/interfaces/station-config.ts`** — Add `donation_url: string`
|
||||
- **`src/app/services/station-config.service.ts`** — Add `donation_url: ''` to `DEFAULT_CONFIG`
|
||||
- **`src/app/admin/admin-station-form.component.ts`** — Add `donation_url` property, wire it in `populate()` and `onSubmit()` payload
|
||||
- **`src/app/admin/admin-station-form.component.html`** — Add a "Donation" form section with a URL input field
|
||||
- **`src/app/admin/admin.component.html`** — Show `donation_url` in the station config summary view
|
||||
|
||||
### Phase 3 — Replace donation links with external links
|
||||
|
||||
All links switch from `routerLink="/donate"` to `[href]="config().donation_url"` with `target="_blank" rel="noopener noreferrer"`, wrapped in `@if (config().donation_url)` so they hide when empty.
|
||||
|
||||
- **`src/app/navbar/navbar.component.html`** — "Donate Now" button (line 37)
|
||||
- **`src/app/hero/hero.component.html`** — "Support the Station" button (line 28)
|
||||
- **`src/app/about/about.component.html`** — "Become a Member" CTA section (lines 76-88), wrap entire block in `@if`
|
||||
- **`src/app/footer/footer.component.html`** — "Donate" and "Become a Member" links (lines 26, 33)
|
||||
|
||||
### Phase 4 — Delete donation page
|
||||
|
||||
- Remove `/donate` route from **`src/app/app.routes.ts`** (lines 22-25)
|
||||
- Delete **`src/app/donate/`** directory (3 files: `.ts`, `.html`, `.scss`)
|
||||
|
||||
### Phase 5 — Remove tier infrastructure (frontend)
|
||||
|
||||
- Delete **`src/app/interfaces/tier.ts`**
|
||||
- Delete **`src/app/services/tier.service.ts`**
|
||||
- Delete **`src/app/admin/admin-tier-form.component.*`** (3 files)
|
||||
- **`src/app/admin/admin.component.ts`** — Remove `Tier`/`TierService`/`AdminTierFormComponent` imports, `TabKey` entry, injected service, `tiers$` BehaviorSubject, `editingTier`, `showTierForm`, `reloadTiers()`, and all tier CRUD methods
|
||||
- **`src/app/admin/admin.component.html`** — Remove Tiers tab button, Tiers tab content panel, tier form modal
|
||||
|
||||
### Phase 6 — Remove tier infrastructure (backend)
|
||||
|
||||
- Delete **`backend/app/api/tiers.py`**
|
||||
- **`backend/app/models.py`** — Delete `DonationTier` class
|
||||
- **`backend/app/schemas.py`** — Delete `TierCreate`, `TierUpdate`, `TierResponse`
|
||||
- **`backend/app/main.py`** — Remove `tiers` import and router registration
|
||||
- **`backend/app/api/admin.py`** — Remove `DonationTier` from reset truncation
|
||||
- **`backend/seed.py`** — Remove `DonationTier` import, `TIERS` list, `_upsert_tier()` helper, tier seeding, tier truncation
|
||||
|
||||
### Phase 7 — Contact form cleanup
|
||||
|
||||
- **`src/app/contact/contact.component.html`** — Remove `<option value="donation">Donation Question</option>`
|
||||
|
||||
## Execution Order
|
||||
|
||||
1 → 2 → 3 → 4 → 5 → 6 → 7
|
||||
|
||||
## Verification
|
||||
|
||||
1. Grep for remaining references to `Tier`, `tier`, `DonationTier`, `donate`, `/donate` — none should remain in live code
|
||||
2. `ng build` — no import errors, no unresolved references
|
||||
3. Backend starts without import errors
|
||||
4. Admin station form shows the new "External Donation URL" field
|
||||
5. Setting a URL → all 4 link locations render external links with `target="_blank"`
|
||||
6. Clearing the URL → all 4 link locations hide
|
||||
7. Navigate to `/donate` → 404 or wildcard redirect to home
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
# SEO Plan for KMountain Flower Radio
|
||||
|
||||
## Objective
|
||||
Improve crawlability, indexability, and ranking quality for a JavaScript-rendered Angular site where core content is loaded after app bootstrap.
|
||||
|
||||
## Current Baseline
|
||||
- The app is client-rendered Angular (no SSR/prerender in current build pipeline).
|
||||
- Nginx serves SPA fallback routes to index.html.
|
||||
- Initial HTML is a minimal shell and most meaningful content is populated after JavaScript executes and API calls complete.
|
||||
- Route-specific SEO metadata is not currently implemented (title updates are global/dynamic only).
|
||||
- robots.txt and sitemap.xml are not currently present in public assets.
|
||||
|
||||
## Target Outcomes
|
||||
- Reliable indexing of all public routes.
|
||||
- Better SERP snippets and relevance for branded and non-branded queries.
|
||||
- Less dependence on crawler JavaScript/render budget.
|
||||
|
||||
## Priority 1 (High Impact, Low Risk)
|
||||
|
||||
### 1) Add robots.txt
|
||||
- Allow crawling of public pages.
|
||||
- Block sensitive/admin/auth endpoints if needed.
|
||||
- Add a sitemap reference.
|
||||
|
||||
### 2) Add sitemap.xml
|
||||
- Include canonical URLs for:
|
||||
- /
|
||||
- /about
|
||||
- /schedule
|
||||
- /events
|
||||
- /contact
|
||||
- Keep lastmod values updated during deploys.
|
||||
- Submit sitemap in Google Search Console and Bing Webmaster Tools.
|
||||
|
||||
### 3) Implement route-level metadata
|
||||
- Set unique title and meta description per public route.
|
||||
- Add canonical tags per route.
|
||||
- Add Open Graph and Twitter card defaults plus per-route overrides.
|
||||
- Mark login/admin routes as noindex.
|
||||
|
||||
### 4) Strengthen crawl path discovery
|
||||
- Keep static crawlable links to all public pages in global navigation/footer.
|
||||
- Ensure key public routes are linked from multiple relevant pages.
|
||||
|
||||
### 5) Add structured data
|
||||
- Add Organization schema site-wide.
|
||||
- Add Event schema for upcoming events content.
|
||||
- Include station identity fields consistently (name, URL, logo, contact where appropriate).
|
||||
|
||||
## Priority 2 (Bigger Lift, Larger Upside)
|
||||
|
||||
### 6) Introduce prerender or SSR for public routes
|
||||
- Preferred first step: prerender static public routes.
|
||||
- Alternative: full SSR if dynamic metadata/content requires runtime generation.
|
||||
- Goal: deliver meaningful HTML to crawlers before JS execution.
|
||||
|
||||
### 7) Ensure metadata appears in initial HTML
|
||||
- Validate title/description/canonical in rendered source for each route.
|
||||
- Ensure each route has a clear H1 and meaningful introductory text in server-delivered HTML.
|
||||
|
||||
### 8) Keep baseline content available without API dependence
|
||||
- Include useful fallback content for core sections (home/about/schedule/events intros).
|
||||
- Treat API-loaded content as enhancement rather than sole source of indexable copy.
|
||||
|
||||
## Priority 3 (Quality + Ranking Support)
|
||||
|
||||
### 9) Improve Core Web Vitals
|
||||
- Optimize home LCP image and dimensions.
|
||||
- Defer non-critical work below the fold.
|
||||
- Reduce layout shifts around async content blocks.
|
||||
|
||||
### 10) Expand internal linking depth
|
||||
- Add contextual links between About, Schedule, Events, and Contact pages.
|
||||
- Use descriptive anchor text aligned with user intent.
|
||||
|
||||
### 11) Media SEO pass
|
||||
- Keep decorative images with empty alt attributes.
|
||||
- Add descriptive alt text for content-bearing images.
|
||||
|
||||
## Implementation Notes for This Repo
|
||||
- Place robots.txt and sitemap.xml in public assets so they are copied to build output.
|
||||
- Keep SPA fallback behavior in Nginx; SEO assets still resolve directly.
|
||||
- Use a centralized route-to-metadata mapping to avoid duplicate/incorrect canonical logic.
|
||||
|
||||
## Measurement Plan
|
||||
|
||||
### Week 1 Baseline
|
||||
- Capture Search Console index coverage and excluded reasons.
|
||||
- Record impressions/clicks for branded and local intent queries.
|
||||
- Run Lighthouse on all public routes.
|
||||
|
||||
### Weeks 2-3 (after Priority 1)
|
||||
- Validate robots.txt and sitemap accessibility.
|
||||
- Validate route metadata and canonical correctness.
|
||||
- Track indexed-page growth and snippet quality.
|
||||
|
||||
### Weeks 4-8 (after Priority 2)
|
||||
- Compare crawl stats and indexing completeness before/after prerender or SSR.
|
||||
- Track ranking movement for schedule/events/community-intent terms.
|
||||
- Validate rich result eligibility for structured data.
|
||||
|
||||
## Acceptance Criteria
|
||||
- robots.txt and sitemap.xml are live and valid.
|
||||
- All public routes have unique title, description, and canonical.
|
||||
- Public routes are indexable and admin/login are noindex.
|
||||
- Search Console shows healthy discovery/indexing across primary routes.
|
||||
- Public pages render useful content for crawlers without relying solely on JS.
|
||||
|
||||
## Risks and Mitigations
|
||||
- Risk: JS render delays cause partial indexing.
|
||||
- Mitigation: prerender/SSR and stronger baseline HTML.
|
||||
- Risk: API issues reduce crawler-visible content.
|
||||
- Mitigation: robust fallback copy and error handling.
|
||||
- Risk: canonical misconfiguration causes duplicate indexing.
|
||||
- Mitigation: centralized canonical helper + validation checks.
|
||||
|
||||
## Recommended Execution Order
|
||||
1. robots.txt + sitemap.xml
|
||||
2. route-level metadata and canonicals
|
||||
3. structured data (Organization + Event)
|
||||
4. prerender for public routes
|
||||
5. performance and internal-linking refinement
|
||||
@@ -0,0 +1,36 @@
|
||||
<div class="tab-content">
|
||||
<div class="tab-toolbar">
|
||||
<h2>Community Highlights ({{ (highlights$ | async)?.length }})</h2>
|
||||
<button class="btn btn-add" (click)="onAdd()">+ Add Highlight</button>
|
||||
</div>
|
||||
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Icon</th>
|
||||
<th>Title</th>
|
||||
<th>Order</th>
|
||||
<th>Active</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (highlight of highlights$ | async; track highlight.id) {
|
||||
<tr>
|
||||
<td>{{ highlight.icon }}</td>
|
||||
<td>{{ highlight.title }}</td>
|
||||
<td>{{ highlight.display_order }}</td>
|
||||
<td>{{ highlight.active ? 'Yes' : 'No' }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn-icon btn-edit" (click)="onEdit(highlight)">Edit</button>
|
||||
<button class="btn-icon btn-delete" (click)="onDelete(highlight.id)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5" class="empty-row">No highlights yet. Click "Add Highlight" to create one.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,2 @@
|
||||
@use '../../styles/variables' as *;
|
||||
@use '../../styles/mixins' as *;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Component, inject, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { CommunityHighlight } from '../interfaces/community-highlight';
|
||||
import { CommunityHighlightService } from '../services/community-highlight.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-community-tab',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './admin-community-tab.component.html',
|
||||
styleUrl: './admin-community-tab.component.scss',
|
||||
})
|
||||
export class AdminCommunityTabComponent {
|
||||
private communityHighlightService = inject(CommunityHighlightService);
|
||||
|
||||
readonly openForm = output<void>();
|
||||
readonly editItem = output<CommunityHighlight>();
|
||||
readonly deleteItem = output<number>();
|
||||
|
||||
readonly highlights$ = this.communityHighlightService.getCommunityHighlights();
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
await firstValueFrom(this.communityHighlightService.getCommunityHighlights());
|
||||
}
|
||||
|
||||
onAdd(): void {
|
||||
this.openForm.emit();
|
||||
}
|
||||
|
||||
onEdit(highlight: CommunityHighlight): void {
|
||||
this.editItem.emit(highlight);
|
||||
}
|
||||
|
||||
async onDelete(id: number): Promise<void> {
|
||||
if (!confirm('Delete this community highlight?')) return;
|
||||
await firstValueFrom(this.communityHighlightService.deleteCommunityHighlight(id));
|
||||
await this.load();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<div class="tab-content">
|
||||
<div class="tab-toolbar">
|
||||
<h2>Events ({{ (events$ | async)?.length }})</h2>
|
||||
<button class="btn btn-add" (click)="onAdd()">+ Add Event</button>
|
||||
</div>
|
||||
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Title</th>
|
||||
<th>Location</th>
|
||||
<th>Active</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (event of events$ | async; track event.id) {
|
||||
<tr>
|
||||
<td>{{ event.date }}</td>
|
||||
<td>{{ event.title }}</td>
|
||||
<td>{{ event.location }}</td>
|
||||
<td>{{ event.active ? 'Yes' : 'No' }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn-icon btn-edit" (click)="onEdit(event)">Edit</button>
|
||||
<button class="btn-icon btn-delete" (click)="onDelete(event.id)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5" class="empty-row">No events yet. Click "Add Event" to create one.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,2 @@
|
||||
@use '../../styles/variables' as *;
|
||||
@use '../../styles/mixins' as *;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Component, inject, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { Event as StationEvent } from '../interfaces/event';
|
||||
import { EventService } from '../services/event.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-events-tab',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './admin-events-tab.component.html',
|
||||
styleUrl: './admin-events-tab.component.scss',
|
||||
})
|
||||
export class AdminEventsTabComponent {
|
||||
private eventService = inject(EventService);
|
||||
|
||||
readonly openForm = output<void>();
|
||||
readonly editItem = output<StationEvent>();
|
||||
readonly deleteItem = output<number>();
|
||||
|
||||
readonly events$ = this.eventService.getEvents();
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
await firstValueFrom(this.eventService.getEvents());
|
||||
}
|
||||
|
||||
onAdd(): void {
|
||||
this.openForm.emit();
|
||||
}
|
||||
|
||||
onEdit(event: StationEvent): void {
|
||||
this.editItem.emit(event);
|
||||
}
|
||||
|
||||
async onDelete(id: number): Promise<void> {
|
||||
if (!confirm('Delete this event?')) return;
|
||||
await firstValueFrom(this.eventService.deleteEvent(id));
|
||||
await this.load();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<div class="tab-content">
|
||||
<div class="tab-toolbar">
|
||||
<h2>History Timeline ({{ (entries$ | async)?.length }})</h2>
|
||||
<button class="btn btn-add" (click)="onAdd()">+ Add Entry</button>
|
||||
</div>
|
||||
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Year</th>
|
||||
<th>Title</th>
|
||||
<th>Order</th>
|
||||
<th>Active</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (entry of entries$ | async; track entry.id) {
|
||||
<tr>
|
||||
<td>{{ entry.year }}</td>
|
||||
<td>{{ entry.title }}</td>
|
||||
<td>{{ entry.display_order }}</td>
|
||||
<td>{{ entry.active ? 'Yes' : 'No' }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn-icon btn-edit" (click)="onEdit(entry)">Edit</button>
|
||||
<button class="btn-icon btn-delete" (click)="onDelete(entry.id)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5" class="empty-row">No history entries yet. Click "Add Entry" to create one.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,2 @@
|
||||
@use '../../styles/variables' as *;
|
||||
@use '../../styles/mixins' as *;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Component, inject, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { HistoryEntry } from '../interfaces/history-entry';
|
||||
import { HistoryEntryService } from '../services/history-entry.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-history-tab',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './admin-history-tab.component.html',
|
||||
styleUrl: './admin-history-tab.component.scss',
|
||||
})
|
||||
export class AdminHistoryTabComponent {
|
||||
private historyEntryService = inject(HistoryEntryService);
|
||||
|
||||
readonly openForm = output<void>();
|
||||
readonly editItem = output<HistoryEntry>();
|
||||
readonly deleteItem = output<number>();
|
||||
|
||||
readonly entries$ = this.historyEntryService.getHistoryEntries();
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
await firstValueFrom(this.historyEntryService.getHistoryEntries());
|
||||
}
|
||||
|
||||
onAdd(): void {
|
||||
this.openForm.emit();
|
||||
}
|
||||
|
||||
onEdit(entry: HistoryEntry): void {
|
||||
this.editItem.emit(entry);
|
||||
}
|
||||
|
||||
async onDelete(id: number): Promise<void> {
|
||||
if (!confirm('Delete this history entry?')) return;
|
||||
await firstValueFrom(this.historyEntryService.deleteHistoryEntry(id));
|
||||
await this.load();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
<div class="tab-content mobile-builds-panel">
|
||||
<div class="tab-toolbar">
|
||||
<h2>Mobile Build Orchestration</h2>
|
||||
<button class="btn btn-add" (click)="reloadMobileBuilds()">Refresh Requests</button>
|
||||
</div>
|
||||
|
||||
<div class="mobile-build-grid">
|
||||
<section class="build-card">
|
||||
<h3>Derived Defaults</h3>
|
||||
@if (mobileBuildDefaults; as defaults) {
|
||||
<div class="derived-list">
|
||||
<div><strong>Tenant:</strong> {{ defaults.tenant_key }}</div>
|
||||
<div><strong>App Name:</strong> {{ defaults.app_name }}</div>
|
||||
<div><strong>Support Email:</strong> {{ defaults.support_email }}</div>
|
||||
<div><strong>Website:</strong> {{ defaults.website }}</div>
|
||||
<div><strong>Stream URL:</strong> {{ defaults.stream_url || '(not set)' }}</div>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="empty-row">Defaults unavailable. Check station config.</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="build-card">
|
||||
<h3>Theme Export</h3>
|
||||
@if (themeJsonStatus$ | async; as status) {
|
||||
@if (status.exists) {
|
||||
<div class="theme-status-ok">
|
||||
<div><strong>theme.json</strong> — ready</div>
|
||||
<div>Path: {{ status.path }}</div>
|
||||
<div>Generated: {{ status.generated_at }}</div>
|
||||
<div>Size: {{ status.size }} bytes</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="theme-status-missing">
|
||||
<p><strong>theme.json</strong> not found at {{ status.path }}</p>
|
||||
<p>Regenerate to create it from the current station config.</p>
|
||||
</div>
|
||||
}
|
||||
<button class="btn btn-outline" (click)="regenerateThemeJson()"
|
||||
[disabled]="themeJsonLoading$ | async">
|
||||
{{ (themeJsonLoading$ | async) ? 'Regenerating...' : 'Regenerate' }}
|
||||
</button>
|
||||
} @else {
|
||||
@if (themeJsonLoading$ | async) {
|
||||
<p class="empty-row">Loading...</p>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="build-card">
|
||||
<h3>Create Build Request</h3>
|
||||
|
||||
<div class="form-row platform-row">
|
||||
<label>
|
||||
<input type="checkbox" [(ngModel)]="buildAndroid">
|
||||
Android
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" [(ngModel)]="buildIos">
|
||||
iOS
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>
|
||||
Android Application ID
|
||||
<input type="text" [(ngModel)]="androidApplicationId" placeholder="com.example.radio">
|
||||
</label>
|
||||
<label>
|
||||
iOS Bundle ID
|
||||
<input type="text" [(ngModel)]="iosBundleId" placeholder="com.example.radio">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>
|
||||
Version Name
|
||||
<input type="text" [(ngModel)]="buildVersionName" placeholder="1.0.0">
|
||||
</label>
|
||||
<label>
|
||||
Build Number
|
||||
<input type="text" [(ngModel)]="buildNumber" placeholder="1">
|
||||
</label>
|
||||
<label>
|
||||
Release Profile
|
||||
<input type="text" [(ngModel)]="buildReleaseProfile" placeholder="release">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<h4>Required Files</h4>
|
||||
<div class="form-row">
|
||||
<label>
|
||||
Android Keystore (.jks/.keystore)
|
||||
<input type="file" (change)="onFileChosen($event, 'android-keystore')">
|
||||
</label>
|
||||
<label>
|
||||
Android Descriptor (JSON)
|
||||
<input type="file" accept=".json,application/json" (change)="onFileChosen($event, 'android-descriptor')">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>
|
||||
iOS Certificate (.p12)
|
||||
<input type="file" accept=".p12" (change)="onFileChosen($event, 'ios-certificate')">
|
||||
</label>
|
||||
<label>
|
||||
iOS Provisioning Profile (.mobileprovision)
|
||||
<input type="file" accept=".mobileprovision" (change)="onFileChosen($event, 'ios-profile')">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (mobileBuildStatusMessage) {
|
||||
<p class="status success">{{ mobileBuildStatusMessage }}</p>
|
||||
}
|
||||
@if (mobileBuildErrorMessage) {
|
||||
<p class="status error">{{ mobileBuildErrorMessage }}</p>
|
||||
}
|
||||
|
||||
<button class="btn btn-add" [disabled]="mobileBuildBusy" (click)="createAndUploadMobileBuildRequest()">
|
||||
{{ mobileBuildBusy ? 'Working…' : 'Create Draft + Upload Files' }}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="build-card">
|
||||
<h3>Build Requests</h3>
|
||||
@if (mobileBuilds$ | async; as builds) {
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Platforms</th>
|
||||
<th>Version</th>
|
||||
<th>Status</th>
|
||||
<th>Preflight</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (build of builds; track build.id) {
|
||||
<tr [class.selected-build]="selectedMobileBuildId === build.id">
|
||||
<td>#{{ build.id }}</td>
|
||||
<td>
|
||||
{{ build.platform_android ? 'Android' : '' }}
|
||||
{{ build.platform_android && build.platform_ios ? ' + ' : '' }}
|
||||
{{ build.platform_ios ? 'iOS' : '' }}
|
||||
</td>
|
||||
<td>{{ build.version_name }} ({{ build.build_number }})</td>
|
||||
<td>{{ build.status }}</td>
|
||||
<td>{{ build.preflight_passed ? 'Passed' : 'Pending/Failed' }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn-icon btn-edit" (click)="onMobileBuildSelected(build.id)">Select</button>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6" class="empty-row">No build requests yet.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (selectedMobileBuild; as build) {
|
||||
<section class="build-card">
|
||||
<div class="tab-toolbar compact">
|
||||
<h3>Selected Request #{{ build.id }}</h3>
|
||||
<div class="action-group">
|
||||
<button class="btn-icon btn-edit" [disabled]="mobileBuildBusy" (click)="runPreflightForSelectedBuild()">Run
|
||||
Preflight</button>
|
||||
<button class="btn-icon btn-edit" [disabled]="mobileBuildBusy || !build.preflight_passed"
|
||||
(click)="triggerSelectedBuild()">Trigger Build</button>
|
||||
<button class="btn-icon btn-edit" [disabled]="mobileBuildBusy"
|
||||
(click)="refreshSelectedBuild()">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (mobileBuildPreflightIssues.length > 0) {
|
||||
<div class="status error">
|
||||
<strong>Preflight issues:</strong>
|
||||
<ul>
|
||||
@for (issue of mobileBuildPreflightIssues; track issue) {
|
||||
<li>{{ issue }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (build.error_message) {
|
||||
<p class="status error"><strong>Error:</strong> {{ build.error_message }}</p>
|
||||
}
|
||||
|
||||
<h4>Artifacts</h4>
|
||||
<div class="artifact-list">
|
||||
@for (artifact of build.artifacts; track artifact.id) {
|
||||
<div class="artifact-row">
|
||||
<div>
|
||||
<strong>{{ artifact.file_name }}</strong>
|
||||
<div class="artifact-meta">{{ artifact.platform }} • {{ artifact.artifact_type }} • {{
|
||||
artifact.sha256.slice(0, 12) }}… • {{ formatFileSize(artifact.size) }}</div>
|
||||
</div>
|
||||
<button class="btn-icon btn-edit"
|
||||
(click)="downloadArtifact(artifact)">
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
} @empty {
|
||||
<p class="empty-row">No artifacts available yet.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<h4>Build Log</h4>
|
||||
<div class="build-log"><pre>{{ build.build_log || 'No logs yet.' }}</pre></div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,200 @@
|
||||
@use '../../styles/variables' as *;
|
||||
@use '../../styles/mixins' as *;
|
||||
|
||||
.mobile-builds-panel {
|
||||
display: grid;
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
|
||||
.mobile-build-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.build-card {
|
||||
background: $neutral-white;
|
||||
border: 1px solid $neutral-light;
|
||||
border-radius: $radius-md;
|
||||
padding: $spacing-md;
|
||||
overflow: hidden;
|
||||
|
||||
h3,
|
||||
h4 {
|
||||
color: $primary-blue;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.derived-list {
|
||||
display: grid;
|
||||
gap: $spacing-xs;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: $spacing-sm;
|
||||
margin-bottom: $spacing-sm;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: $neutral-dark;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input[type='text'],
|
||||
input[type='file'] {
|
||||
border: 1px solid $neutral-light;
|
||||
border-radius: $radius-sm;
|
||||
padding: $spacing-xs $spacing-sm;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
}
|
||||
|
||||
.platform-row {
|
||||
grid-template-columns: auto auto;
|
||||
|
||||
label {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
border-radius: $radius-sm;
|
||||
padding: $spacing-sm;
|
||||
margin: $spacing-sm 0;
|
||||
font-size: 0.9rem;
|
||||
|
||||
&.success {
|
||||
background: rgba($success-green, 0.1);
|
||||
color: $success-green;
|
||||
}
|
||||
|
||||
&.error {
|
||||
background: rgba($danger-red, 0.1);
|
||||
color: $danger-red;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: $spacing-xs 0 0 $spacing-md;
|
||||
}
|
||||
}
|
||||
|
||||
.selected-build {
|
||||
background: rgba($primary-blue, 0.06);
|
||||
}
|
||||
|
||||
.tab-toolbar.compact {
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
.action-group {
|
||||
display: flex;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.artifact-list {
|
||||
display: grid;
|
||||
gap: $spacing-sm;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.artifact-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: 1px solid $neutral-light;
|
||||
border-radius: $radius-sm;
|
||||
padding: $spacing-sm;
|
||||
}
|
||||
|
||||
.artifact-meta {
|
||||
font-size: 0.78rem;
|
||||
color: $neutral-medium;
|
||||
}
|
||||
|
||||
.build-log {
|
||||
background: $neutral-black;
|
||||
color: $neutral-white;
|
||||
border-radius: $radius-sm;
|
||||
padding: $spacing-sm;
|
||||
max-height: 280px;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
font-size: 0.8rem;
|
||||
width: 100%;
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-status-ok,
|
||||
.theme-status-missing {
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border-radius: $radius-sm;
|
||||
margin-bottom: $spacing-sm;
|
||||
font-size: 0.88rem;
|
||||
color: $neutral-dark;
|
||||
}
|
||||
|
||||
.theme-status-ok {
|
||||
background: rgba($success-green, 0.08);
|
||||
border: 1px solid rgba($success-green, 0.25);
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.theme-status-missing p {
|
||||
margin: 0 0 $spacing-xs;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-status-missing {
|
||||
background: rgba($accent-orange, 0.08);
|
||||
border: 1px solid rgba($accent-orange, 0.25);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border: 2px solid $primary-blue;
|
||||
color: $primary-blue;
|
||||
border-radius: $radius-md;
|
||||
padding: $spacing-xs $spacing-md;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast, color $transition-fast;
|
||||
margin-top: $spacing-sm;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: $primary-blue;
|
||||
color: $neutral-white;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@include responsive(md) {
|
||||
.mobile-build-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.action-group {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { BehaviorSubject, firstValueFrom } from 'rxjs';
|
||||
|
||||
import {
|
||||
MobileBuildArtifact,
|
||||
MobileBuildDefaults,
|
||||
MobileBuildRequest,
|
||||
ThemeStatus,
|
||||
} from '../interfaces/mobile-build';
|
||||
import { MobileBuildService } from '../services/mobile-build.service';
|
||||
import { getAppConfig } from '../services/app-config.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-mobile-builds-tab',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './admin-mobile-builds-tab.component.html',
|
||||
styleUrl: './admin-mobile-builds-tab.component.scss',
|
||||
})
|
||||
export class AdminMobileBuildsTabComponent implements OnInit {
|
||||
private mobileBuildService = inject(MobileBuildService);
|
||||
|
||||
readonly mobileBuilds$ = new BehaviorSubject<MobileBuildRequest[]>([]);
|
||||
readonly themeJsonStatus$ = new BehaviorSubject<ThemeStatus | null>(null);
|
||||
readonly themeJsonLoading$ = new BehaviorSubject<boolean>(false);
|
||||
|
||||
mobileBuildDefaults: MobileBuildDefaults | null = null;
|
||||
selectedMobileBuildId: number | null = null;
|
||||
selectedMobileBuild: MobileBuildRequest | null = null;
|
||||
mobileBuildStatusMessage = '';
|
||||
mobileBuildErrorMessage = '';
|
||||
mobileBuildPreflightIssues: string[] = [];
|
||||
mobileBuildBusy = false;
|
||||
|
||||
buildAndroid = true;
|
||||
buildIos = true;
|
||||
androidApplicationId = '';
|
||||
iosBundleId = '';
|
||||
buildVersionName = '1.0.0';
|
||||
buildNumber = '1';
|
||||
buildReleaseProfile = 'release';
|
||||
|
||||
androidKeystoreFile: File | null = null;
|
||||
androidDescriptorFile: File | null = null;
|
||||
iosCertificateFile: File | null = null;
|
||||
iosProfileFile: File | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadThemeJsonStatus();
|
||||
this.reloadMobileBuildDefaults();
|
||||
this.reloadMobileBuilds();
|
||||
}
|
||||
|
||||
async loadThemeJsonStatus(): Promise<void> {
|
||||
try {
|
||||
this.themeJsonLoading$.next(true);
|
||||
this.themeJsonStatus$.next(await firstValueFrom(
|
||||
this.mobileBuildService.getThemeStatus(),
|
||||
));
|
||||
} catch {
|
||||
this.themeJsonStatus$.next(null);
|
||||
} finally {
|
||||
this.themeJsonLoading$.next(false);
|
||||
}
|
||||
}
|
||||
|
||||
async regenerateThemeJson(): Promise<void> {
|
||||
try {
|
||||
this.themeJsonLoading$.next(true);
|
||||
await firstValueFrom(this.mobileBuildService.regenerateTheme());
|
||||
await this.loadThemeJsonStatus();
|
||||
} catch (err) {
|
||||
this.mobileBuildErrorMessage = this.getErrorMessage(
|
||||
err,
|
||||
'Failed to regenerate theme.json.',
|
||||
);
|
||||
} finally {
|
||||
this.themeJsonLoading$.next(false);
|
||||
}
|
||||
}
|
||||
|
||||
async reloadMobileBuildDefaults(): Promise<void> {
|
||||
try {
|
||||
this.mobileBuildDefaults = await firstValueFrom(this.mobileBuildService.getDefaults());
|
||||
} catch {
|
||||
this.mobileBuildDefaults = null;
|
||||
}
|
||||
}
|
||||
|
||||
async reloadMobileBuilds(): Promise<void> {
|
||||
const data = await firstValueFrom(this.mobileBuildService.listRequests());
|
||||
this.mobileBuilds$.next(data);
|
||||
|
||||
if (this.selectedMobileBuildId !== null) {
|
||||
const selected = data.find((item) => item.id === this.selectedMobileBuildId) ?? null;
|
||||
this.selectedMobileBuild = selected;
|
||||
if (!selected) {
|
||||
this.selectedMobileBuildId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMobileBuildSelected(buildId: number): void {
|
||||
this.selectedMobileBuildId = buildId;
|
||||
const selected = this.mobileBuilds$.value.find((item) => item.id === buildId) ?? null;
|
||||
this.selectedMobileBuild = selected;
|
||||
this.mobileBuildPreflightIssues = selected?.preflight_report
|
||||
? selected.preflight_report.split('\n').filter((line) => line.trim().length > 0)
|
||||
: [];
|
||||
}
|
||||
|
||||
onFileChosen(event: Event, target: 'android-keystore' | 'android-descriptor' | 'ios-certificate' | 'ios-profile'): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files && input.files.length > 0 ? input.files[0] : null;
|
||||
|
||||
if (target === 'android-keystore') this.androidKeystoreFile = file;
|
||||
if (target === 'android-descriptor') this.androidDescriptorFile = file;
|
||||
if (target === 'ios-certificate') this.iosCertificateFile = file;
|
||||
if (target === 'ios-profile') this.iosProfileFile = file;
|
||||
}
|
||||
|
||||
private getErrorMessage(err: unknown, fallback: string): string {
|
||||
if (typeof err === 'object' && err !== null) {
|
||||
const maybeErr = err as { error?: { detail?: string | string[]; message?: string }; message?: string };
|
||||
const detail = maybeErr.error?.detail;
|
||||
if (typeof detail === 'string') return detail;
|
||||
if (Array.isArray(detail)) return detail.join(' | ');
|
||||
if (typeof maybeErr.error?.message === 'string') return maybeErr.error.message;
|
||||
if (typeof maybeErr.message === 'string') return maybeErr.message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private resetMobileBuildMessages(): void {
|
||||
this.mobileBuildStatusMessage = '';
|
||||
this.mobileBuildErrorMessage = '';
|
||||
}
|
||||
|
||||
private validateBuildInputs(): string | null {
|
||||
if (!this.buildAndroid && !this.buildIos) {
|
||||
return 'Select at least one platform.';
|
||||
}
|
||||
|
||||
if (this.buildAndroid && !this.androidApplicationId.trim()) {
|
||||
return 'Android application ID is required.';
|
||||
}
|
||||
|
||||
if (this.buildIos && !this.iosBundleId.trim()) {
|
||||
return 'iOS bundle ID is required.';
|
||||
}
|
||||
|
||||
if (!this.buildVersionName.trim() || !this.buildNumber.trim()) {
|
||||
return 'Version name and build number are required.';
|
||||
}
|
||||
|
||||
if (this.buildAndroid && (!this.androidKeystoreFile || !this.androidDescriptorFile)) {
|
||||
return 'Android requires both keystore and descriptor files.';
|
||||
}
|
||||
|
||||
if (this.buildIos && (!this.iosCertificateFile || !this.iosProfileFile)) {
|
||||
return 'iOS requires both certificate and provisioning profile files.';
|
||||
}
|
||||
|
||||
// Validate file extensions before hitting the backend
|
||||
const extChecks: Array<{ file: File | null; label: string; exts: string[] }> = [];
|
||||
if (this.buildAndroid) {
|
||||
extChecks.push({
|
||||
file: this.androidKeystoreFile,
|
||||
label: 'Android keystore',
|
||||
exts: ['.jks', '.keystore', '.p12'],
|
||||
});
|
||||
extChecks.push({
|
||||
file: this.androidDescriptorFile,
|
||||
label: 'Android descriptor',
|
||||
exts: ['.json'],
|
||||
});
|
||||
}
|
||||
if (this.buildIos) {
|
||||
extChecks.push({
|
||||
file: this.iosCertificateFile,
|
||||
label: 'iOS certificate',
|
||||
exts: ['.p12'],
|
||||
});
|
||||
extChecks.push({
|
||||
file: this.iosProfileFile,
|
||||
label: 'iOS provisioning profile',
|
||||
exts: ['.mobileprovision'],
|
||||
});
|
||||
}
|
||||
|
||||
for (const { file, label, exts } of extChecks) {
|
||||
if (!file) continue;
|
||||
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
|
||||
if (!exts.includes(ext)) {
|
||||
return `${label} must have one of: ${exts.join(', ')}`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async createAndUploadMobileBuildRequest(): Promise<void> {
|
||||
this.resetMobileBuildMessages();
|
||||
this.mobileBuildPreflightIssues = [];
|
||||
const inputError = this.validateBuildInputs();
|
||||
if (inputError) {
|
||||
this.mobileBuildErrorMessage = inputError;
|
||||
return;
|
||||
}
|
||||
|
||||
this.mobileBuildBusy = true;
|
||||
let createdRequestId: number | null = null;
|
||||
try {
|
||||
const request = await firstValueFrom(this.mobileBuildService.createRequest({
|
||||
platform_android: this.buildAndroid,
|
||||
platform_ios: this.buildIos,
|
||||
android_application_id: this.androidApplicationId.trim() || undefined,
|
||||
ios_bundle_id: this.iosBundleId.trim() || undefined,
|
||||
version_name: this.buildVersionName.trim(),
|
||||
build_number: this.buildNumber.trim(),
|
||||
release_profile: this.buildReleaseProfile.trim() || 'release',
|
||||
}));
|
||||
createdRequestId = request.id;
|
||||
|
||||
if (this.buildAndroid && this.androidKeystoreFile && this.androidDescriptorFile) {
|
||||
await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'android', 'keystore', this.androidKeystoreFile));
|
||||
await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'android', 'descriptor', this.androidDescriptorFile));
|
||||
}
|
||||
|
||||
if (this.buildIos && this.iosCertificateFile && this.iosProfileFile) {
|
||||
await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'ios', 'certificate', this.iosCertificateFile));
|
||||
await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'ios', 'profile', this.iosProfileFile));
|
||||
}
|
||||
|
||||
this.mobileBuildStatusMessage = `Draft build request #${request.id} created and files uploaded.`;
|
||||
await this.reloadMobileBuilds();
|
||||
this.onMobileBuildSelected(request.id);
|
||||
} catch (err) {
|
||||
// Clean up orphaned request if files couldn't all be uploaded
|
||||
if (createdRequestId !== null) {
|
||||
await firstValueFrom(
|
||||
this.mobileBuildService.deleteRequest(createdRequestId),
|
||||
).catch(() => {}); // best-effort cleanup
|
||||
await this.reloadMobileBuilds().catch(() => {});
|
||||
}
|
||||
this.mobileBuildErrorMessage = this.getErrorMessage(err, 'Failed to create build request.');
|
||||
} finally {
|
||||
this.mobileBuildBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async runPreflightForSelectedBuild(): Promise<void> {
|
||||
if (!this.selectedMobileBuildId) return;
|
||||
|
||||
this.resetMobileBuildMessages();
|
||||
this.mobileBuildBusy = true;
|
||||
try {
|
||||
const result = await firstValueFrom(this.mobileBuildService.preflight(this.selectedMobileBuildId));
|
||||
this.mobileBuildPreflightIssues = result.issues;
|
||||
this.mobileBuildStatusMessage = result.passed
|
||||
? 'Preflight passed. Build can be triggered.'
|
||||
: 'Preflight failed. Resolve the listed issues.';
|
||||
await this.reloadMobileBuilds();
|
||||
this.onMobileBuildSelected(this.selectedMobileBuildId);
|
||||
} catch (err) {
|
||||
this.mobileBuildErrorMessage = this.getErrorMessage(err, 'Preflight failed to run.');
|
||||
} finally {
|
||||
this.mobileBuildBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async triggerSelectedBuild(): Promise<void> {
|
||||
if (!this.selectedMobileBuildId) return;
|
||||
|
||||
this.resetMobileBuildMessages();
|
||||
this.mobileBuildBusy = true;
|
||||
try {
|
||||
const request = await firstValueFrom(this.mobileBuildService.trigger(this.selectedMobileBuildId));
|
||||
this.mobileBuildStatusMessage = `Build #${request.id} queued.`;
|
||||
await this.reloadMobileBuilds();
|
||||
this.onMobileBuildSelected(this.selectedMobileBuildId);
|
||||
} catch (err) {
|
||||
this.mobileBuildErrorMessage = this.getErrorMessage(err, 'Failed to trigger build.');
|
||||
} finally {
|
||||
this.mobileBuildBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async refreshSelectedBuild(): Promise<void> {
|
||||
if (!this.selectedMobileBuildId) return;
|
||||
await this.reloadMobileBuilds();
|
||||
this.onMobileBuildSelected(this.selectedMobileBuildId);
|
||||
}
|
||||
|
||||
getArtifactDownloadHref(path: string): string {
|
||||
return `${getAppConfig().apiBaseUrl}${path}`;
|
||||
}
|
||||
|
||||
formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
const size = bytes / Math.pow(1024, i);
|
||||
return `${size.toFixed(size < 10 ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
|
||||
downloadArtifact(artifact: MobileBuildArtifact): void {
|
||||
this.mobileBuildService
|
||||
.getDownloadUrl(artifact.id)
|
||||
.subscribe({
|
||||
next: (res) => {
|
||||
window.open(res.download_url, '_blank');
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to get download URL:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<div class="tab-content">
|
||||
<div class="tab-toolbar">
|
||||
<h2>Shows ({{ (shows$ | async)?.length }})</h2>
|
||||
<button class="btn btn-add" (click)="onAdd()">+ Add Show</button>
|
||||
</div>
|
||||
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Order</th>
|
||||
<th>Title</th>
|
||||
<th>Host</th>
|
||||
<th>Genre</th>
|
||||
<th>Slots</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (show of shows$ | async; track show.id) {
|
||||
<tr>
|
||||
<td>{{ show.display_order }}</td>
|
||||
<td>{{ show.title }}</td>
|
||||
<td>{{ show.host }}</td>
|
||||
<td>{{ show.genre }}</td>
|
||||
<td>
|
||||
@for (slot of show.schedules; track slot.id) {
|
||||
<span class="slot-badge">{{ slot.day_label.charAt(0) }} {{ slot.time }}</span>
|
||||
}
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="btn-icon btn-edit" (click)="onEdit(show)">Edit</button>
|
||||
<button class="btn-icon btn-delete" (click)="onDelete(show.id)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6" class="empty-row">No shows yet. Click "Add Show" to create one.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,2 @@
|
||||
@use '../../styles/variables' as *;
|
||||
@use '../../styles/mixins' as *;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Component, inject, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { Show } from '../interfaces/show';
|
||||
import { ShowService } from '../services/show.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-shows-tab',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './admin-shows-tab.component.html',
|
||||
styleUrl: './admin-shows-tab.component.scss',
|
||||
})
|
||||
export class AdminShowsTabComponent {
|
||||
private showService = inject(ShowService);
|
||||
|
||||
readonly openForm = output<void>();
|
||||
readonly editItem = output<Show>();
|
||||
readonly deleteItem = output<number>();
|
||||
|
||||
readonly shows$ = this.showService.getShows();
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
await firstValueFrom(this.showService.getShows());
|
||||
}
|
||||
|
||||
onAdd(): void {
|
||||
this.openForm.emit();
|
||||
}
|
||||
|
||||
onEdit(show: Show): void {
|
||||
this.editItem.emit(show);
|
||||
}
|
||||
|
||||
async onDelete(id: number): Promise<void> {
|
||||
if (!confirm('Delete this show?')) return;
|
||||
await firstValueFrom(this.showService.deleteShow(id));
|
||||
await this.load();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<div class="tab-content">
|
||||
<div class="tab-toolbar">
|
||||
<h2>Station Configuration</h2>
|
||||
<button class="btn btn-add" (click)="onEdit()">Edit Config</button>
|
||||
</div>
|
||||
|
||||
@if (config(); as config) {
|
||||
<div class="station-config-view">
|
||||
<div class="config-row">
|
||||
<span class="config-label">Callsign</span>
|
||||
<span class="config-value">{{ config.callsign }}</span>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<span class="config-label">Name</span>
|
||||
<span class="config-value">{{ config.name_primary }} {{ config.name_secondary }}</span>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<span class="config-label">Tagline</span>
|
||||
<span class="config-value">{{ config.tagline }}</span>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<span class="config-label">Frequency</span>
|
||||
<span class="config-value">{{ config.frequency }}</span>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<span class="config-label">Founded</span>
|
||||
<span class="config-value">{{ config.founded_year }}</span>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<span class="config-label">Nonprofit</span>
|
||||
<span class="config-value">{{ config.nonprofit_type }} — EIN {{ config.ein }}</span>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<span class="config-label">Email</span>
|
||||
<span class="config-value">{{ config.email }}</span>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<span class="config-label">Phone</span>
|
||||
<span class="config-value">{{ config.phone }}</span>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<span class="config-label">Website</span>
|
||||
<span class="config-value">{{ config.website }}</span>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<span class="config-label">Donation URL</span>
|
||||
<span class="config-value">{{ config.donation_url || '(not set)' }}</span>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<span class="config-label">Play Store URL</span>
|
||||
<span class="config-value">{{ config.play_store_url || '(not set)' }}</span>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<span class="config-label">App Store Embed</span>
|
||||
<span class="config-value">{{ config.app_store_embed_html ? 'Configured' : '(not set)' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,2 @@
|
||||
@use '../../styles/variables' as *;
|
||||
@use '../../styles/mixins' as *;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Component, inject, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { StationConfigService } from '../services/station-config.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-station-tab',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './admin-station-tab.component.html',
|
||||
styleUrl: './admin-station-tab.component.scss',
|
||||
})
|
||||
export class AdminStationTabComponent {
|
||||
private stationConfigService = inject(StationConfigService);
|
||||
|
||||
readonly openForm = output<void>();
|
||||
|
||||
readonly config = this.stationConfigService.config;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.stationConfigService.load();
|
||||
}
|
||||
|
||||
onEdit(): void {
|
||||
this.openForm.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<div class="tab-content">
|
||||
<div class="tab-toolbar">
|
||||
<h2>Team Members ({{ (members$ | async)?.length }})</h2>
|
||||
<button class="btn btn-add" (click)="onAdd()">+ Add Member</button>
|
||||
</div>
|
||||
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Title</th>
|
||||
<th>Order</th>
|
||||
<th>Active</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (member of members$ | async; track member.id) {
|
||||
<tr>
|
||||
<td>{{ member.name }}</td>
|
||||
<td>{{ member.title }}</td>
|
||||
<td>{{ member.display_order }}</td>
|
||||
<td>{{ member.active ? 'Yes' : 'No' }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn-icon btn-edit" (click)="onEdit(member)">Edit</button>
|
||||
<button class="btn-icon btn-delete" (click)="onDelete(member.id)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5" class="empty-row">No team members yet. Click "Add Member" to create one.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,2 @@
|
||||
@use '../../styles/variables' as *;
|
||||
@use '../../styles/mixins' as *;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Component, inject, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { TeamMember } from '../interfaces/team-member';
|
||||
import { TeamMemberService } from '../services/team-member.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-team-tab',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './admin-team-tab.component.html',
|
||||
styleUrl: './admin-team-tab.component.scss',
|
||||
})
|
||||
export class AdminTeamTabComponent {
|
||||
private teamMemberService = inject(TeamMemberService);
|
||||
|
||||
readonly openForm = output<void>();
|
||||
readonly editItem = output<TeamMember>();
|
||||
readonly deleteItem = output<number>();
|
||||
|
||||
readonly members$ = this.teamMemberService.getTeamMembers();
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
await firstValueFrom(this.teamMemberService.getTeamMembers());
|
||||
}
|
||||
|
||||
onAdd(): void {
|
||||
this.openForm.emit();
|
||||
}
|
||||
|
||||
onEdit(member: TeamMember): void {
|
||||
this.editItem.emit(member);
|
||||
}
|
||||
|
||||
async onDelete(id: number): Promise<void> {
|
||||
if (!confirm('Delete this team member?')) return;
|
||||
await firstValueFrom(this.teamMemberService.deleteTeamMember(id));
|
||||
await this.load();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<div class="tab-content theme-panel">
|
||||
<div class="tab-toolbar">
|
||||
<h2>Color Theme</h2>
|
||||
</div>
|
||||
|
||||
<div class="theme-harmony-bar">
|
||||
<div class="harmony-info">
|
||||
<span class="harmony-label">Harmony:</span>
|
||||
<span class="harmony-rule">{{ getHarmonyLabel() }}</span>
|
||||
</div>
|
||||
<label class="auto-variants-toggle">
|
||||
<input type="checkbox" [(ngModel)]="themeAutoVariants">
|
||||
Auto-generate variants
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (themeSaveSuccess) {
|
||||
<div class="theme-message theme-success">{{ themeSaveSuccess }}</div>
|
||||
}
|
||||
@if (themeSaveError) {
|
||||
<div class="theme-message theme-error">{{ themeSaveError }}</div>
|
||||
}
|
||||
|
||||
<div class="color-grid">
|
||||
@for (role of colorRoles; track role.field) {
|
||||
<div class="color-card" [class.has-issue]="hasHarmonyIssue(role.field)">
|
||||
<div class="color-swatch" [style.background]="themeColors[role.field]">
|
||||
<input type="color" [value]="themeColors[role.field]"
|
||||
(input)="onThemeColorChange(role.field, $event.target.value)">
|
||||
</div>
|
||||
<div class="color-info">
|
||||
<span class="color-name">{{ role.label }}</span>
|
||||
<span class="color-hex">{{ themeColors[role.field] }}</span>
|
||||
</div>
|
||||
<div class="color-status">
|
||||
@if (hasHarmonyIssue(role.field)) {
|
||||
<span class="harmony-warn" title="Not harmonious — click Auto-Fix to correct">⚠</span>
|
||||
} @else if (role.group !== 'neutral') {
|
||||
<span class="harmony-ok">✓</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="theme-actions">
|
||||
<button type="button" class="btn btn-outline-theme" (click)="resetTheme()">Reset to Defaults</button>
|
||||
<button type="button" class="btn btn-outline-theme" (click)="autoFixTheme()">Auto-Fix Harmony</button>
|
||||
<button type="button" class="btn btn-save-theme" (click)="saveTheme()" [disabled]="themeSaving">
|
||||
{{ themeSaving ? 'Saving…' : 'Save Theme' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,192 @@
|
||||
@use '../../styles/variables' as *;
|
||||
@use '../../styles/mixins' as *;
|
||||
|
||||
.theme-panel {
|
||||
.theme-harmony-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: $spacing-md;
|
||||
background: rgba($primary-blue, 0.05);
|
||||
border-radius: $radius-md;
|
||||
margin-bottom: $spacing-lg;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-sm;
|
||||
|
||||
.harmony-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
|
||||
.harmony-label {
|
||||
font-weight: 600;
|
||||
color: $neutral-medium;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.harmony-rule {
|
||||
color: $primary-blue;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
.auto-variants-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
font-size: 0.875rem;
|
||||
color: $neutral-dark;
|
||||
cursor: pointer;
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.theme-message {
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border-radius: $radius-md;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: $spacing-md;
|
||||
text-align: center;
|
||||
|
||||
&.theme-success {
|
||||
background: rgba($success-green, 0.1);
|
||||
color: $success-green;
|
||||
}
|
||||
|
||||
&.theme-error {
|
||||
background: rgba($danger-red, 0.1);
|
||||
color: $danger-red;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.color-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: $spacing-md;
|
||||
margin-bottom: $spacing-xl;
|
||||
}
|
||||
|
||||
.color-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
background: $neutral-white;
|
||||
border: 2px solid $neutral-light;
|
||||
border-radius: $radius-md;
|
||||
transition: border-color $transition-fast, box-shadow $transition-fast;
|
||||
|
||||
&.has-issue {
|
||||
border-color: #e6a817;
|
||||
box-shadow: 0 0 0 1px rgba(230, 168, 23, 0.3);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: $primary-blue-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: $radius-sm;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
|
||||
transition: transform $transition-fast;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
input[type="color"] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
.color-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
.color-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: $neutral-dark;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.color-hex {
|
||||
font-size: 0.75rem;
|
||||
color: $neutral-medium;
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
}
|
||||
|
||||
.color-status {
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
|
||||
.harmony-ok {
|
||||
color: $success-green;
|
||||
}
|
||||
|
||||
.harmony-warn {
|
||||
color: #e6a817;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: $spacing-sm;
|
||||
flex-wrap: wrap;
|
||||
padding-top: $spacing-md;
|
||||
border-top: 1px solid $neutral-light;
|
||||
}
|
||||
|
||||
.btn-save-theme {
|
||||
@include button-style($primary-blue, $neutral-white, $primary-blue-light);
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-outline-theme {
|
||||
background: transparent;
|
||||
border: 2px solid $primary-blue;
|
||||
color: $primary-blue;
|
||||
border-radius: $radius-md;
|
||||
padding: $spacing-sm $spacing-lg;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast, color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: $primary-blue;
|
||||
color: $neutral-white;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
import { StationConfigService } from '../services/station-config.service';
|
||||
import { ThemeService } from '../services/theme.service';
|
||||
import {
|
||||
COLOR_ROLES,
|
||||
HARMONY_RULE_LABELS,
|
||||
detectHarmonyRule,
|
||||
validatePalette,
|
||||
autoFixPalette,
|
||||
generateLight,
|
||||
generateDark,
|
||||
generateMuted,
|
||||
HarmonyRule,
|
||||
ColorValidation,
|
||||
} from '../utils/color-harmony';
|
||||
|
||||
interface ThemeColorState {
|
||||
color_primary: string;
|
||||
color_primary_light: string;
|
||||
color_primary_dark: string;
|
||||
color_primary_muted: string;
|
||||
color_accent: string;
|
||||
color_accent_light: string;
|
||||
color_accent_dark: string;
|
||||
color_accent_muted: string;
|
||||
color_background: string;
|
||||
color_text: string;
|
||||
color_success: string;
|
||||
color_danger: string;
|
||||
color_info: string;
|
||||
color_white: string;
|
||||
color_light: string;
|
||||
color_medium: string;
|
||||
color_black: string;
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-theme-tab',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './admin-theme-tab.component.html',
|
||||
styleUrl: './admin-theme-tab.component.scss',
|
||||
})
|
||||
export class AdminThemeTabComponent {
|
||||
private stationConfigService = inject(StationConfigService);
|
||||
private themeService = inject(ThemeService);
|
||||
|
||||
readonly colorRoles = COLOR_ROLES;
|
||||
|
||||
themeColors: ThemeColorState = {} as ThemeColorState;
|
||||
themeHarmonyRule: HarmonyRule = 'none';
|
||||
themeValidation: { [key: string]: ColorValidation } = {};
|
||||
themeAutoVariants = true;
|
||||
themeSaving = false;
|
||||
themeSaveSuccess = '';
|
||||
themeSaveError = '';
|
||||
|
||||
readonly themeDefaults: ThemeColorState = {
|
||||
color_primary: '#1a3a5c',
|
||||
color_primary_light: '#2a5a8c',
|
||||
color_primary_dark: '#0e2440',
|
||||
color_primary_muted: '#3a6a9c',
|
||||
color_accent: '#e87a2e',
|
||||
color_accent_light: '#f09a4e',
|
||||
color_accent_dark: '#c85a1e',
|
||||
color_accent_muted: '#f0a86a',
|
||||
color_background: '#faf6f0',
|
||||
color_text: '#3a3632',
|
||||
color_success: '#4a9e4f',
|
||||
color_danger: '#c83030',
|
||||
color_info: '#3a8abf',
|
||||
color_white: '#ffffff',
|
||||
color_light: '#f0ebe3',
|
||||
color_medium: '#8a8580',
|
||||
color_black: '#1a1816',
|
||||
};
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadThemeColors();
|
||||
}
|
||||
|
||||
loadThemeColors(): void {
|
||||
const config = this.stationConfigService.config();
|
||||
this.themeColors = {
|
||||
color_primary: config.color_primary || this.themeDefaults.color_primary,
|
||||
color_primary_light: config.color_primary_light || this.themeDefaults.color_primary_light,
|
||||
color_primary_dark: config.color_primary_dark || this.themeDefaults.color_primary_dark,
|
||||
color_primary_muted: config.color_primary_muted || this.themeDefaults.color_primary_muted,
|
||||
color_accent: config.color_accent || this.themeDefaults.color_accent,
|
||||
color_accent_light: config.color_accent_light || this.themeDefaults.color_accent_light,
|
||||
color_accent_dark: config.color_accent_dark || this.themeDefaults.color_accent_dark,
|
||||
color_accent_muted: config.color_accent_muted || this.themeDefaults.color_accent_muted,
|
||||
color_background: config.color_background || this.themeDefaults.color_background,
|
||||
color_text: config.color_text || this.themeDefaults.color_text,
|
||||
color_success: config.color_success || this.themeDefaults.color_success,
|
||||
color_danger: config.color_danger || this.themeDefaults.color_danger,
|
||||
color_info: config.color_info || this.themeDefaults.color_info,
|
||||
color_white: config.color_white || this.themeDefaults.color_white,
|
||||
color_light: config.color_light || this.themeDefaults.color_light,
|
||||
color_medium: config.color_medium || this.themeDefaults.color_medium,
|
||||
color_black: config.color_black || this.themeDefaults.color_black,
|
||||
};
|
||||
this.themeSaveSuccess = '';
|
||||
this.themeSaveError = '';
|
||||
this.revalidateTheme();
|
||||
}
|
||||
|
||||
revalidateTheme(): void {
|
||||
const primary = this.themeColors.color_primary;
|
||||
const accent = this.themeColors.color_accent;
|
||||
this.themeHarmonyRule = detectHarmonyRule(primary, accent);
|
||||
this.themeValidation = validatePalette(this.themeColors, this.themeHarmonyRule);
|
||||
}
|
||||
|
||||
onThemeColorChange(field: string, value: string): void {
|
||||
this.themeColors[field] = value;
|
||||
|
||||
if (this.themeAutoVariants) {
|
||||
if (field === 'color_primary') {
|
||||
this.themeColors.color_primary_light = generateLight(value);
|
||||
this.themeColors.color_primary_dark = generateDark(value);
|
||||
this.themeColors.color_primary_muted = generateMuted(value);
|
||||
} else if (field === 'color_accent') {
|
||||
this.themeColors.color_accent_light = generateLight(value);
|
||||
this.themeColors.color_accent_dark = generateDark(value);
|
||||
this.themeColors.color_accent_muted = generateMuted(value);
|
||||
}
|
||||
}
|
||||
|
||||
this.themeService.apply(this.themeColors as any);
|
||||
this.revalidateTheme();
|
||||
}
|
||||
|
||||
autoFixTheme(): void {
|
||||
const fixed = autoFixPalette(this.themeColors, this.themeHarmonyRule);
|
||||
for (const key of Object.keys(fixed) as (keyof ThemeColorState)[]) {
|
||||
(this.themeColors as any)[key] = fixed[key];
|
||||
}
|
||||
this.themeService.apply(this.themeColors as any);
|
||||
this.revalidateTheme();
|
||||
}
|
||||
|
||||
resetTheme(): void {
|
||||
for (const key of Object.keys(this.themeDefaults) as (keyof ThemeColorState)[]) {
|
||||
(this.themeColors as any)[key] = this.themeDefaults[key];
|
||||
}
|
||||
this.themeService.apply(this.themeColors as any);
|
||||
this.revalidateTheme();
|
||||
}
|
||||
|
||||
async saveTheme(): Promise<void> {
|
||||
this.themeSaving = true;
|
||||
this.themeSaveError = '';
|
||||
this.themeSaveSuccess = '';
|
||||
|
||||
try {
|
||||
await this.stationConfigService.updateConfig(this.themeColors);
|
||||
this.themeSaveSuccess = 'Theme saved successfully!';
|
||||
} catch (err: any) {
|
||||
this.themeSaveError = err?.error?.detail || 'Failed to save theme.';
|
||||
} finally {
|
||||
this.themeSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
getHarmonyLabel(): string {
|
||||
return HARMONY_RULE_LABELS[this.themeHarmonyRule];
|
||||
}
|
||||
|
||||
hasHarmonyIssue(field: string): boolean {
|
||||
const v = this.themeValidation[field];
|
||||
return v ? !v.isHarmonious : false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<div class="tab-content">
|
||||
<div class="tab-toolbar">
|
||||
<h2>Underwriters ({{ (underwriters$ | async)?.length }})</h2>
|
||||
<button class="btn btn-add" (click)="onAdd()">+ Add Underwriter</button>
|
||||
</div>
|
||||
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Website</th>
|
||||
<th>Order</th>
|
||||
<th>Active</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (uw of underwriters$ | async; track uw.id) {
|
||||
<tr>
|
||||
<td>{{ uw.name }}</td>
|
||||
<td class="description-cell">{{ uw.description }}</td>
|
||||
<td>
|
||||
@if (uw.website_url) {
|
||||
<a [href]="uw.website_url" target="_blank" rel="noopener">{{ uw.website_url }}</a>
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
<td>{{ uw.display_order }}</td>
|
||||
<td>{{ uw.active ? 'Yes' : 'No' }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn-icon btn-edit" (click)="onEdit(uw)">Edit</button>
|
||||
<button class="btn-icon btn-delete" (click)="onDelete(uw.id)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6" class="empty-row">No underwriters yet. Click "Add Underwriter" to create one.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
@use '../../styles/variables' as *;
|
||||
@use '../../styles/mixins' as *;
|
||||
|
||||
.description-cell {
|
||||
max-width: 300px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Component, inject, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { Underwriter } from '../interfaces/underwriter';
|
||||
import { UnderwriterService } from '../services/underwriter.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-underwriters-tab',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './admin-underwriters-tab.component.html',
|
||||
styleUrl: './admin-underwriters-tab.component.scss',
|
||||
})
|
||||
export class AdminUnderwritersTabComponent {
|
||||
private underwriterService = inject(UnderwriterService);
|
||||
|
||||
readonly openForm = output<void>();
|
||||
readonly editItem = output<Underwriter>();
|
||||
readonly deleteItem = output<number>();
|
||||
|
||||
readonly underwriters$ = this.underwriterService.getUnderwriters();
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
await firstValueFrom(this.underwriterService.getUnderwriters());
|
||||
}
|
||||
|
||||
onAdd(): void {
|
||||
this.openForm.emit();
|
||||
}
|
||||
|
||||
onEdit(underwriter: Underwriter): void {
|
||||
this.editItem.emit(underwriter);
|
||||
}
|
||||
|
||||
async onDelete(id: number): Promise<void> {
|
||||
if (!confirm('Delete this underwriter?')) return;
|
||||
await firstValueFrom(this.underwriterService.deleteUnderwriter(id));
|
||||
await this.load();
|
||||
}
|
||||
}
|
||||
@@ -8,604 +8,66 @@
|
||||
<!-- Tabs -->
|
||||
<div class="admin-tabs">
|
||||
<button class="tab-btn" [class.active]="activeTab() === 'shows'" (click)="activeTab.set('shows')">Shows</button>
|
||||
<button class="tab-btn" [class.active]="activeTab() === 'events'"
|
||||
(click)="activeTab.set('events')">Events</button>
|
||||
<button class="tab-btn" [class.active]="activeTab() === 'history'"
|
||||
(click)="activeTab.set('history')">History</button>
|
||||
<button class="tab-btn" [class.active]="activeTab() === 'events'" (click)="activeTab.set('events')">Events</button>
|
||||
<button class="tab-btn" [class.active]="activeTab() === 'history'" (click)="activeTab.set('history')">History</button>
|
||||
<button class="tab-btn" [class.active]="activeTab() === 'team'" (click)="activeTab.set('team')">Team</button>
|
||||
<button class="tab-btn" [class.active]="activeTab() === 'community'"
|
||||
(click)="activeTab.set('community')">Community</button>
|
||||
<button class="tab-btn" [class.active]="activeTab() === 'station'"
|
||||
(click)="activeTab.set('station')">Station</button>
|
||||
<button class="tab-btn" [class.active]="activeTab() === 'theme'"
|
||||
(click)="activeTab.set('theme'); onTabChanged()">Theme</button>
|
||||
<button class="tab-btn" [class.active]="activeTab() === 'underwriters'"
|
||||
(click)="activeTab.set('underwriters')">Underwriters</button>
|
||||
<button class="tab-btn" [class.active]="activeTab() === 'mobile-builds'"
|
||||
(click)="activeTab.set('mobile-builds')">Mobile Builds</button>
|
||||
<button class="tab-btn" [class.active]="activeTab() === 'community'" (click)="activeTab.set('community')">Community</button>
|
||||
<button class="tab-btn" [class.active]="activeTab() === 'station'" (click)="activeTab.set('station')">Station</button>
|
||||
<button class="tab-btn" [class.active]="activeTab() === 'theme'" (click)="activeTab.set('theme')">Theme</button>
|
||||
<button class="tab-btn" [class.active]="activeTab() === 'underwriters'" (click)="activeTab.set('underwriters')">Underwriters</button>
|
||||
<button class="tab-btn" [class.active]="activeTab() === 'mobile-builds'" (click)="activeTab.set('mobile-builds')">Mobile Builds</button>
|
||||
<button class="tab-btn" [class.active]="activeTab() === 'stats'" (click)="activeTab.set('stats')">Stats</button>
|
||||
</div>
|
||||
|
||||
<!-- Shows Tab -->
|
||||
<!-- Tab content -->
|
||||
@if (activeTab() === 'shows') {
|
||||
@if (shows$ | async; as shows) {
|
||||
<div class="tab-content">
|
||||
<div class="tab-toolbar">
|
||||
<h2>Shows ({{ shows.length }})</h2>
|
||||
<button class="btn btn-add" (click)="openShowForm()">+ Add Show</button>
|
||||
</div>
|
||||
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Order</th>
|
||||
<th>Title</th>
|
||||
<th>Host</th>
|
||||
<th>Genre</th>
|
||||
<th>Slots</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (show of shows; track show.id) {
|
||||
<tr>
|
||||
<td>{{ show.display_order }}</td>
|
||||
<td>{{ show.title }}</td>
|
||||
<td>{{ show.host }}</td>
|
||||
<td>{{ show.genre }}</td>
|
||||
<td>
|
||||
@for (slot of show.schedules; track slot.id) {
|
||||
<span class="slot-badge">{{ slot.day_label.charAt(0) }} {{ slot.time }}</span>
|
||||
}
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="btn-icon btn-edit" (click)="editShow(show)">Edit</button>
|
||||
<button class="btn-icon btn-delete" (click)="deleteShow(show.id)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6" class="empty-row">No shows yet. Click "Add Show" to create one.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
<app-admin-shows-tab
|
||||
(openForm)="openShowForm()"
|
||||
(editItem)="editShow($event)" />
|
||||
}
|
||||
|
||||
<!-- Events Tab -->
|
||||
@if (activeTab() === 'events') {
|
||||
@if (events$ | async; as events) {
|
||||
<div class="tab-content">
|
||||
<div class="tab-toolbar">
|
||||
<h2>Events ({{ events.length }})</h2>
|
||||
<button class="btn btn-add" (click)="openEventForm()">+ Add Event</button>
|
||||
</div>
|
||||
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Title</th>
|
||||
<th>Location</th>
|
||||
<th>Active</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (event of events; track event.id) {
|
||||
<tr>
|
||||
<td>{{ event.date }}</td>
|
||||
<td>{{ event.title }}</td>
|
||||
<td>{{ event.location }}</td>
|
||||
<td>{{ event.active ? 'Yes' : 'No' }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn-icon btn-edit" (click)="editEvent(event)">Edit</button>
|
||||
<button class="btn-icon btn-delete" (click)="deleteEvent(event.id)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5" class="empty-row">No events yet. Click "Add Event" to create one.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
<app-admin-events-tab
|
||||
(openForm)="openEventForm()"
|
||||
(editItem)="editEvent($event)" />
|
||||
}
|
||||
|
||||
<!-- History Tab -->
|
||||
@if (activeTab() === 'history') {
|
||||
@if (historyEntries$ | async; as entries) {
|
||||
<div class="tab-content">
|
||||
<div class="tab-toolbar">
|
||||
<h2>History Timeline ({{ entries.length }})</h2>
|
||||
<button class="btn btn-add" (click)="openHistoryForm()">+ Add Entry</button>
|
||||
</div>
|
||||
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Year</th>
|
||||
<th>Title</th>
|
||||
<th>Order</th>
|
||||
<th>Active</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (entry of entries; track entry.id) {
|
||||
<tr>
|
||||
<td>{{ entry.year }}</td>
|
||||
<td>{{ entry.title }}</td>
|
||||
<td>{{ entry.display_order }}</td>
|
||||
<td>{{ entry.active ? 'Yes' : 'No' }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn-icon btn-edit" (click)="editHistory(entry)">Edit</button>
|
||||
<button class="btn-icon btn-delete" (click)="deleteHistory(entry.id)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5" class="empty-row">No history entries yet. Click "Add Entry" to create one.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
<app-admin-history-tab
|
||||
(openForm)="openHistoryForm()"
|
||||
(editItem)="editHistory($event)" />
|
||||
}
|
||||
|
||||
<!-- Team Tab -->
|
||||
@if (activeTab() === 'team') {
|
||||
@if (teamMembers$ | async; as members) {
|
||||
<div class="tab-content">
|
||||
<div class="tab-toolbar">
|
||||
<h2>Team Members ({{ members.length }})</h2>
|
||||
<button class="btn btn-add" (click)="openTeamForm()">+ Add Member</button>
|
||||
</div>
|
||||
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Title</th>
|
||||
<th>Order</th>
|
||||
<th>Active</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (member of members; track member.id) {
|
||||
<tr>
|
||||
<td>{{ member.name }}</td>
|
||||
<td>{{ member.title }}</td>
|
||||
<td>{{ member.display_order }}</td>
|
||||
<td>{{ member.active ? 'Yes' : 'No' }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn-icon btn-edit" (click)="editTeam(member)">Edit</button>
|
||||
<button class="btn-icon btn-delete" (click)="deleteTeam(member.id)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5" class="empty-row">No team members yet. Click "Add Member" to create one.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
<app-admin-team-tab
|
||||
(openForm)="openTeamForm()"
|
||||
(editItem)="editTeam($event)" />
|
||||
}
|
||||
|
||||
<!-- Community Tab -->
|
||||
@if (activeTab() === 'community') {
|
||||
@if (communityHighlights$ | async; as highlights) {
|
||||
<div class="tab-content">
|
||||
<div class="tab-toolbar">
|
||||
<h2>Community Highlights ({{ highlights.length }})</h2>
|
||||
<button class="btn btn-add" (click)="openCommunityForm()">+ Add Highlight</button>
|
||||
</div>
|
||||
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Icon</th>
|
||||
<th>Title</th>
|
||||
<th>Order</th>
|
||||
<th>Active</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (highlight of highlights; track highlight.id) {
|
||||
<tr>
|
||||
<td>{{ highlight.icon }}</td>
|
||||
<td>{{ highlight.title }}</td>
|
||||
<td>{{ highlight.display_order }}</td>
|
||||
<td>{{ highlight.active ? 'Yes' : 'No' }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn-icon btn-edit" (click)="editCommunity(highlight)">Edit</button>
|
||||
<button class="btn-icon btn-delete" (click)="deleteCommunity(highlight.id)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5" class="empty-row">No highlights yet. Click "Add Highlight" to create one.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
<app-admin-community-tab
|
||||
(openForm)="openCommunityForm()"
|
||||
(editItem)="editCommunity($event)" />
|
||||
}
|
||||
|
||||
<!-- Station Tab -->
|
||||
@if (activeTab() === 'station') {
|
||||
@if (stationConfig$ | async; as config) {
|
||||
<div class="tab-content">
|
||||
<div class="tab-toolbar">
|
||||
<h2>Station Configuration</h2>
|
||||
<button class="btn btn-add" (click)="openStationForm()">Edit Config</button>
|
||||
</div>
|
||||
|
||||
<div class="station-config-view">
|
||||
<div class="config-row">
|
||||
<span class="config-label">Callsign</span>
|
||||
<span class="config-value">{{ config.callsign }}</span>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<span class="config-label">Name</span>
|
||||
<span class="config-value">{{ config.name_primary }} {{ config.name_secondary }}</span>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<span class="config-label">Tagline</span>
|
||||
<span class="config-value">{{ config.tagline }}</span>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<span class="config-label">Frequency</span>
|
||||
<span class="config-value">{{ config.frequency }}</span>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<span class="config-label">Founded</span>
|
||||
<span class="config-value">{{ config.founded_year }}</span>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<span class="config-label">Nonprofit</span>
|
||||
<span class="config-value">{{ config.nonprofit_type }} — EIN {{ config.ein }}</span>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<span class="config-label">Email</span>
|
||||
<span class="config-value">{{ config.email }}</span>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<span class="config-label">Phone</span>
|
||||
<span class="config-value">{{ config.phone }}</span>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<span class="config-label">Website</span>
|
||||
<span class="config-value">{{ config.website }}</span>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<span class="config-label">Donation URL</span>
|
||||
<span class="config-value">{{ config.donation_url || '(not set)' }}</span>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<span class="config-label">Play Store URL</span>
|
||||
<span class="config-value">{{ config.play_store_url || '(not set)' }}</span>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<span class="config-label">App Store Embed</span>
|
||||
<span class="config-value">{{ config.app_store_embed_html ? 'Configured' : '(not set)' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<app-admin-station-tab (openForm)="openStationForm()" />
|
||||
}
|
||||
|
||||
<!-- Theme Tab -->
|
||||
@if (activeTab() === 'theme') {
|
||||
<div class="tab-content theme-panel">
|
||||
<div class="tab-toolbar">
|
||||
<h2>Color Theme</h2>
|
||||
</div>
|
||||
|
||||
<div class="theme-harmony-bar">
|
||||
<div class="harmony-info">
|
||||
<span class="harmony-label">Harmony:</span>
|
||||
<span class="harmony-rule">{{ getHarmonyLabel() }}</span>
|
||||
</div>
|
||||
<label class="auto-variants-toggle">
|
||||
<input type="checkbox" [(ngModel)]="themeAutoVariants">
|
||||
Auto-generate variants
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (themeSaveSuccess) {
|
||||
<div class="theme-message theme-success">{{ themeSaveSuccess }}</div>
|
||||
}
|
||||
@if (themeSaveError) {
|
||||
<div class="theme-message theme-error">{{ themeSaveError }}</div>
|
||||
}
|
||||
|
||||
<!-- Color grid -->
|
||||
<div class="color-grid">
|
||||
@for (role of colorRoles; track role.field) {
|
||||
<div class="color-card" [class.has-issue]="hasHarmonyIssue(role.field)">
|
||||
<div class="color-swatch" [style.background]="themeColors[role.field]">
|
||||
<input type="color" [value]="themeColors[role.field]"
|
||||
(input)="onThemeColorChange(role.field, $event.target.value)">
|
||||
</div>
|
||||
<div class="color-info">
|
||||
<span class="color-name">{{ role.label }}</span>
|
||||
<span class="color-hex">{{ themeColors[role.field] }}</span>
|
||||
</div>
|
||||
<div class="color-status">
|
||||
@if (hasHarmonyIssue(role.field)) {
|
||||
<span class="harmony-warn" title="Not harmonious — click Auto-Fix to correct">⚠</span>
|
||||
} @else if (role.group !== 'neutral') {
|
||||
<span class="harmony-ok">✓</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="theme-actions">
|
||||
<button type="button" class="btn btn-outline-theme" (click)="resetTheme()">Reset to Defaults</button>
|
||||
<button type="button" class="btn btn-outline-theme" (click)="autoFixTheme()">Auto-Fix Harmony</button>
|
||||
<button type="button" class="btn btn-save-theme" (click)="saveTheme()" [disabled]="themeSaving">
|
||||
{{ themeSaving ? 'Saving…' : 'Save Theme' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<app-admin-theme-tab />
|
||||
}
|
||||
|
||||
<!-- Underwriters Tab -->
|
||||
@if (activeTab() === 'underwriters') {
|
||||
@if (underwriters$ | async; as underwriters) {
|
||||
<div class="tab-content">
|
||||
<div class="tab-toolbar">
|
||||
<h2>Underwriters ({{ underwriters.length }})</h2>
|
||||
<button class="btn btn-add" (click)="openUnderwriterForm()">+ Add Underwriter</button>
|
||||
</div>
|
||||
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Website</th>
|
||||
<th>Order</th>
|
||||
<th>Active</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (uw of underwriters; track uw.id) {
|
||||
<tr>
|
||||
<td>{{ uw.name }}</td>
|
||||
<td class="description-cell">{{ uw.description }}</td>
|
||||
<td>
|
||||
@if (uw.website_url) {
|
||||
<a [href]="uw.website_url" target="_blank" rel="noopener">{{ uw.website_url }}</a>
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</td>
|
||||
<td>{{ uw.display_order }}</td>
|
||||
<td>{{ uw.active ? 'Yes' : 'No' }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn-icon btn-edit" (click)="editUnderwriter(uw)">Edit</button>
|
||||
<button class="btn-icon btn-delete" (click)="deleteUnderwriter(uw.id)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6" class="empty-row">No underwriters yet. Click "Add Underwriter" to create one.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
<app-admin-underwriters-tab
|
||||
(openForm)="openUnderwriterForm()"
|
||||
(editItem)="editUnderwriter($event)" />
|
||||
}
|
||||
|
||||
<!-- Mobile Builds Tab -->
|
||||
@if (activeTab() === 'mobile-builds') {
|
||||
<div class="tab-content mobile-builds-panel">
|
||||
<div class="tab-toolbar">
|
||||
<h2>Mobile Build Orchestration</h2>
|
||||
<button class="btn btn-add" (click)="reloadMobileBuilds()">Refresh Requests</button>
|
||||
</div>
|
||||
|
||||
<div class="mobile-build-grid">
|
||||
<section class="build-card">
|
||||
<h3>Derived Defaults</h3>
|
||||
@if (mobileBuildDefaults; as defaults) {
|
||||
<div class="derived-list">
|
||||
<div><strong>Tenant:</strong> {{ defaults.tenant_key }}</div>
|
||||
<div><strong>App Name:</strong> {{ defaults.app_name }}</div>
|
||||
<div><strong>Support Email:</strong> {{ defaults.support_email }}</div>
|
||||
<div><strong>Website:</strong> {{ defaults.website }}</div>
|
||||
<div><strong>Stream URL:</strong> {{ defaults.stream_url || '(not set)' }}</div>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="empty-row">Defaults unavailable. Check station config.</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="build-card">
|
||||
<h3>Create Build Request</h3>
|
||||
|
||||
<div class="form-row platform-row">
|
||||
<label>
|
||||
<input type="checkbox" [(ngModel)]="buildAndroid">
|
||||
Android
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" [(ngModel)]="buildIos">
|
||||
iOS
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>
|
||||
Android Application ID
|
||||
<input type="text" [(ngModel)]="androidApplicationId" placeholder="com.example.radio">
|
||||
</label>
|
||||
<label>
|
||||
iOS Bundle ID
|
||||
<input type="text" [(ngModel)]="iosBundleId" placeholder="com.example.radio">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>
|
||||
Version Name
|
||||
<input type="text" [(ngModel)]="buildVersionName" placeholder="1.0.0">
|
||||
</label>
|
||||
<label>
|
||||
Build Number
|
||||
<input type="text" [(ngModel)]="buildNumber" placeholder="1">
|
||||
</label>
|
||||
<label>
|
||||
Release Profile
|
||||
<input type="text" [(ngModel)]="buildReleaseProfile" placeholder="release">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<h4>Required Files</h4>
|
||||
<div class="form-row">
|
||||
<label>
|
||||
Android Keystore (.jks/.keystore)
|
||||
<input type="file" (change)="onFileChosen($event, 'android-keystore')">
|
||||
</label>
|
||||
<label>
|
||||
Android Descriptor (JSON)
|
||||
<input type="file" accept=".json,application/json" (change)="onFileChosen($event, 'android-descriptor')">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>
|
||||
iOS Certificate (.p12)
|
||||
<input type="file" accept=".p12" (change)="onFileChosen($event, 'ios-certificate')">
|
||||
</label>
|
||||
<label>
|
||||
iOS Provisioning Profile (.mobileprovision)
|
||||
<input type="file" accept=".mobileprovision" (change)="onFileChosen($event, 'ios-profile')">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (mobileBuildStatusMessage) {
|
||||
<p class="status success">{{ mobileBuildStatusMessage }}</p>
|
||||
}
|
||||
@if (mobileBuildErrorMessage) {
|
||||
<p class="status error">{{ mobileBuildErrorMessage }}</p>
|
||||
}
|
||||
|
||||
<button class="btn btn-add" [disabled]="mobileBuildBusy" (click)="createAndUploadMobileBuildRequest()">
|
||||
{{ mobileBuildBusy ? 'Working…' : 'Create Draft + Upload Files' }}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="build-card">
|
||||
<h3>Build Requests</h3>
|
||||
@if (mobileBuilds$ | async; as builds) {
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Platforms</th>
|
||||
<th>Version</th>
|
||||
<th>Status</th>
|
||||
<th>Preflight</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (build of builds; track build.id) {
|
||||
<tr [class.selected-build]="selectedMobileBuildId === build.id">
|
||||
<td>#{{ build.id }}</td>
|
||||
<td>
|
||||
{{ build.platform_android ? 'Android' : '' }}
|
||||
{{ build.platform_android && build.platform_ios ? ' + ' : '' }}
|
||||
{{ build.platform_ios ? 'iOS' : '' }}
|
||||
</td>
|
||||
<td>{{ build.version_name }} ({{ build.build_number }})</td>
|
||||
<td>{{ build.status }}</td>
|
||||
<td>{{ build.preflight_passed ? 'Passed' : 'Pending/Failed' }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn-icon btn-edit" (click)="onMobileBuildSelected(build.id)">Select</button>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6" class="empty-row">No build requests yet.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (selectedMobileBuild; as build) {
|
||||
<section class="build-card">
|
||||
<div class="tab-toolbar compact">
|
||||
<h3>Selected Request #{{ build.id }}</h3>
|
||||
<div class="action-group">
|
||||
<button class="btn-icon btn-edit" [disabled]="mobileBuildBusy" (click)="runPreflightForSelectedBuild()">Run
|
||||
Preflight</button>
|
||||
<button class="btn-icon btn-edit" [disabled]="mobileBuildBusy || !build.preflight_passed"
|
||||
(click)="triggerSelectedBuild()">Trigger Build</button>
|
||||
<button class="btn-icon btn-edit" [disabled]="mobileBuildBusy"
|
||||
(click)="refreshSelectedBuild()">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (mobileBuildPreflightIssues.length > 0) {
|
||||
<div class="status error">
|
||||
<strong>Preflight issues:</strong>
|
||||
<ul>
|
||||
@for (issue of mobileBuildPreflightIssues; track issue) {
|
||||
<li>{{ issue }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (build.error_message) {
|
||||
<p class="status error"><strong>Error:</strong> {{ build.error_message }}</p>
|
||||
}
|
||||
|
||||
<h4>Artifacts</h4>
|
||||
<div class="artifact-list">
|
||||
@for (artifact of build.artifacts; track artifact.id) {
|
||||
<div class="artifact-row">
|
||||
<div>
|
||||
<strong>{{ artifact.file_name }}</strong>
|
||||
<div class="artifact-meta">{{ artifact.platform }} • {{ artifact.artifact_type }} • {{
|
||||
artifact.sha256.slice(0, 12) }}…</div>
|
||||
</div>
|
||||
<a class="btn-icon btn-edit" [href]="getArtifactDownloadHref(artifact.download_url)">Download</a>
|
||||
</div>
|
||||
} @empty {
|
||||
<p class="empty-row">No artifacts available yet.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<h4>Build Log</h4>
|
||||
<pre class="build-log">{{ build.build_log || 'No logs yet.' }}</pre>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
<app-admin-mobile-builds-tab />
|
||||
}
|
||||
|
||||
<!-- Stats Tab -->
|
||||
@if (activeTab() === 'stats') {
|
||||
<div class="tab-content">
|
||||
<app-admin-stats-dashboard />
|
||||
@@ -621,7 +83,7 @@
|
||||
<app-admin-event-form [editItem]="editingEvent" (saved)="onEventSaved()" (closed)="onEventClosed()" />
|
||||
}
|
||||
@if (showStationForm) {
|
||||
<app-admin-station-form [editItem]="stationConfig$ | async" (saved)="onStationSaved()" (closed)="onStationClosed()" />
|
||||
<app-admin-station-form [editItem]="stationConfig" (saved)="onStationSaved()" (closed)="onStationClosed()" />
|
||||
}
|
||||
@if (showHistoryForm) {
|
||||
<app-admin-history-form [editItem]="editingHistory" (saved)="onHistorySaved()" (closed)="onHistoryClosed()" />
|
||||
@@ -633,7 +95,6 @@
|
||||
<app-admin-community-form [editItem]="editingCommunity" (saved)="onCommunitySaved()" (closed)="onCommunityClosed()" />
|
||||
}
|
||||
@if (showUnderwriterForm) {
|
||||
<app-admin-underwriter-form [editItem]="editingUnderwriter" (saved)="onUnderwriterSaved()"
|
||||
(closed)="onUnderwriterClosed()" />
|
||||
<app-admin-underwriter-form [editItem]="editingUnderwriter" (saved)="onUnderwriterSaved()" (closed)="onUnderwriterClosed()" />
|
||||
}
|
||||
</section>
|
||||
</section>
|
||||
|
||||
+158
-503
@@ -5,546 +5,201 @@
|
||||
min-height: calc(100vh - 80px);
|
||||
background: $neutral-cream;
|
||||
padding: $spacing-xl 0;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
text-align: center;
|
||||
margin-bottom: $spacing-xl;
|
||||
// ── Header ─────────────────────────────────────────────
|
||||
|
||||
h1 {
|
||||
font-family: $font-heading;
|
||||
color: $primary-blue;
|
||||
font-size: 2rem;
|
||||
margin: 0 0 $spacing-xs;
|
||||
}
|
||||
|
||||
p {
|
||||
color: $neutral-medium;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tabs ─────────────────────────────────────────────────
|
||||
|
||||
.admin-tabs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: $spacing-sm;
|
||||
margin-bottom: $spacing-xl;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
background: $neutral-white;
|
||||
border: 1px solid $neutral-light;
|
||||
border-radius: $radius-md;
|
||||
padding: $spacing-sm $spacing-xl;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: $neutral-dark;
|
||||
cursor: pointer;
|
||||
transition: all $transition-fast;
|
||||
|
||||
&:hover {
|
||||
border-color: $primary-blue-muted;
|
||||
color: $primary-blue;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: $primary-blue;
|
||||
border-color: $primary-blue;
|
||||
color: $neutral-white;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tab content ──────────────────────────────────────────
|
||||
|
||||
.tab-content {
|
||||
@include card-style;
|
||||
padding: $spacing-lg;
|
||||
}
|
||||
|
||||
.tab-toolbar {
|
||||
@include flex-between;
|
||||
margin-bottom: $spacing-lg;
|
||||
|
||||
h2 {
|
||||
font-family: $font-heading;
|
||||
color: $primary-blue;
|
||||
font-size: 1.3rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
@include button-style($success-green, $neutral-white, darken($success-green, 8%));
|
||||
}
|
||||
|
||||
// ── Table ────────────────────────────────────────────────
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
thead th {
|
||||
text-align: left;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: $neutral-medium;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border-bottom: 2px solid $neutral-light;
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border-bottom: 1px solid $neutral-light;
|
||||
font-size: 0.95rem;
|
||||
color: $neutral-dark;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.benefits-cell {
|
||||
max-width: 300px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.slot-badge {
|
||||
display: inline-block;
|
||||
background: rgba($primary-blue, 0.1);
|
||||
color: $primary-blue;
|
||||
font-size: 0.75rem;
|
||||
font-family: var(--font-mono, monospace);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
margin: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty-row {
|
||||
.admin-header {
|
||||
text-align: center;
|
||||
color: $neutral-medium;
|
||||
padding: $spacing-xl !important;
|
||||
}
|
||||
margin-bottom: $spacing-xl;
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: $spacing-xs;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
h1 {
|
||||
font-family: $font-heading;
|
||||
color: $primary-blue;
|
||||
font-size: 2rem;
|
||||
margin: 0 0 $spacing-xs;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
border: none;
|
||||
padding: $spacing-xs $spacing-sm;
|
||||
border-radius: $radius-sm;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background: rgba($info-blue, 0.1);
|
||||
color: $info-blue;
|
||||
|
||||
&:hover {
|
||||
background: rgba($info-blue, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: rgba($danger-red, 0.1);
|
||||
color: $danger-red;
|
||||
|
||||
&:hover {
|
||||
background: rgba($danger-red, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
text-align: center;
|
||||
padding: $spacing-3xl;
|
||||
color: $neutral-medium;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
// ── Station config view ──────────────────────────────────
|
||||
|
||||
.station-config-view {
|
||||
.config-row {
|
||||
display: flex;
|
||||
padding: $spacing-sm 0;
|
||||
border-bottom: 1px solid $neutral-light;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
p {
|
||||
color: $neutral-medium;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.config-label {
|
||||
font-weight: 600;
|
||||
color: $neutral-medium;
|
||||
width: 140px;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.9rem;
|
||||
// ── Tabs ───────────────────────────────────────────────
|
||||
|
||||
.admin-tabs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: $spacing-sm;
|
||||
margin-bottom: $spacing-xl;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.config-value {
|
||||
color: $neutral-dark;
|
||||
.tab-btn {
|
||||
background: $neutral-white;
|
||||
border: 1px solid $neutral-light;
|
||||
border-radius: $radius-md;
|
||||
padding: $spacing-sm $spacing-xl;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
font-weight: 600;
|
||||
color: $neutral-dark;
|
||||
cursor: pointer;
|
||||
transition: all $transition-fast;
|
||||
|
||||
// ── Responsive ───────────────────────────────────────────
|
||||
|
||||
@include responsive(md) {
|
||||
.admin-table {
|
||||
font-size: 0.85rem;
|
||||
|
||||
thead th,
|
||||
tbody td {
|
||||
padding: $spacing-xs $spacing-sm;
|
||||
&:hover {
|
||||
border-color: $primary-blue-muted;
|
||||
color: $primary-blue;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: $primary-blue;
|
||||
border-color: $primary-blue;
|
||||
color: $neutral-white;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tab content ────────────────────────────────────────
|
||||
|
||||
.tab-content {
|
||||
@include card-style;
|
||||
padding: $spacing-lg;
|
||||
}
|
||||
|
||||
.tab-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Theme tab ────────────────────────────────────────────
|
||||
|
||||
.theme-panel {
|
||||
.theme-harmony-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: $spacing-md;
|
||||
background: rgba($primary-blue, 0.05);
|
||||
border-radius: $radius-md;
|
||||
@include flex-between;
|
||||
margin-bottom: $spacing-lg;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-sm;
|
||||
|
||||
.harmony-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
|
||||
.harmony-label {
|
||||
font-weight: 600;
|
||||
color: $neutral-medium;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.harmony-rule {
|
||||
color: $primary-blue;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
.auto-variants-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
font-size: 0.875rem;
|
||||
color: $neutral-dark;
|
||||
cursor: pointer;
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
h2 {
|
||||
font-family: $font-heading;
|
||||
color: $primary-blue;
|
||||
font-size: 1.3rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-message {
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border-radius: $radius-md;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: $spacing-md;
|
||||
text-align: center;
|
||||
|
||||
&.theme-success {
|
||||
background: rgba($success-green, 0.1);
|
||||
color: $success-green;
|
||||
}
|
||||
|
||||
&.theme-error {
|
||||
background: rgba($danger-red, 0.1);
|
||||
color: $danger-red;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Color grid
|
||||
.color-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: $spacing-md;
|
||||
margin-bottom: $spacing-xl;
|
||||
}
|
||||
|
||||
.color-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
background: $neutral-white;
|
||||
border: 2px solid $neutral-light;
|
||||
border-radius: $radius-md;
|
||||
transition: border-color $transition-fast, box-shadow $transition-fast;
|
||||
|
||||
&.has-issue {
|
||||
border-color: #e6a817;
|
||||
box-shadow: 0 0 0 1px rgba(230, 168, 23, 0.3);
|
||||
.btn-add {
|
||||
@include button-style($success-green, $neutral-white, darken($success-green, 8%));
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: $primary-blue-muted;
|
||||
}
|
||||
}
|
||||
// ── Table ──────────────────────────────────────────────
|
||||
|
||||
.color-swatch {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: $radius-sm;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
|
||||
transition: transform $transition-fast;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
input[type="color"] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
border-collapse: collapse;
|
||||
|
||||
thead th {
|
||||
text-align: left;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: $neutral-medium;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border-bottom: 2px solid $neutral-light;
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border-bottom: 1px solid $neutral-light;
|
||||
font-size: 0.95rem;
|
||||
color: $neutral-dark;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.slot-badge {
|
||||
display: inline-block;
|
||||
background: rgba($primary-blue, 0.1);
|
||||
color: $primary-blue;
|
||||
font-size: 0.75rem;
|
||||
font-family: var(--font-mono, monospace);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
margin: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty-row {
|
||||
text-align: center;
|
||||
color: $neutral-medium;
|
||||
padding: $spacing-xl !important;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: $spacing-xs;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
.color-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
.color-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: $neutral-dark;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.color-hex {
|
||||
font-size: 0.75rem;
|
||||
color: $neutral-medium;
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
}
|
||||
|
||||
.color-status {
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
|
||||
.harmony-ok {
|
||||
color: $success-green;
|
||||
}
|
||||
|
||||
.harmony-warn {
|
||||
color: #e6a817;
|
||||
}
|
||||
}
|
||||
|
||||
// Theme actions
|
||||
.theme-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: $spacing-sm;
|
||||
flex-wrap: wrap;
|
||||
padding-top: $spacing-md;
|
||||
border-top: 1px solid $neutral-light;
|
||||
}
|
||||
|
||||
.btn-save-theme {
|
||||
@include button-style($primary-blue, $neutral-white, $primary-blue-light);
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-outline-theme {
|
||||
background: transparent;
|
||||
border: 2px solid $primary-blue;
|
||||
color: $primary-blue;
|
||||
border-radius: $radius-md;
|
||||
padding: $spacing-sm $spacing-lg;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast, color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: $primary-blue;
|
||||
color: $neutral-white;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mobile builds tab ───────────────────────────────────
|
||||
|
||||
.mobile-builds-panel {
|
||||
display: grid;
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
|
||||
.mobile-build-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.build-card {
|
||||
background: $neutral-white;
|
||||
border: 1px solid $neutral-light;
|
||||
border-radius: $radius-md;
|
||||
padding: $spacing-md;
|
||||
|
||||
h3,
|
||||
h4 {
|
||||
color: $primary-blue;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.derived-list {
|
||||
display: grid;
|
||||
gap: $spacing-xs;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: $spacing-sm;
|
||||
margin-bottom: $spacing-sm;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: $neutral-dark;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input[type='text'],
|
||||
input[type='file'] {
|
||||
border: 1px solid $neutral-light;
|
||||
border-radius: $radius-sm;
|
||||
padding: $spacing-xs $spacing-sm;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
}
|
||||
|
||||
.platform-row {
|
||||
grid-template-columns: auto auto;
|
||||
|
||||
label {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
border-radius: $radius-sm;
|
||||
padding: $spacing-sm;
|
||||
margin: $spacing-sm 0;
|
||||
font-size: 0.9rem;
|
||||
|
||||
&.success {
|
||||
background: rgba($success-green, 0.1);
|
||||
color: $success-green;
|
||||
border-radius: $radius-sm;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: background $transition-fast;
|
||||
}
|
||||
|
||||
&.error {
|
||||
.btn-edit {
|
||||
background: rgba($info-blue, 0.1);
|
||||
color: $info-blue;
|
||||
|
||||
&:hover {
|
||||
background: rgba($info-blue, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: rgba($danger-red, 0.1);
|
||||
color: $danger-red;
|
||||
|
||||
&:hover {
|
||||
background: rgba($danger-red, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: $spacing-xs 0 0 $spacing-md;
|
||||
}
|
||||
}
|
||||
// ── Station config view ────────────────────────────────
|
||||
|
||||
.selected-build {
|
||||
background: rgba($primary-blue, 0.06);
|
||||
}
|
||||
.station-config-view {
|
||||
.config-row {
|
||||
display: flex;
|
||||
padding: $spacing-sm 0;
|
||||
border-bottom: 1px solid $neutral-light;
|
||||
|
||||
.tab-toolbar.compact {
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.action-group {
|
||||
display: flex;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
.config-label {
|
||||
font-weight: 600;
|
||||
color: $neutral-medium;
|
||||
width: 140px;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.artifact-list {
|
||||
display: grid;
|
||||
gap: $spacing-sm;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.artifact-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: 1px solid $neutral-light;
|
||||
border-radius: $radius-sm;
|
||||
padding: $spacing-sm;
|
||||
}
|
||||
|
||||
.artifact-meta {
|
||||
font-size: 0.78rem;
|
||||
color: $neutral-medium;
|
||||
}
|
||||
|
||||
.build-log {
|
||||
background: $neutral-black;
|
||||
color: $neutral-white;
|
||||
border-radius: $radius-sm;
|
||||
padding: $spacing-sm;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
@include responsive(md) {
|
||||
.mobile-build-grid {
|
||||
grid-template-columns: 1fr;
|
||||
.config-value {
|
||||
color: $neutral-dark;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
.action-group {
|
||||
flex-wrap: wrap;
|
||||
// ── Responsive ─────────────────────────────────────────
|
||||
|
||||
@include responsive(md) {
|
||||
.admin-table {
|
||||
font-size: 0.85rem;
|
||||
|
||||
thead th,
|
||||
tbody td {
|
||||
padding: $spacing-xs $spacing-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,23 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { CommonModule, AsyncPipe } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { BehaviorSubject, firstValueFrom } from 'rxjs';
|
||||
import { Component, inject, OnInit, signal, ViewEncapsulation } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { Show } from '../interfaces/show';
|
||||
import { Event as StationEvent } from '../interfaces/event';
|
||||
import { StationConfig } from '../interfaces/station-config';
|
||||
import { HistoryEntry } from '../interfaces/history-entry';
|
||||
import { TeamMember } from '../interfaces/team-member';
|
||||
import { CommunityHighlight } from '../interfaces/community-highlight';
|
||||
import { Underwriter } from '../interfaces/underwriter';
|
||||
import {
|
||||
MobileBuildDefaults,
|
||||
MobileBuildRequest,
|
||||
} from '../interfaces/mobile-build';
|
||||
import { ShowService } from '../services/show.service';
|
||||
import { EventService } from '../services/event.service';
|
||||
import { StationConfig } from '../interfaces/station-config';
|
||||
import { StationConfigService } from '../services/station-config.service';
|
||||
import { ThemeService } from '../services/theme.service';
|
||||
import { HistoryEntryService } from '../services/history-entry.service';
|
||||
import { TeamMemberService } from '../services/team-member.service';
|
||||
import { CommunityHighlightService } from '../services/community-highlight.service';
|
||||
import { UnderwriterService } from '../services/underwriter.service';
|
||||
import { MobileBuildService } from '../services/mobile-build.service';
|
||||
import { AdminShowsTabComponent } from './admin-shows-tab.component';
|
||||
import { AdminEventsTabComponent } from './admin-events-tab.component';
|
||||
import { AdminHistoryTabComponent } from './admin-history-tab.component';
|
||||
import { AdminTeamTabComponent } from './admin-team-tab.component';
|
||||
import { AdminCommunityTabComponent } from './admin-community-tab.component';
|
||||
import { AdminStationTabComponent } from './admin-station-tab.component';
|
||||
import { AdminThemeTabComponent } from './admin-theme-tab.component';
|
||||
import { AdminUnderwritersTabComponent } from './admin-underwriters-tab.component';
|
||||
import { AdminMobileBuildsTabComponent } from './admin-mobile-builds-tab.component';
|
||||
import { AdminShowFormComponent } from './admin-show-form.component';
|
||||
import { AdminEventFormComponent } from './admin-event-form.component';
|
||||
import { AdminStationFormComponent } from './admin-station-form.component';
|
||||
@@ -31,50 +26,35 @@ import { AdminTeamFormComponent } from './admin-team-form.component';
|
||||
import { AdminCommunityFormComponent } from './admin-community-form.component';
|
||||
import { AdminUnderwriterFormComponent } from './admin-underwriter-form.component';
|
||||
import { AdminStatsDashboardComponent } from './admin-stats-dashboard.component';
|
||||
import {
|
||||
COLOR_ROLES,
|
||||
HARMONY_RULE_LABELS,
|
||||
detectHarmonyRule,
|
||||
validatePalette,
|
||||
autoFixPalette,
|
||||
generateLight,
|
||||
generateDark,
|
||||
generateMuted,
|
||||
HarmonyRule,
|
||||
ColorValidation,
|
||||
} from '../utils/color-harmony';
|
||||
import { getAppConfig } from '../services/app-config.service';
|
||||
|
||||
type TabKey = 'shows' | 'events' | 'station' | 'theme' | 'history' | 'team' | 'community' | 'underwriters' | 'mobile-builds' | 'stats';
|
||||
|
||||
interface ThemeColorState {
|
||||
color_primary: string;
|
||||
color_primary_light: string;
|
||||
color_primary_dark: string;
|
||||
color_primary_muted: string;
|
||||
color_accent: string;
|
||||
color_accent_light: string;
|
||||
color_accent_dark: string;
|
||||
color_accent_muted: string;
|
||||
color_background: string;
|
||||
color_text: string;
|
||||
color_success: string;
|
||||
color_danger: string;
|
||||
color_info: string;
|
||||
color_white: string;
|
||||
color_light: string;
|
||||
color_medium: string;
|
||||
color_black: string;
|
||||
[key: string]: string;
|
||||
}
|
||||
type TabKey =
|
||||
| 'shows'
|
||||
| 'events'
|
||||
| 'history'
|
||||
| 'team'
|
||||
| 'community'
|
||||
| 'station'
|
||||
| 'theme'
|
||||
| 'underwriters'
|
||||
| 'mobile-builds'
|
||||
| 'stats';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin',
|
||||
standalone: true,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
imports: [
|
||||
CommonModule,
|
||||
AsyncPipe,
|
||||
FormsModule,
|
||||
AdminShowsTabComponent,
|
||||
AdminEventsTabComponent,
|
||||
AdminHistoryTabComponent,
|
||||
AdminTeamTabComponent,
|
||||
AdminCommunityTabComponent,
|
||||
AdminStationTabComponent,
|
||||
AdminThemeTabComponent,
|
||||
AdminUnderwritersTabComponent,
|
||||
AdminMobileBuildsTabComponent,
|
||||
AdminStatsDashboardComponent,
|
||||
AdminShowFormComponent,
|
||||
AdminEventFormComponent,
|
||||
AdminStationFormComponent,
|
||||
@@ -82,106 +62,25 @@ interface ThemeColorState {
|
||||
AdminTeamFormComponent,
|
||||
AdminCommunityFormComponent,
|
||||
AdminUnderwriterFormComponent,
|
||||
AdminStatsDashboardComponent,
|
||||
],
|
||||
templateUrl: './admin.component.html',
|
||||
styleUrl: './admin.component.scss',
|
||||
})
|
||||
export class AdminComponent implements OnInit {
|
||||
private showService = inject(ShowService);
|
||||
private eventService = inject(EventService);
|
||||
private stationConfigService = inject(StationConfigService);
|
||||
private themeService = inject(ThemeService);
|
||||
private historyEntryService = inject(HistoryEntryService);
|
||||
private teamMemberService = inject(TeamMemberService);
|
||||
private communityHighlightService = inject(CommunityHighlightService);
|
||||
private underwriterService = inject(UnderwriterService);
|
||||
private mobileBuildService = inject(MobileBuildService);
|
||||
|
||||
activeTab = signal<TabKey>('shows');
|
||||
|
||||
/** Item currently being edited (set by editXxx, cleared on save/close). */
|
||||
editingShow: Show | null = null;
|
||||
editingEvent: StationEvent | null = null;
|
||||
editingHistory: HistoryEntry | null = null;
|
||||
editingTeam: TeamMember | null = null;
|
||||
editingCommunity: CommunityHighlight | null = null;
|
||||
editingUnderwriter: Underwriter | null = null;
|
||||
/** Station config for the station form modal. */
|
||||
stationConfig: StationConfig | null = null;
|
||||
|
||||
/** BehaviorSubjects guarantee change detection via the async pipe — start empty, populated on load. */
|
||||
readonly shows$ = new BehaviorSubject<Show[]>([]);
|
||||
readonly events$ = new BehaviorSubject<StationEvent[]>([]);
|
||||
readonly stationConfig$ = new BehaviorSubject<StationConfig | null>(null);
|
||||
readonly historyEntries$ = new BehaviorSubject<HistoryEntry[]>([]);
|
||||
readonly teamMembers$ = new BehaviorSubject<TeamMember[]>([]);
|
||||
readonly communityHighlights$ = new BehaviorSubject<CommunityHighlight[]>([]);
|
||||
readonly underwriters$ = new BehaviorSubject<Underwriter[]>([]);
|
||||
readonly mobileBuilds$ = new BehaviorSubject<MobileBuildRequest[]>([]);
|
||||
|
||||
mobileBuildDefaults: MobileBuildDefaults | null = null;
|
||||
selectedMobileBuildId: number | null = null;
|
||||
selectedMobileBuild: MobileBuildRequest | null = null;
|
||||
mobileBuildStatusMessage = '';
|
||||
mobileBuildErrorMessage = '';
|
||||
mobileBuildPreflightIssues: string[] = [];
|
||||
mobileBuildBusy = false;
|
||||
|
||||
buildAndroid = true;
|
||||
buildIos = true;
|
||||
androidApplicationId = '';
|
||||
iosBundleId = '';
|
||||
buildVersionName = '1.0.0';
|
||||
buildNumber = '1';
|
||||
buildReleaseProfile = 'release';
|
||||
|
||||
androidKeystoreFile: File | null = null;
|
||||
androidDescriptorFile: File | null = null;
|
||||
iosCertificateFile: File | null = null;
|
||||
iosProfileFile: File | null = null;
|
||||
|
||||
// ── Theme tab state ──────────────────────────────────────────
|
||||
|
||||
/** Local editable color palette (copied from StationConfig on tab open). */
|
||||
themeColors: ThemeColorState = {} as ThemeColorState;
|
||||
|
||||
/** Current harmony rule detected from the palette. */
|
||||
themeHarmonyRule: HarmonyRule = 'none';
|
||||
|
||||
/** Validation results per color field. */
|
||||
themeValidation: { [key: string]: ColorValidation } = {};
|
||||
|
||||
/** Whether auto-generate variants is enabled. */
|
||||
themeAutoVariants = true;
|
||||
|
||||
/** Saving state for theme tab. */
|
||||
themeSaving = false;
|
||||
themeSaveSuccess = '';
|
||||
themeSaveError = '';
|
||||
|
||||
/** Color role definitions for the template. */
|
||||
readonly colorRoles = COLOR_ROLES;
|
||||
|
||||
/** Default palette for reset. */
|
||||
readonly themeDefaults: ThemeColorState = {
|
||||
color_primary: '#1a3a5c',
|
||||
color_primary_light: '#2a5a8c',
|
||||
color_primary_dark: '#0e2440',
|
||||
color_primary_muted: '#3a6a9c',
|
||||
color_accent: '#e87a2e',
|
||||
color_accent_light: '#f09a4e',
|
||||
color_accent_dark: '#c85a1e',
|
||||
color_accent_muted: '#f0a86a',
|
||||
color_background: '#faf6f0',
|
||||
color_text: '#3a3632',
|
||||
color_success: '#4a9e4f',
|
||||
color_danger: '#c83030',
|
||||
color_info: '#3a8abf',
|
||||
color_white: '#ffffff',
|
||||
color_light: '#f0ebe3',
|
||||
color_medium: '#8a8580',
|
||||
color_black: '#1a1816',
|
||||
};
|
||||
ngOnInit(): void {
|
||||
this.stationConfigService.load().then(() => {
|
||||
this.stationConfig = this.stationConfigService.config();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Form modal state ──────────────────────────────────────
|
||||
showShowForm = false;
|
||||
showEventForm = false;
|
||||
showStationForm = false;
|
||||
@@ -190,234 +89,14 @@ export class AdminComponent implements OnInit {
|
||||
showCommunityForm = false;
|
||||
showUnderwriterForm = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadAll();
|
||||
}
|
||||
|
||||
async reloadShows(): Promise<void> {
|
||||
const data = await firstValueFrom(this.showService.getShows());
|
||||
this.shows$.next(data);
|
||||
}
|
||||
|
||||
async reloadEvents(): Promise<void> {
|
||||
const data = await firstValueFrom(this.eventService.getEvents());
|
||||
this.events$.next(data);
|
||||
}
|
||||
|
||||
async reloadStationConfig(): Promise<void> {
|
||||
await this.stationConfigService.load();
|
||||
this.stationConfig$.next(this.stationConfigService.config());
|
||||
}
|
||||
|
||||
async reloadHistoryEntries(): Promise<void> {
|
||||
const data = await firstValueFrom(this.historyEntryService.getHistoryEntries());
|
||||
this.historyEntries$.next(data);
|
||||
}
|
||||
|
||||
async reloadTeamMembers(): Promise<void> {
|
||||
const data = await firstValueFrom(this.teamMemberService.getTeamMembers());
|
||||
this.teamMembers$.next(data);
|
||||
}
|
||||
|
||||
async reloadCommunityHighlights(): Promise<void> {
|
||||
const data = await firstValueFrom(this.communityHighlightService.getCommunityHighlights());
|
||||
this.communityHighlights$.next(data);
|
||||
}
|
||||
|
||||
async reloadUnderwriters(): Promise<void> {
|
||||
const data = await firstValueFrom(this.underwriterService.getUnderwriters());
|
||||
this.underwriters$.next(data);
|
||||
}
|
||||
|
||||
async loadAll(): Promise<void> {
|
||||
await Promise.all([
|
||||
this.reloadShows(),
|
||||
this.reloadEvents(),
|
||||
this.reloadStationConfig(),
|
||||
this.reloadHistoryEntries(),
|
||||
this.reloadTeamMembers(),
|
||||
this.reloadCommunityHighlights(),
|
||||
this.reloadUnderwriters(),
|
||||
this.reloadMobileBuildDefaults(),
|
||||
this.reloadMobileBuilds(),
|
||||
]);
|
||||
}
|
||||
|
||||
async reloadMobileBuildDefaults(): Promise<void> {
|
||||
try {
|
||||
this.mobileBuildDefaults = await firstValueFrom(this.mobileBuildService.getDefaults());
|
||||
} catch {
|
||||
this.mobileBuildDefaults = null;
|
||||
}
|
||||
}
|
||||
|
||||
async reloadMobileBuilds(): Promise<void> {
|
||||
const data = await firstValueFrom(this.mobileBuildService.listRequests());
|
||||
this.mobileBuilds$.next(data);
|
||||
|
||||
if (this.selectedMobileBuildId !== null) {
|
||||
const selected = data.find((item) => item.id === this.selectedMobileBuildId) ?? null;
|
||||
this.selectedMobileBuild = selected;
|
||||
if (!selected) {
|
||||
this.selectedMobileBuildId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMobileBuildSelected(buildId: number): void {
|
||||
this.selectedMobileBuildId = buildId;
|
||||
const selected = this.mobileBuilds$.value.find((item) => item.id === buildId) ?? null;
|
||||
this.selectedMobileBuild = selected;
|
||||
this.mobileBuildPreflightIssues = selected?.preflight_report
|
||||
? selected.preflight_report.split('\n').filter((line) => line.trim().length > 0)
|
||||
: [];
|
||||
}
|
||||
|
||||
onFileChosen(event: Event, target: 'android-keystore' | 'android-descriptor' | 'ios-certificate' | 'ios-profile'): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files && input.files.length > 0 ? input.files[0] : null;
|
||||
|
||||
if (target === 'android-keystore') this.androidKeystoreFile = file;
|
||||
if (target === 'android-descriptor') this.androidDescriptorFile = file;
|
||||
if (target === 'ios-certificate') this.iosCertificateFile = file;
|
||||
if (target === 'ios-profile') this.iosProfileFile = file;
|
||||
}
|
||||
|
||||
private getErrorMessage(err: unknown, fallback: string): string {
|
||||
if (typeof err === 'object' && err !== null) {
|
||||
const maybeErr = err as { error?: { detail?: string | string[]; message?: string }; message?: string };
|
||||
const detail = maybeErr.error?.detail;
|
||||
if (typeof detail === 'string') return detail;
|
||||
if (Array.isArray(detail)) return detail.join(' | ');
|
||||
if (typeof maybeErr.error?.message === 'string') return maybeErr.error.message;
|
||||
if (typeof maybeErr.message === 'string') return maybeErr.message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private resetMobileBuildMessages(): void {
|
||||
this.mobileBuildStatusMessage = '';
|
||||
this.mobileBuildErrorMessage = '';
|
||||
}
|
||||
|
||||
private validateBuildInputs(): string | null {
|
||||
if (!this.buildAndroid && !this.buildIos) {
|
||||
return 'Select at least one platform.';
|
||||
}
|
||||
|
||||
if (this.buildAndroid && !this.androidApplicationId.trim()) {
|
||||
return 'Android application ID is required.';
|
||||
}
|
||||
|
||||
if (this.buildIos && !this.iosBundleId.trim()) {
|
||||
return 'iOS bundle ID is required.';
|
||||
}
|
||||
|
||||
if (!this.buildVersionName.trim() || !this.buildNumber.trim()) {
|
||||
return 'Version name and build number are required.';
|
||||
}
|
||||
|
||||
if (this.buildAndroid && (!this.androidKeystoreFile || !this.androidDescriptorFile)) {
|
||||
return 'Android requires both keystore and descriptor files.';
|
||||
}
|
||||
|
||||
if (this.buildIos && (!this.iosCertificateFile || !this.iosProfileFile)) {
|
||||
return 'iOS requires both certificate and provisioning profile files.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async createAndUploadMobileBuildRequest(): Promise<void> {
|
||||
this.resetMobileBuildMessages();
|
||||
this.mobileBuildPreflightIssues = [];
|
||||
const inputError = this.validateBuildInputs();
|
||||
if (inputError) {
|
||||
this.mobileBuildErrorMessage = inputError;
|
||||
return;
|
||||
}
|
||||
|
||||
this.mobileBuildBusy = true;
|
||||
try {
|
||||
const request = await firstValueFrom(this.mobileBuildService.createRequest({
|
||||
platform_android: this.buildAndroid,
|
||||
platform_ios: this.buildIos,
|
||||
android_application_id: this.androidApplicationId.trim() || undefined,
|
||||
ios_bundle_id: this.iosBundleId.trim() || undefined,
|
||||
version_name: this.buildVersionName.trim(),
|
||||
build_number: this.buildNumber.trim(),
|
||||
release_profile: this.buildReleaseProfile.trim() || 'release',
|
||||
}));
|
||||
|
||||
if (this.buildAndroid && this.androidKeystoreFile && this.androidDescriptorFile) {
|
||||
await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'android', 'keystore', this.androidKeystoreFile));
|
||||
await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'android', 'descriptor', this.androidDescriptorFile));
|
||||
}
|
||||
|
||||
if (this.buildIos && this.iosCertificateFile && this.iosProfileFile) {
|
||||
await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'ios', 'certificate', this.iosCertificateFile));
|
||||
await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'ios', 'profile', this.iosProfileFile));
|
||||
}
|
||||
|
||||
this.mobileBuildStatusMessage = `Draft build request #${request.id} created and files uploaded.`;
|
||||
await this.reloadMobileBuilds();
|
||||
this.onMobileBuildSelected(request.id);
|
||||
} catch (err) {
|
||||
this.mobileBuildErrorMessage = this.getErrorMessage(err, 'Failed to create build request.');
|
||||
} finally {
|
||||
this.mobileBuildBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async runPreflightForSelectedBuild(): Promise<void> {
|
||||
if (!this.selectedMobileBuildId) return;
|
||||
|
||||
this.resetMobileBuildMessages();
|
||||
this.mobileBuildBusy = true;
|
||||
try {
|
||||
const result = await firstValueFrom(this.mobileBuildService.preflight(this.selectedMobileBuildId));
|
||||
this.mobileBuildPreflightIssues = result.issues;
|
||||
this.mobileBuildStatusMessage = result.passed
|
||||
? 'Preflight passed. Build can be triggered.'
|
||||
: 'Preflight failed. Resolve the listed issues.';
|
||||
await this.reloadMobileBuilds();
|
||||
this.onMobileBuildSelected(this.selectedMobileBuildId);
|
||||
} catch (err) {
|
||||
this.mobileBuildErrorMessage = this.getErrorMessage(err, 'Preflight failed to run.');
|
||||
} finally {
|
||||
this.mobileBuildBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async triggerSelectedBuild(): Promise<void> {
|
||||
if (!this.selectedMobileBuildId) return;
|
||||
|
||||
this.resetMobileBuildMessages();
|
||||
this.mobileBuildBusy = true;
|
||||
try {
|
||||
const request = await firstValueFrom(this.mobileBuildService.trigger(this.selectedMobileBuildId));
|
||||
this.mobileBuildStatusMessage = `Build #${request.id} queued.`;
|
||||
await this.reloadMobileBuilds();
|
||||
this.onMobileBuildSelected(this.selectedMobileBuildId);
|
||||
} catch (err) {
|
||||
this.mobileBuildErrorMessage = this.getErrorMessage(err, 'Failed to trigger build.');
|
||||
} finally {
|
||||
this.mobileBuildBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async refreshSelectedBuild(): Promise<void> {
|
||||
if (!this.selectedMobileBuildId) return;
|
||||
await this.reloadMobileBuilds();
|
||||
this.onMobileBuildSelected(this.selectedMobileBuildId);
|
||||
}
|
||||
|
||||
getArtifactDownloadHref(path: string): string {
|
||||
return `${getAppConfig().apiBaseUrl}${path}`;
|
||||
}
|
||||
|
||||
// ── Show CRUD ───────────────────────────────────────────
|
||||
editingShow: Show | null = null;
|
||||
editingEvent: StationEvent | null = null;
|
||||
editingHistory: HistoryEntry | null = null;
|
||||
editingTeam: TeamMember | null = null;
|
||||
editingCommunity: CommunityHighlight | null = null;
|
||||
editingUnderwriter: Underwriter | null = null;
|
||||
|
||||
// ── Show form ─────────────────────────────────────────────
|
||||
openShowForm(): void {
|
||||
this.editingShow = null;
|
||||
this.showShowForm = true;
|
||||
@@ -429,7 +108,6 @@ export class AdminComponent implements OnInit {
|
||||
}
|
||||
|
||||
onShowSaved(): void {
|
||||
this.reloadShows();
|
||||
this.showShowForm = false;
|
||||
this.editingShow = null;
|
||||
}
|
||||
@@ -439,14 +117,7 @@ export class AdminComponent implements OnInit {
|
||||
this.editingShow = null;
|
||||
}
|
||||
|
||||
async deleteShow(id: number): Promise<void> {
|
||||
if (!confirm('Delete this show?')) return;
|
||||
await firstValueFrom(this.showService.deleteShow(id));
|
||||
await this.reloadShows();
|
||||
}
|
||||
|
||||
// ── Event CRUD ──────────────────────────────────────────
|
||||
|
||||
// ── Event form ────────────────────────────────────────────
|
||||
openEventForm(): void {
|
||||
this.editingEvent = null;
|
||||
this.showEventForm = true;
|
||||
@@ -458,7 +129,6 @@ export class AdminComponent implements OnInit {
|
||||
}
|
||||
|
||||
onEventSaved(): void {
|
||||
this.reloadEvents();
|
||||
this.showEventForm = false;
|
||||
this.editingEvent = null;
|
||||
}
|
||||
@@ -468,20 +138,13 @@ export class AdminComponent implements OnInit {
|
||||
this.editingEvent = null;
|
||||
}
|
||||
|
||||
async deleteEvent(id: number): Promise<void> {
|
||||
if (!confirm('Delete this event?')) return;
|
||||
await firstValueFrom(this.eventService.deleteEvent(id));
|
||||
await this.reloadEvents();
|
||||
}
|
||||
|
||||
// ── Station Config ──────────────────────────────────────
|
||||
|
||||
// ── Station form ──────────────────────────────────────────
|
||||
openStationForm(): void {
|
||||
this.showStationForm = true;
|
||||
}
|
||||
|
||||
onStationSaved(): void {
|
||||
this.reloadStationConfig();
|
||||
this.stationConfig = this.stationConfigService.config();
|
||||
this.showStationForm = false;
|
||||
}
|
||||
|
||||
@@ -489,8 +152,7 @@ export class AdminComponent implements OnInit {
|
||||
this.showStationForm = false;
|
||||
}
|
||||
|
||||
// ── History CRUD ────────────────────────────────────────
|
||||
|
||||
// ── History form ──────────────────────────────────────────
|
||||
openHistoryForm(): void {
|
||||
this.editingHistory = null;
|
||||
this.showHistoryForm = true;
|
||||
@@ -502,7 +164,6 @@ export class AdminComponent implements OnInit {
|
||||
}
|
||||
|
||||
onHistorySaved(): void {
|
||||
this.reloadHistoryEntries();
|
||||
this.showHistoryForm = false;
|
||||
this.editingHistory = null;
|
||||
}
|
||||
@@ -512,14 +173,7 @@ export class AdminComponent implements OnInit {
|
||||
this.editingHistory = null;
|
||||
}
|
||||
|
||||
async deleteHistory(id: number): Promise<void> {
|
||||
if (!confirm('Delete this history entry?')) return;
|
||||
await firstValueFrom(this.historyEntryService.deleteHistoryEntry(id));
|
||||
await this.reloadHistoryEntries();
|
||||
}
|
||||
|
||||
// ── Team CRUD ───────────────────────────────────────────
|
||||
|
||||
// ── Team form ─────────────────────────────────────────────
|
||||
openTeamForm(): void {
|
||||
this.editingTeam = null;
|
||||
this.showTeamForm = true;
|
||||
@@ -531,7 +185,6 @@ export class AdminComponent implements OnInit {
|
||||
}
|
||||
|
||||
onTeamSaved(): void {
|
||||
this.reloadTeamMembers();
|
||||
this.showTeamForm = false;
|
||||
this.editingTeam = null;
|
||||
}
|
||||
@@ -541,14 +194,7 @@ export class AdminComponent implements OnInit {
|
||||
this.editingTeam = null;
|
||||
}
|
||||
|
||||
async deleteTeam(id: number): Promise<void> {
|
||||
if (!confirm('Delete this team member?')) return;
|
||||
await firstValueFrom(this.teamMemberService.deleteTeamMember(id));
|
||||
await this.reloadTeamMembers();
|
||||
}
|
||||
|
||||
// ── Community CRUD ──────────────────────────────────────
|
||||
|
||||
// ── Community form ────────────────────────────────────────
|
||||
openCommunityForm(): void {
|
||||
this.editingCommunity = null;
|
||||
this.showCommunityForm = true;
|
||||
@@ -560,7 +206,6 @@ export class AdminComponent implements OnInit {
|
||||
}
|
||||
|
||||
onCommunitySaved(): void {
|
||||
this.reloadCommunityHighlights();
|
||||
this.showCommunityForm = false;
|
||||
this.editingCommunity = null;
|
||||
}
|
||||
@@ -570,14 +215,7 @@ export class AdminComponent implements OnInit {
|
||||
this.editingCommunity = null;
|
||||
}
|
||||
|
||||
async deleteCommunity(id: number): Promise<void> {
|
||||
if (!confirm('Delete this community highlight?')) return;
|
||||
await firstValueFrom(this.communityHighlightService.deleteCommunityHighlight(id));
|
||||
await this.reloadCommunityHighlights();
|
||||
}
|
||||
|
||||
// ── Underwriter CRUD ────────────────────────────────────
|
||||
|
||||
// ── Underwriter form ──────────────────────────────────────
|
||||
openUnderwriterForm(): void {
|
||||
this.editingUnderwriter = null;
|
||||
this.showUnderwriterForm = true;
|
||||
@@ -589,7 +227,6 @@ export class AdminComponent implements OnInit {
|
||||
}
|
||||
|
||||
onUnderwriterSaved(): void {
|
||||
this.reloadUnderwriters();
|
||||
this.showUnderwriterForm = false;
|
||||
this.editingUnderwriter = null;
|
||||
}
|
||||
@@ -598,120 +235,4 @@ export class AdminComponent implements OnInit {
|
||||
this.showUnderwriterForm = false;
|
||||
this.editingUnderwriter = null;
|
||||
}
|
||||
|
||||
async deleteUnderwriter(id: number): Promise<void> {
|
||||
if (!confirm('Delete this underwriter?')) return;
|
||||
await firstValueFrom(this.underwriterService.deleteUnderwriter(id));
|
||||
await this.reloadUnderwriters();
|
||||
}
|
||||
|
||||
// ── Theme tab ────────────────────────────────────────────
|
||||
|
||||
onTabChanged(): void {
|
||||
if (this.activeTab() === 'theme') {
|
||||
this.loadThemeColors();
|
||||
}
|
||||
}
|
||||
|
||||
loadThemeColors(): void {
|
||||
const config = this.stationConfigService.config();
|
||||
this.themeColors = {
|
||||
color_primary: config.color_primary || this.themeDefaults.color_primary,
|
||||
color_primary_light: config.color_primary_light || this.themeDefaults.color_primary_light,
|
||||
color_primary_dark: config.color_primary_dark || this.themeDefaults.color_primary_dark,
|
||||
color_primary_muted: config.color_primary_muted || this.themeDefaults.color_primary_muted,
|
||||
color_accent: config.color_accent || this.themeDefaults.color_accent,
|
||||
color_accent_light: config.color_accent_light || this.themeDefaults.color_accent_light,
|
||||
color_accent_dark: config.color_accent_dark || this.themeDefaults.color_accent_dark,
|
||||
color_accent_muted: config.color_accent_muted || this.themeDefaults.color_accent_muted,
|
||||
color_background: config.color_background || this.themeDefaults.color_background,
|
||||
color_text: config.color_text || this.themeDefaults.color_text,
|
||||
color_success: config.color_success || this.themeDefaults.color_success,
|
||||
color_danger: config.color_danger || this.themeDefaults.color_danger,
|
||||
color_info: config.color_info || this.themeDefaults.color_info,
|
||||
color_white: config.color_white || this.themeDefaults.color_white,
|
||||
color_light: config.color_light || this.themeDefaults.color_light,
|
||||
color_medium: config.color_medium || this.themeDefaults.color_medium,
|
||||
color_black: config.color_black || this.themeDefaults.color_black,
|
||||
};
|
||||
this.themeSaveSuccess = '';
|
||||
this.themeSaveError = '';
|
||||
this.revalidateTheme();
|
||||
}
|
||||
|
||||
/** Re-run harmony detection + validation on the current palette. */
|
||||
revalidateTheme(): void {
|
||||
const primary = this.themeColors.color_primary;
|
||||
const accent = this.themeColors.color_accent;
|
||||
this.themeHarmonyRule = detectHarmonyRule(primary, accent);
|
||||
this.themeValidation = validatePalette(this.themeColors, this.themeHarmonyRule);
|
||||
}
|
||||
|
||||
/** Handle color change from the picker. */
|
||||
onThemeColorChange(field: string, value: string): void {
|
||||
this.themeColors[field] = value;
|
||||
|
||||
// Auto-generate variants for primary/accent bases
|
||||
if (this.themeAutoVariants) {
|
||||
if (field === 'color_primary') {
|
||||
this.themeColors.color_primary_light = generateLight(value);
|
||||
this.themeColors.color_primary_dark = generateDark(value);
|
||||
this.themeColors.color_primary_muted = generateMuted(value);
|
||||
} else if (field === 'color_accent') {
|
||||
this.themeColors.color_accent_light = generateLight(value);
|
||||
this.themeColors.color_accent_dark = generateDark(value);
|
||||
this.themeColors.color_accent_muted = generateMuted(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Live-apply to DOM for instant preview
|
||||
this.themeService.apply(this.themeColors as any);
|
||||
this.revalidateTheme();
|
||||
}
|
||||
|
||||
/** Auto-fix all non-harmonious colors. */
|
||||
autoFixTheme(): void {
|
||||
const fixed = autoFixPalette(this.themeColors, this.themeHarmonyRule);
|
||||
for (const key of Object.keys(fixed) as (keyof ThemeColorState)[]) {
|
||||
(this.themeColors as any)[key] = fixed[key];
|
||||
}
|
||||
this.themeService.apply(this.themeColors as any);
|
||||
this.revalidateTheme();
|
||||
}
|
||||
|
||||
/** Reset to default palette. */
|
||||
resetTheme(): void {
|
||||
for (const key of Object.keys(this.themeDefaults) as (keyof ThemeColorState)[]) {
|
||||
(this.themeColors as any)[key] = this.themeDefaults[key];
|
||||
}
|
||||
this.themeService.apply(this.themeColors as any);
|
||||
this.revalidateTheme();
|
||||
}
|
||||
|
||||
/** Save theme colors to the database. */
|
||||
async saveTheme(): Promise<void> {
|
||||
this.themeSaving = true;
|
||||
this.themeSaveError = '';
|
||||
this.themeSaveSuccess = '';
|
||||
|
||||
try {
|
||||
await this.stationConfigService.updateConfig(this.themeColors);
|
||||
this.themeSaveSuccess = 'Theme saved successfully!';
|
||||
} catch (err: any) {
|
||||
this.themeSaveError = err?.error?.detail || 'Failed to save theme.';
|
||||
} finally {
|
||||
this.themeSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get harmony rule label for display. */
|
||||
getHarmonyLabel(): string {
|
||||
return HARMONY_RULE_LABELS[this.themeHarmonyRule];
|
||||
}
|
||||
|
||||
/** Check if a color field has a harmony issue. */
|
||||
hasHarmonyIssue(field: string): boolean {
|
||||
const v = this.themeValidation[field];
|
||||
return v ? !v.isHarmonious : false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('App', () => {
|
||||
describe('computed hasStream', () => {
|
||||
it('should return false when stream_url is missing', () => {
|
||||
const streamUrl = '';
|
||||
expect(!!streamUrl && streamUrl.trim().length > 0).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when stream_url is whitespace', () => {
|
||||
const streamUrl = ' ';
|
||||
expect(!!streamUrl && streamUrl.trim().length > 0).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when stream_url is a valid URL', () => {
|
||||
const streamUrl = 'https://stream.example.com/live';
|
||||
expect(!!streamUrl && streamUrl.trim().length > 0).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stationConfig title formatting', () => {
|
||||
it('should concatenate name_primary and name_secondary with a space', () => {
|
||||
const namePrimary = 'KM';
|
||||
const nameSecondary = 'TN';
|
||||
const fullName = `${namePrimary} ${nameSecondary}`.trim();
|
||||
expect(fullName).toBe('KM TN');
|
||||
});
|
||||
|
||||
it('should trim trailing space when name_secondary is empty', () => {
|
||||
const namePrimary = 'KM';
|
||||
const nameSecondary = '';
|
||||
const fullName = `${namePrimary} ${nameSecondary}`.trim();
|
||||
expect(fullName).toBe('KM');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -56,8 +56,8 @@
|
||||
animation: float 4s ease-in-out infinite;
|
||||
|
||||
.hero-flower {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
margin: 0 auto;
|
||||
filter: drop-shadow(0 4px 12px rgba(232, 122, 46, 0.4));
|
||||
}
|
||||
@@ -200,8 +200,8 @@
|
||||
}
|
||||
|
||||
.hero-flower {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
|
||||
@@ -8,16 +8,6 @@ export interface MobileBuildDefaults {
|
||||
stream_metadata_url: string;
|
||||
}
|
||||
|
||||
export interface MobileBuildUploadedFile {
|
||||
id: number;
|
||||
request_id: number;
|
||||
platform: 'android' | 'ios';
|
||||
file_kind: string;
|
||||
original_name: string;
|
||||
size: number;
|
||||
uploaded_at: string;
|
||||
}
|
||||
|
||||
export interface MobileBuildArtifact {
|
||||
id: number;
|
||||
request_id: number;
|
||||
@@ -49,7 +39,6 @@ export interface MobileBuildRequest {
|
||||
updated_at: string;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
files: MobileBuildUploadedFile[];
|
||||
artifacts: MobileBuildArtifact[];
|
||||
}
|
||||
|
||||
@@ -67,3 +56,10 @@ export interface MobileBuildPreflightResult {
|
||||
passed: boolean;
|
||||
issues: string[];
|
||||
}
|
||||
|
||||
export interface ThemeStatus {
|
||||
exists: boolean;
|
||||
path: string;
|
||||
generated_at: string | null;
|
||||
size: number | null;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,12 @@ import { CommonModule } from '@angular/common';
|
||||
|
||||
import { StationConfigService } from '../services/station-config.service';
|
||||
|
||||
/** Decode malformed HTML entities from stream metadata (e.g. `&039;` → `'`). */
|
||||
function decodeMetadata(text: string): string {
|
||||
return text.replace(/'/g, "'")
|
||||
.replace(/&/g, "&");
|
||||
}
|
||||
|
||||
/** Shape of the Airtime live-info JSON response (subset of fields we use). */
|
||||
interface LiveInfoResponse {
|
||||
current: {
|
||||
@@ -103,14 +109,14 @@ export class MediaPlayerComponent implements OnDestroy {
|
||||
if (!resp.ok) throw new Error('Metadata fetch failed');
|
||||
const data: LiveInfoResponse = await resp.json();
|
||||
|
||||
const showName = data.currentShow?.[0]?.name ?? '';
|
||||
const showName = decodeMetadata(data.currentShow?.[0]?.name ?? '');
|
||||
const current = data.current;
|
||||
const trackTitle = current?.metadata?.track_title ?? '';
|
||||
const artistName = current?.metadata?.artist_name ?? '';
|
||||
const trackTitle = decodeMetadata(current?.metadata?.track_title ?? '');
|
||||
const artistName = decodeMetadata(current?.metadata?.artist_name ?? '');
|
||||
|
||||
let track = '';
|
||||
if (artistName && trackTitle) track = `${artistName} - ${trackTitle}`;
|
||||
else if (current?.name) track = current.name;
|
||||
else if (current?.name) track = decodeMetadata(current.name);
|
||||
else if (trackTitle) track = trackTitle;
|
||||
|
||||
this.showName.set(showName);
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
MobileBuildDefaults,
|
||||
MobileBuildPreflightResult,
|
||||
MobileBuildRequest,
|
||||
MobileBuildUploadedFile,
|
||||
ThemeStatus,
|
||||
} from '../interfaces/mobile-build';
|
||||
|
||||
@Injectable({
|
||||
@@ -34,19 +34,23 @@ export class MobileBuildService {
|
||||
return this.http.post<MobileBuildRequest>(`${this.baseUrl}/requests`, payload);
|
||||
}
|
||||
|
||||
deleteRequest(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.baseUrl}/requests/${id}`);
|
||||
}
|
||||
|
||||
uploadFile(
|
||||
requestId: number,
|
||||
platform: 'android' | 'ios',
|
||||
fileKind: string,
|
||||
file: File,
|
||||
): Observable<MobileBuildUploadedFile> {
|
||||
): Observable<{ platform: string; file_kind: string; display_name: string; sha256: string; size: number }> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const params = new HttpParams()
|
||||
.set('platform', platform)
|
||||
.set('file_kind', fileKind);
|
||||
|
||||
return this.http.post<MobileBuildUploadedFile>(
|
||||
return this.http.post<{ platform: string; file_kind: string; display_name: string; sha256: string; size: number }>(
|
||||
`${this.baseUrl}/requests/${requestId}/files`,
|
||||
form,
|
||||
{ params },
|
||||
@@ -66,4 +70,24 @@ export class MobileBuildService {
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
getThemeStatus(): Observable<ThemeStatus> {
|
||||
return this.http.get<ThemeStatus>(
|
||||
`${getAppConfig().apiBaseUrl}/api/theme/status`,
|
||||
);
|
||||
}
|
||||
|
||||
regenerateTheme(): Observable<unknown> {
|
||||
return this.http.post(
|
||||
`${getAppConfig().apiBaseUrl}/api/theme/regenerate`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
getDownloadUrl(artifactId: number): Observable<{ download_url: string }> {
|
||||
return this.http.get<{ download_url: string }>(
|
||||
`${this.baseUrl}/artifacts/${artifactId}/download-url`,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// Vitest test setup for frontend unit tests.
|
||||
// Angular TestBed initialization is not required for standalone unit tests.
|
||||
// Add shared mocks, global beforeEach hooks, or polyfills here as needed.
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"generated_at": "2026-07-06T05:59:35.748956+00:00",
|
||||
"station": {
|
||||
"callsign": "STAYLIT",
|
||||
"name_primary": "Stay Lit",
|
||||
"name_secondary": "Radio",
|
||||
"tagline": "The Number 1 Underground Station",
|
||||
"frequency": ""
|
||||
},
|
||||
"colors": {
|
||||
"primary": "#000000",
|
||||
"primary_light": "#757575",
|
||||
"primary_dark": "#0d0d0d",
|
||||
"primary_muted": "#a8a8a8",
|
||||
"accent": "#fe9b2a",
|
||||
"accent_light": "#fecb8f",
|
||||
"accent_dark": "#c16701",
|
||||
"accent_muted": "#e99a3f",
|
||||
"background": "#faf6f0",
|
||||
"text": "#3a3632",
|
||||
"success": "#9c7649",
|
||||
"danger": "#c98231",
|
||||
"info": "#bf813b",
|
||||
"white": "#ffffff",
|
||||
"light": "#f0ebe3",
|
||||
"medium": "#8a8580",
|
||||
"black": "#1a1816"
|
||||
},
|
||||
"images": {
|
||||
"logo": "https://kmountainflower.org/api/storage/2aaf76bb75dd4b4f9f743e397b0e1f59",
|
||||
"hero_background": "https://kmountainflower.org/api/storage/89c2e01fb56141f98bc618dbe009b7d9",
|
||||
"hero_icon": "https://kmountainflower.org/api/storage/bc60ea468348485b84c548401ec3457e"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user