diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 8794749..8f9e7e5 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -13,7 +13,8 @@ "extensions": [ "Angular.ng-template", "magnobiet.sass-extension-pack", - "Anthropic.claude-code" + "Anthropic.claude-code", + "ms-azuretools.vscode-containers" ] } } diff --git a/angular.json b/angular.json index 36dfc82..3856bdd 100644 --- a/angular.json +++ b/angular.json @@ -74,7 +74,7 @@ { "type": "anyComponentStyle", "maximumWarning": "4kB", - "maximumError": "8kB" + "maximumError": "10kB" } ], "outputHashing": "all" diff --git a/backend/app/__pycache__/config.cpython-312.pyc b/backend/app/__pycache__/config.cpython-312.pyc index 0f0569a..290075e 100644 Binary files a/backend/app/__pycache__/config.cpython-312.pyc and b/backend/app/__pycache__/config.cpython-312.pyc differ diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc index 226b83e..8e89514 100644 Binary files a/backend/app/__pycache__/main.cpython-312.pyc and b/backend/app/__pycache__/main.cpython-312.pyc differ diff --git a/backend/app/__pycache__/models.cpython-312.pyc b/backend/app/__pycache__/models.cpython-312.pyc index 46affd9..6fb50ac 100644 Binary files a/backend/app/__pycache__/models.cpython-312.pyc and b/backend/app/__pycache__/models.cpython-312.pyc differ diff --git a/backend/app/__pycache__/schemas.cpython-312.pyc b/backend/app/__pycache__/schemas.cpython-312.pyc index 5cbaa23..5a3da62 100644 Binary files a/backend/app/__pycache__/schemas.cpython-312.pyc and b/backend/app/__pycache__/schemas.cpython-312.pyc differ diff --git a/backend/app/__pycache__/theme_export.cpython-312.pyc b/backend/app/__pycache__/theme_export.cpython-312.pyc new file mode 100644 index 0000000..280ef73 Binary files /dev/null and b/backend/app/__pycache__/theme_export.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/community.cpython-312.pyc b/backend/app/api/__pycache__/community.cpython-312.pyc index 07b797d..53e4fc0 100644 Binary files a/backend/app/api/__pycache__/community.cpython-312.pyc and b/backend/app/api/__pycache__/community.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/events.cpython-312.pyc b/backend/app/api/__pycache__/events.cpython-312.pyc index bc50720..fed23f2 100644 Binary files a/backend/app/api/__pycache__/events.cpython-312.pyc and b/backend/app/api/__pycache__/events.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/history.cpython-312.pyc b/backend/app/api/__pycache__/history.cpython-312.pyc index 101cf15..19f6c14 100644 Binary files a/backend/app/api/__pycache__/history.cpython-312.pyc and b/backend/app/api/__pycache__/history.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/mobile_builds.cpython-312.pyc b/backend/app/api/__pycache__/mobile_builds.cpython-312.pyc index 8d5e91d..786bccc 100644 Binary files a/backend/app/api/__pycache__/mobile_builds.cpython-312.pyc and b/backend/app/api/__pycache__/mobile_builds.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/shows.cpython-312.pyc b/backend/app/api/__pycache__/shows.cpython-312.pyc index a79caf2..2d3c695 100644 Binary files a/backend/app/api/__pycache__/shows.cpython-312.pyc and b/backend/app/api/__pycache__/shows.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/station_config.cpython-312.pyc b/backend/app/api/__pycache__/station_config.cpython-312.pyc index c66d790..9de1766 100644 Binary files a/backend/app/api/__pycache__/station_config.cpython-312.pyc and b/backend/app/api/__pycache__/station_config.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/team.cpython-312.pyc b/backend/app/api/__pycache__/team.cpython-312.pyc index b1b33b5..5c8996d 100644 Binary files a/backend/app/api/__pycache__/team.cpython-312.pyc and b/backend/app/api/__pycache__/team.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/theme.cpython-312.pyc b/backend/app/api/__pycache__/theme.cpython-312.pyc new file mode 100644 index 0000000..12ebd33 Binary files /dev/null and b/backend/app/api/__pycache__/theme.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/underwriters.cpython-312.pyc b/backend/app/api/__pycache__/underwriters.cpython-312.pyc index ca25f62..4a82f39 100644 Binary files a/backend/app/api/__pycache__/underwriters.cpython-312.pyc and b/backend/app/api/__pycache__/underwriters.cpython-312.pyc differ diff --git a/backend/app/api/mobile_builds.py b/backend/app/api/mobile_builds.py index 0303263..3760de7 100644 --- a/backend/app/api/mobile_builds.py +++ b/backend/app/api/mobile_builds.py @@ -305,6 +305,25 @@ async def _upload_files_to_worker( raise RuntimeError(f"Failed to upload file {row.original_name}: {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( session: AsyncSession, request: MobileBuildRequest, @@ -317,6 +336,9 @@ async def _run_platform_build( await _append_log(session, request, f"[{platform}] Uploading {len(platform_files)} file(s) to worker") await _upload_files_to_worker(platform_files, host) + # Upload theme.json to worker + await _upload_theme_to_worker(session, request, host) + ssh_prefix = _build_ssh_prefix(host) cmd = ssh_prefix + [ ( diff --git a/backend/app/api/station_config.py b/backend/app/api/station_config.py index 1f0203a..1e13fb2 100644 --- a/backend/app/api/station_config.py +++ b/backend/app/api/station_config.py @@ -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 diff --git a/backend/app/api/theme.py b/backend/app/api/theme.py new file mode 100644 index 0000000..85a3bf0 --- /dev/null +++ b/backend/app/api/theme.py @@ -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 diff --git a/backend/app/config.py b/backend/app/config.py index af675bf..01d6180 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -32,6 +32,10 @@ class Settings(BaseSettings): 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" + # Theme export + WEBSITE_URL: str = "https://kmountainflower.org" + THEME_JSON_PATH: str = "/app/theme.json" + model_config = {"env_prefix": "KMTN_"} diff --git a/backend/app/main.py b/backend/app/main.py index d60179b..160ae8c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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": diff --git a/backend/app/schemas.py b/backend/app/schemas.py index aef385d..ada361f 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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 ────────────────────────────────────────── diff --git a/backend/app/theme_export.py b/backend/app/theme_export.py new file mode 100644 index 0000000..4c615fc --- /dev/null +++ b/backend/app/theme_export.py @@ -0,0 +1,134 @@ +"""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. +- Write it atomically to disk (for scp by build machines). +- Check whether the file exists and return its metadata. +""" + +import json +import logging +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 +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 build_theme_dict(config: StationConfig) -> Dict[str, Any]: + """Build the theme.json dict from a StationConfig ORM object.""" + 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, + }, + "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 write_theme_json() -> Dict[str, Any]: + """Read the current station config and write theme.json to disk. + + 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 = build_theme_dict(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, + } diff --git a/src/app/admin/admin.component.html b/src/app/admin/admin.component.html index 863c49d..a28bd46 100644 --- a/src/app/admin/admin.component.html +++ b/src/app/admin/admin.component.html @@ -22,7 +22,7 @@ Underwriters Mobile Builds + (click)="activeTab.set('mobile-builds'); loadThemeJsonStatus()">Mobile Builds Stats @@ -437,6 +437,33 @@ } + + Theme Export + @if (themeJsonStatus$ | async; as status) { + @if (status.exists) { + + theme.json — ready + Path: {{ status.path }} + Generated: {{ status.generated_at }} + Size: {{ status.size }} bytes + + } @else { + + theme.json not found at {{ status.path }} + Regenerate to create it from the current station config. + + } + + {{ (themeJsonLoading$ | async) ? 'Regenerating...' : 'Regenerate' }} + + } @else { + @if (themeJsonLoading$ | async) { + Loading... + } + } + + Create Build Request diff --git a/src/app/admin/admin.component.scss b/src/app/admin/admin.component.scss index 02f85e4..7e94c0c 100644 --- a/src/app/admin/admin.component.scss +++ b/src/app/admin/admin.component.scss @@ -539,6 +539,60 @@ font-size: 0.8rem; } +// ── Theme export status ────────────────────────────────── + +.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; diff --git a/src/app/admin/admin.component.ts b/src/app/admin/admin.component.ts index 33f15ba..af01df3 100644 --- a/src/app/admin/admin.component.ts +++ b/src/app/admin/admin.component.ts @@ -13,6 +13,7 @@ import { Underwriter } from '../interfaces/underwriter'; import { MobileBuildDefaults, MobileBuildRequest, + ThemeStatus, } from '../interfaces/mobile-build'; import { ShowService } from '../services/show.service'; import { EventService } from '../services/event.service'; @@ -126,6 +127,10 @@ export class AdminComponent implements OnInit { mobileBuildPreflightIssues: string[] = []; mobileBuildBusy = false; + // ── Theme JSON status (mobile build tab) ──────────────────── + readonly themeJsonStatus$ = new BehaviorSubject(null); + readonly themeJsonLoading$ = new BehaviorSubject(false); + buildAndroid = true; buildIos = true; androidApplicationId = ''; @@ -412,6 +417,36 @@ export class AdminComponent implements OnInit { this.onMobileBuildSelected(this.selectedMobileBuildId); } + // ── Theme JSON status ────────────────────────────────────── + + async loadThemeJsonStatus(): Promise { + 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 { + 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); + } + } + getArtifactDownloadHref(path: string): string { return `${getAppConfig().apiBaseUrl}${path}`; } diff --git a/src/app/interfaces/mobile-build.ts b/src/app/interfaces/mobile-build.ts index 52d6f3a..1b0cb08 100644 --- a/src/app/interfaces/mobile-build.ts +++ b/src/app/interfaces/mobile-build.ts @@ -67,3 +67,10 @@ export interface MobileBuildPreflightResult { passed: boolean; issues: string[]; } + +export interface ThemeStatus { + exists: boolean; + path: string; + generated_at: string | null; + size: number | null; +} diff --git a/src/app/services/mobile-build.service.ts b/src/app/services/mobile-build.service.ts index 1b4aede..dd790a1 100644 --- a/src/app/services/mobile-build.service.ts +++ b/src/app/services/mobile-build.service.ts @@ -9,6 +9,7 @@ import { MobileBuildPreflightResult, MobileBuildRequest, MobileBuildUploadedFile, + ThemeStatus, } from '../interfaces/mobile-build'; @Injectable({ @@ -66,4 +67,17 @@ export class MobileBuildService { {}, ); } + + getThemeStatus(): Observable { + return this.http.get( + `${getAppConfig().apiBaseUrl}/api/theme/status`, + ); + } + + regenerateTheme(): Observable { + return this.http.post( + `${getAppConfig().apiBaseUrl}/api/theme/regenerate`, + {}, + ); + } } diff --git a/theme.json b/theme.json new file mode 100644 index 0000000..d35955f --- /dev/null +++ b/theme.json @@ -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" + } +}
theme.json not found at {{ status.path }}
Regenerate to create it from the current station config.
Loading...