Adds website theme serialization system
This commit is contained in:
@@ -13,7 +13,8 @@
|
||||
"extensions": [
|
||||
"Angular.ng-template",
|
||||
"magnobiet.sass-extension-pack",
|
||||
"Anthropic.claude-code"
|
||||
"Anthropic.claude-code",
|
||||
"ms-azuretools.vscode-containers"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
"maximumError": "10kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
|
||||
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.
@@ -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 + [
|
||||
(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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_"}
|
||||
|
||||
|
||||
|
||||
+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":
|
||||
|
||||
@@ -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 ──────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -22,7 +22,7 @@
|
||||
<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>
|
||||
(click)="activeTab.set('mobile-builds'); loadThemeJsonStatus()">Mobile Builds</button>
|
||||
<button class="tab-btn" [class.active]="activeTab() === 'stats'" (click)="activeTab.set('stats')">Stats</button>
|
||||
</div>
|
||||
|
||||
@@ -437,6 +437,33 @@
|
||||
}
|
||||
</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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ThemeStatus | null>(null);
|
||||
readonly themeJsonLoading$ = new BehaviorSubject<boolean>(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<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);
|
||||
}
|
||||
}
|
||||
|
||||
getArtifactDownloadHref(path: string): string {
|
||||
return `${getAppConfig().apiBaseUrl}${path}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
MobileBuildPreflightResult,
|
||||
MobileBuildRequest,
|
||||
MobileBuildUploadedFile,
|
||||
ThemeStatus,
|
||||
} from '../interfaces/mobile-build';
|
||||
|
||||
@Injectable({
|
||||
@@ -66,4 +67,17 @@ 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`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user