6.4 KiB
Plan: Data-Driven Station Configuration
Context
The station's branding — callsign, name, founding year, nonprofit status, logo, hero banner images, contact info, and frequency — is hardcoded across 10+ component templates. This makes the app a single-use template rather than a customizable platform. We need an admin-editable StationConfig singleton stored in PostgreSQL, a reactive frontend service, and an admin form so any station operator can rebrand the site without touching code.
Architecture
- Backend: New
StationConfigSQLAlchemy model (singleton viakey='default'), aPUT /station-configendpoint (admin-only), aGET /station-configpublic endpoint, and aPOST /station-config/uploadfile-upload endpoint. - Frontend: New
StationConfigServicewith asignal<StationConfig>loaded viaAPP_INITIALIZER. All components that currently hardcode station data read from this service. - Admin UI: New "Station" tab in the existing admin dashboard with a modal form for editing all fields, including file upload inputs for images.
Step 1: Backend — Model + Schemas
backend/app/models.py
Add StationConfig class using the existing Base. Fields:
id,key(unique, always'default')- Identity:
callsign,name_primary,name_secondary,tagline,frequency,founded_year - Nonprofit:
nonprofit_type,ein - Images (URL strings):
logo_url,hero_background_url,hero_icon_url,hero_divider_url - Contact:
address(Text),phone,email,website
All columns have defaults matching current hardcoded values.
backend/app/schemas.py
Add StationConfigResponse (all fields, from_attributes: True) and StationConfigUpdate (all fields Optional, default None for partial updates).
Step 2: Backend — API Router
backend/app/api/station_config.py (NEW)
Three endpoints:
GET /station-config— Returns the singleton row. Public.PUT /station-config— Partial update viamodel_dump(exclude_unset=True). Requiresget_current_admin_user.POST /station-config/upload— AcceptsUploadFile, saves touploads/directory with UUID filename, validatesimage/*MIME type, max 5MB. Returns{"url": "uploads/<uuid>.<ext>"}. Requiresget_current_admin_user.
backend/app/main.py
- Import
StationConfigfrom models. - Register router:
app.include_router(station_config.router, prefix="/api/station-config", tags=["station-config"]). - Mount
StaticFilesforuploads/directory at/uploads(dev only; Nginx handles prod). - Create
uploads/directory on startup.
backend/seed.py
- Add
STATION_CONFIGdict with current defaults. - Add
_upsert_station_config()helper (get-or-create bykey='default'). - Call it in
seed(). - Add
StationConfig.__table__.delete()totruncate_all().
backend/app/api/admin.py
- Add
StationConfigto the truncation list inreset_database().
Step 3: Frontend — Interface + Service
src/app/interfaces/station-config.ts (NEW)
TypeScript interface mirroring the schema.
src/app/services/station-config.service.ts (NEW)
config = signal<StationConfig>(DEFAULT_CONFIG)— signal initialized with hardcoded defaults so components never seenull.load()— fetches from API, updates signal. Errors silently caught (same pattern asappConfigFactory).updateConfig(payload: Partial<StationConfig>)— PUT request.uploadImage(file: File)— POST FormData to upload endpoint.
src/app/app.config.ts
Add APP_INITIALIZER provider that calls stationConfigService.load() (parallel with existing appConfigInitProvider).
Step 4: Frontend — Admin Station Form
src/app/admin/admin-station-form.component.* (3 NEW files)
Modal form following the existing admin-program-form pattern:
- Identity section: callsign, name_primary, name_secondary, tagline, frequency, founded_year
- Nonprofit section: nonprofit_type, ein
- Images section: 4 file inputs (logo, hero background, hero icon, hero divider) with thumbnail previews of current values
- Contact section: address (textarea), phone, email, website
- On submit: calls
stationConfigService.updateConfig()with all fields as a partial update
src/app/admin/admin.component.ts
- Add
'station'toTabKeyunion. - Import
StationConfigServiceandAdminStationFormComponent. - Add
editingStation,showStationFormproperties. - Add
openStationForm(),onStationSaved(),onStationClosed()methods. - In
loadAll(), load station config.
src/app/admin/admin.component.html
- Add "Station" tab button.
- Add station tab content: a read-only form display of current values with an "Edit" button.
- Add form modal:
@if (showStationForm) { <app-admin-station-form ...> }.
Step 5: Frontend — Update All Components
Each component injects StationConfigService and reads configService.config() (a signal). Since config loads via APP_INITIALIZER with defaults, it's never null.
| Component | Changes |
|---|---|
| Navbar | Logo src, brand name (callsign + name_primary + name_secondary), tagline with founded_year |
| Hero | Background/icon/divider image srcs, title, subtitle (tagline + founded_year) |
| Footer | Logo src, brand name, tagline, nonprofit_type, EIN, copyright year range |
| About | Hero icon src, station name, frequency, nonprofit_type |
| Contact | Logo src, address (split on \n for <br>), phone, email, frequency, website |
| Donate | Hero icon src, station name, EIN, frequency |
| Schedule | Logo src, frequency |
| Login | Logo src, station name |
| App | Set document.title from config in ngOnInit |
Step 6: Verification
- Start backend:
cd backend && KMTN_DATABASE_URL=sqlite+aiosqlite:///./kmountain.db uvicorn app.main:app --reload - Verify Swagger:
GET /api/station-configreturns seeded defaults - Verify upload:
POST /api/station-config/uploadwith an image file returns a URL - Start frontend:
ng serve - Navigate to site — verify all pages render with seeded data
- Log in as admin → navigate to
/admin→ click "Station" tab - Edit a field (e.g., change name_primary to "Summit") → save → verify navbar/hero/footer update
- Upload a new logo image → verify it appears in navbar and footer
- Run
curl -X POST http://localhost:8000/api/admin/reset→ verify config resets to seed defaults