working basic site admin

This commit is contained in:
2026-06-21 02:56:49 +00:00
parent 53d932655e
commit f52625b37b
38 changed files with 1163 additions and 56 deletions
+125
View File
@@ -0,0 +1,125 @@
# 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 `StationConfig` SQLAlchemy model (singleton via `key='default'`), a `PUT /station-config` endpoint (admin-only), a `GET /station-config` public endpoint, and a `POST /station-config/upload` file-upload endpoint.
- **Frontend**: New `StationConfigService` with a `signal<StationConfig>` loaded via `APP_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:
1. `GET /station-config` — Returns the singleton row. Public.
2. `PUT /station-config` — Partial update via `model_dump(exclude_unset=True)`. Requires `get_current_admin_user`.
3. `POST /station-config/upload` — Accepts `UploadFile`, saves to `uploads/` directory with UUID filename, validates `image/*` MIME type, max 5MB. Returns `{"url": "uploads/<uuid>.<ext>"}`. Requires `get_current_admin_user`.
### `backend/app/main.py`
- Import `StationConfig` from models.
- Register router: `app.include_router(station_config.router, prefix="/api/station-config", tags=["station-config"])`.
- Mount `StaticFiles` for `uploads/` directory at `/uploads` (dev only; Nginx handles prod).
- Create `uploads/` directory on startup.
### `backend/seed.py`
- Add `STATION_CONFIG` dict with current defaults.
- Add `_upsert_station_config()` helper (get-or-create by `key='default'`).
- Call it in `seed()`.
- Add `StationConfig.__table__.delete()` to `truncate_all()`.
### `backend/app/api/admin.py`
- Add `StationConfig` to the truncation list in `reset_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 see `null`.
- `load()` — fetches from API, updates signal. Errors silently caught (same pattern as `appConfigFactory`).
- `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'` to `TabKey` union.
- Import `StationConfigService` and `AdminStationFormComponent`.
- Add `editingStation`, `showStationForm` properties.
- 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
1. Start backend: `cd backend && KMTN_DATABASE_URL=sqlite+aiosqlite:///./kmountain.db uvicorn app.main:app --reload`
2. Verify Swagger: `GET /api/station-config` returns seeded defaults
3. Verify upload: `POST /api/station-config/upload` with an image file returns a URL
4. Start frontend: `ng serve`
5. Navigate to site — verify all pages render with seeded data
6. Log in as admin → navigate to `/admin` → click "Station" tab
7. Edit a field (e.g., change name_primary to "Summit") → save → verify navbar/hero/footer update
8. Upload a new logo image → verify it appears in navbar and footer
9. Run `curl -X POST http://localhost:8000/api/admin/reset` → verify config resets to seed defaults
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -1
View File
@@ -2,7 +2,7 @@ from fastapi import APIRouter
from app.config import settings from app.config import settings
from app.database import get_session from app.database import get_session
from app.models import DonationTier, Event, Program from app.models import DonationTier, Event, Program, StationConfig
router = APIRouter() router = APIRouter()
@@ -20,6 +20,7 @@ async def reset_database():
await session.execute(Program.__table__.delete()) await session.execute(Program.__table__.delete())
await session.execute(Event.__table__.delete()) await session.execute(Event.__table__.delete())
await session.execute(DonationTier.__table__.delete()) await session.execute(DonationTier.__table__.delete())
await session.execute(StationConfig.__table__.delete())
await session.commit() await session.commit()
await run_seed() await run_seed()
+95
View File
@@ -0,0 +1,95 @@
"""Station config router: read/update the singleton station branding config."""
import os
import uuid
from fastapi import APIRouter, Depends, HTTPException, UploadFile, 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.user_models import User
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter()
UPLOAD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads")
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB
@router.get("", response_model=StationConfigResponse)
async def get_station_config(session: AsyncSession = Depends(get_session)):
"""Return the singleton station config. Public endpoint."""
result = await session.execute(
select(StationConfig).where(StationConfig.key == "default")
)
config = result.scalar_one_or_none()
if config is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Station config not found. Run seed to initialize.",
)
return config
@router.put("", response_model=StationConfigResponse)
async def update_station_config(
payload: StationConfigUpdate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_admin_user),
):
"""Partial update of station config. Admin only."""
result = await session.execute(
select(StationConfig).where(StationConfig.key == "default")
)
config = result.scalar_one_or_none()
if config is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Station config not found")
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(config, key, value)
await session.commit()
await session.refresh(config)
return config
@router.post("/upload")
async def upload_image(
file: UploadFile,
current_user: User = Depends(get_current_admin_user),
):
"""Upload an image file for station branding. Admin only.
Saves the file to the uploads/ directory with a UUID filename.
Returns the relative URL path.
"""
# Validate MIME type
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Only image files are allowed",
)
# Read and validate size
content = await file.read()
if len(content) > MAX_FILE_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File size exceeds 5 MB limit",
)
# Ensure upload directory exists
os.makedirs(UPLOAD_DIR, exist_ok=True)
# Generate unique filename preserving extension
ext = os.path.splitext(file.filename or "upload")[1] if file.filename else ".bin"
filename = f"{uuid.uuid4().hex}{ext}"
filepath = os.path.join(UPLOAD_DIR, filename)
with open(filepath, "wb") as f:
f.write(content)
return {"url": f"uploads/{filename}"}
+35 -2
View File
@@ -1,14 +1,31 @@
import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from sqlalchemy import func, select from sqlalchemy import func, select
from app.config import settings from app.config import settings
from app.database import async_session, engine from app.database import async_session, engine
from app.models import Base, Program from app.models import Base, Program, StationConfig
from app.user_models import User from app.user_models import User
from app.api import programs, events, tiers, auth from app.api import programs, events, tiers, auth, station_config
async def _ensure_station_config(session):
"""Ensure the singleton station config row exists (idempotent)."""
result = await session.execute(
select(StationConfig).where(StationConfig.key == "default")
)
if result.scalar_one_or_none() is None:
print(" → Station config missing — seeding defaults...")
from seed import seed as run_seed
# Only seed the station config, not the full dataset
from seed import STATION_CONFIG, _upsert_station_config
await _upsert_station_config(session, STATION_CONFIG)
await session.commit()
print(" ✓ Station config seeded")
@asynccontextmanager @asynccontextmanager
@@ -25,6 +42,11 @@ async def lifespan(app: FastAPI):
print(" → Database is empty — running seed...") print(" → Database is empty — running seed...")
from seed import seed as run_seed from seed import seed as run_seed
await run_seed() await run_seed()
return # Full seed already included station config
# Ensure station config exists (runs even if DB already had data)
async with async_session() as session:
await _ensure_station_config(session)
# Bootstrap admin user if configured # Bootstrap admin user if configured
if settings.ADMIN_USERNAME and settings.ADMIN_PASSWORD: if settings.ADMIN_USERNAME and settings.ADMIN_PASSWORD:
@@ -46,6 +68,10 @@ async def lifespan(app: FastAPI):
await session.commit() await session.commit()
print(f" → Bootstrap admin created: {settings.ADMIN_USERNAME}") print(f" → Bootstrap admin created: {settings.ADMIN_USERNAME}")
# Ensure uploads directory exists
uploads_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "uploads")
os.makedirs(uploads_dir, exist_ok=True)
yield yield
@@ -69,6 +95,13 @@ app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(programs.router, prefix="/api/programs", tags=["programs"]) app.include_router(programs.router, prefix="/api/programs", tags=["programs"])
app.include_router(events.router, prefix="/api/events", tags=["events"]) app.include_router(events.router, prefix="/api/events", tags=["events"])
app.include_router(tiers.router, prefix="/api/tiers", tags=["tiers"]) app.include_router(tiers.router, prefix="/api/tiers", tags=["tiers"])
app.include_router(station_config.router, prefix="/api/station-config", tags=["station-config"])
# Serve uploaded files (dev only — Nginx handles this in production)
if settings.ENV != "production":
uploads_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "uploads")
if os.path.exists(uploads_dir):
app.mount("/uploads", StaticFiles(directory=uploads_dir), name="uploads")
# Dev-only admin router (disabled in production) # Dev-only admin router (disabled in production)
if settings.ENV != "production": if settings.ENV != "production":
+31
View File
@@ -49,3 +49,34 @@ class DonationTier(Base):
icon = Column(String(10), nullable=False) icon = Column(String(10), nullable=False)
benefits = Column(JSON, nullable=False) benefits = Column(JSON, nullable=False)
display_order = Column(Integer, nullable=False) display_order = Column(Integer, nullable=False)
class StationConfig(Base):
__tablename__ = "station_config"
id = Column(Integer, primary_key=True, autoincrement=True)
key = Column(String(20), unique=True, nullable=False, default="default")
# Identity
callsign = Column(String(10), nullable=False, default="KMTN")
name_primary = Column(String(100), nullable=False, default="KMountain")
name_secondary = Column(String(100), nullable=False, default="Flower Radio")
tagline = Column(String(200), nullable=False, default="Community Radio — Est. 1987")
frequency = Column(String(10), nullable=False, default="98.7 FM")
founded_year = Column(Integer, nullable=False, default=1987)
# Nonprofit
nonprofit_type = Column(String(10), nullable=False, default="501(c)(3)")
ein = Column(String(20), nullable=False, default="84-XXXXXXX")
# Images (URL strings relative to app root)
logo_url = Column(String(500), nullable=False, default="svg/small-flower.svg")
hero_background_url = Column(String(500), nullable=False, default="svg/mountain-landscape.svg")
hero_icon_url = Column(String(500), nullable=False, default="svg/orange-flower.svg")
hero_divider_url = Column(String(500), nullable=False, default="svg/mountain-divider.svg")
# Contact
address = Column(Text, nullable=False, default="42 Pine Street\nMountain View, CO 80424")
phone = Column(String(30), nullable=False, default="(970) 555-0198")
email = Column(String(100), nullable=False, default="hello@kmountainflower.org")
website = Column(String(200), nullable=False, default="kmountainflower.org")
+44
View File
@@ -112,3 +112,47 @@ class TierResponse(BaseModel):
display_order: int display_order: int
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
# ── StationConfig ─────────────────────────────────────────
class StationConfigResponse(BaseModel):
id: int
key: str
callsign: str
name_primary: str
name_secondary: str
tagline: str
frequency: str
founded_year: int
nonprofit_type: str
ein: str
logo_url: str
hero_background_url: str
hero_icon_url: str
hero_divider_url: str
address: str
phone: str
email: str
website: str
model_config = {"from_attributes": True}
class StationConfigUpdate(BaseModel):
callsign: Optional[str] = None
name_primary: Optional[str] = None
name_secondary: Optional[str] = None
tagline: Optional[str] = None
frequency: Optional[str] = None
founded_year: Optional[int] = None
nonprofit_type: Optional[str] = None
ein: Optional[str] = None
logo_url: Optional[str] = None
hero_background_url: Optional[str] = None
hero_icon_url: Optional[str] = None
hero_divider_url: Optional[str] = None
address: Optional[str] = None
phone: Optional[str] = None
email: Optional[str] = None
website: Optional[str] = None
+39 -1
View File
@@ -14,7 +14,7 @@ from datetime import date
from sqlalchemy import select from sqlalchemy import select
from app.database import async_session from app.database import async_session
from app.models import Program, Event, DonationTier from app.models import Program, Event, DonationTier, StationConfig
# ── Seed data ────────────────────────────────────────────── # ── Seed data ──────────────────────────────────────────────
@@ -93,6 +93,26 @@ TIERS: list[dict] = [
}, },
] ]
STATION_CONFIG: dict = {
"key": "default",
"callsign": "KMTN",
"name_primary": "KMountain",
"name_secondary": "Flower Radio",
"tagline": "Community Radio — Est. 1987",
"frequency": "98.7 FM",
"founded_year": 1987,
"nonprofit_type": "501(c)(3)",
"ein": "84-XXXXXXX",
"logo_url": "svg/small-flower.svg",
"hero_background_url": "svg/mountain-landscape.svg",
"hero_icon_url": "svg/orange-flower.svg",
"hero_divider_url": "svg/mountain-divider.svg",
"address": "42 Pine Street\nMountain View, CO 80424",
"phone": "(970) 555-0198",
"email": "hello@kmountainflower.org",
"website": "kmountainflower.org",
}
# ── Helpers ──────────────────────────────────────────────── # ── Helpers ────────────────────────────────────────────────
@@ -141,6 +161,19 @@ async def _upsert_tier(session, data: dict) -> None:
session.add(DonationTier(**data)) session.add(DonationTier(**data))
async def _upsert_station_config(session, data: dict) -> None:
"""Get-or-create the singleton station config (key='default')."""
existing = await session.execute(
select(StationConfig).where(StationConfig.key == "default")
)
config = existing.scalar_one_or_none()
if config:
for key, value in data.items():
setattr(config, key, value)
else:
session.add(StationConfig(**data))
# ── Main ────────────────────────────────────────────────── # ── Main ──────────────────────────────────────────────────
async def seed() -> None: async def seed() -> None:
@@ -161,6 +194,10 @@ async def seed() -> None:
await session.commit() await session.commit()
print(f" ✓ Seeded {len(TIERS)} donation tiers") print(f" ✓ Seeded {len(TIERS)} donation tiers")
await _upsert_station_config(session, STATION_CONFIG)
await session.commit()
print(" ✓ Seeded station config")
async def truncate_all() -> None: async def truncate_all() -> None:
"""Remove all seed data from the database.""" """Remove all seed data from the database."""
@@ -168,6 +205,7 @@ async def truncate_all() -> None:
await session.execute(Program.__table__.delete()) await session.execute(Program.__table__.delete())
await session.execute(Event.__table__.delete()) await session.execute(Event.__table__.delete())
await session.execute(DonationTier.__table__.delete()) await session.execute(DonationTier.__table__.delete())
await session.execute(StationConfig.__table__.delete())
await session.commit() await session.commit()
print(" ✓ Truncated all tables") print(" ✓ Truncated all tables")
+6 -6
View File
@@ -1,10 +1,10 @@
<section class="about"> <section class="about">
<div class="container"> <div class="container">
<div class="about-intro"> <div class="about-intro">
<img src="svg/orange-flower.svg" alt="" class="about-flower-decor" aria-hidden="true"> <img [src]="config().hero_icon_url" alt="" class="about-flower-decor" aria-hidden="true">
<h2>About <span class="text-accent">KMountain Flower</span></h2> <h2>About <span class="text-accent">{{ config().name_primary }} {{ config().name_secondary }}</span></h2>
<p class="section-intro"> <p class="section-intro">
For nearly four decades, KMountain Flower has been the voice of our For nearly four decades, {{ config().name_primary }} {{ config().name_secondary }} has been the voice of our
community — a nonprofit, listener-supported radio station dedicated to community — a nonprofit, listener-supported radio station dedicated to
music, local news, and the stories that bind mountain towns together. music, local news, and the stories that bind mountain towns together.
</p> </p>
@@ -33,7 +33,7 @@
<div class="card-icon">📡</div> <div class="card-icon">📡</div>
<h3>Our Reach</h3> <h3>Our Reach</h3>
<p> <p>
Over the air on 98.7 FM and streaming live worldwide. Whether you're Over the air on {{ config().frequency }} and streaming live worldwide. Whether you're
hiking the ridgeline or on the other side of the planet, our signal hiking the ridgeline or on the other side of the planet, our signal
— and our welcome — goes everywhere. — and our welcome — goes everywhere.
</p> </p>
@@ -51,11 +51,11 @@
<div class="about-cta"> <div class="about-cta">
<div class="about-flower-divider" aria-hidden="true"> <div class="about-flower-divider" aria-hidden="true">
<img src="svg/small-flower.svg" alt=""> <img [src]="config().logo_url" alt="">
</div> </div>
<h3>Become a Member</h3> <h3>Become a Member</h3>
<p class="section-intro"> <p class="section-intro">
As a 501(c)(3) nonprofit, every donation keeps our airwaves free and our As a {{ config().nonprofit_type }} nonprofit, every donation keeps our airwaves free and our
signal strong. Members receive exclusive perks and the satisfaction of signal strong. Members receive exclusive perks and the satisfaction of
knowing they helped make this possible. knowing they helped make this possible.
</p> </p>
+8 -3
View File
@@ -1,10 +1,15 @@
import { Component } from '@angular/core'; import { Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { StationConfigService } from '../services/station-config.service';
@Component({ @Component({
selector: 'app-about', selector: 'app-about',
standalone: true, standalone: true,
imports: [], imports: [RouterLink],
templateUrl: './about.component.html', templateUrl: './about.component.html',
styleUrl: './about.component.scss', styleUrl: './about.component.scss',
}) })
export class AboutComponent {} export class AboutComponent {
readonly config = inject(StationConfigService).config;
}
@@ -0,0 +1,141 @@
<div class="form-overlay" (click)="onClose()">
<div class="form-dialog form-dialog--wide" (click)="$event.stopPropagation()">
<div class="form-header">
<h2>Edit Station Config</h2>
<button type="button" class="btn-close" (click)="onClose()">&times;</button>
</div>
@if (error) {
<div class="form-error" role="alert">{{ error }}</div>
}
@if (success) {
<div class="form-success" role="alert">{{ success }}</div>
}
<form (ngSubmit)="onSubmit()" class="admin-form" #form="ngForm">
<!-- Identity -->
<div class="form-section">
<h3>Identity</h3>
<div class="form-row">
<div class="form-group">
<label for="callsign">Callsign</label>
<input id="callsign" type="text" [(ngModel)]="callsign" name="callsign" required>
</div>
<div class="form-group">
<label for="frequency">Frequency</label>
<input id="frequency" type="text" [(ngModel)]="frequency" name="frequency" placeholder="98.7 FM">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="name_primary">Primary Name</label>
<input id="name_primary" type="text" [(ngModel)]="name_primary" name="name_primary" required>
</div>
<div class="form-group">
<label for="name_secondary">Secondary Name</label>
<input id="name_secondary" type="text" [(ngModel)]="name_secondary" name="name_secondary">
</div>
</div>
<div class="form-group">
<label for="tagline">Tagline</label>
<input id="tagline" type="text" [(ngModel)]="tagline" name="tagline">
</div>
<div class="form-group">
<label for="founded_year">Founded Year</label>
<input id="founded_year" type="number" [(ngModel)]="founded_year" name="founded_year">
</div>
</div>
<!-- Images -->
<div class="form-section">
<h3>Images</h3>
<div class="form-group">
<label for="logo_url">Logo URL</label>
<div class="url-with-upload">
<input id="logo_url" type="text" [(ngModel)]="logo_url" name="logo_url" placeholder="uploads/…">
<label class="btn btn-upload">
Upload
<input type="file" accept="image/*" (change)="onImageUpload($event, 'logo_url')" [disabled]="loading">
</label>
</div>
</div>
<div class="form-group">
<label for="hero_background_url">Hero Background URL</label>
<div class="url-with-upload">
<input id="hero_background_url" type="text" [(ngModel)]="hero_background_url" name="hero_background_url">
<label class="btn btn-upload">
Upload
<input type="file" accept="image/*" (change)="onImageUpload($event, 'hero_background_url')" [disabled]="loading">
</label>
</div>
</div>
<div class="form-group">
<label for="hero_icon_url">Hero Icon URL</label>
<div class="url-with-upload">
<input id="hero_icon_url" type="text" [(ngModel)]="hero_icon_url" name="hero_icon_url">
<label class="btn btn-upload">
Upload
<input type="file" accept="image/*" (change)="onImageUpload($event, 'hero_icon_url')" [disabled]="loading">
</label>
</div>
</div>
<div class="form-group">
<label for="hero_divider_url">Hero Divider URL</label>
<div class="url-with-upload">
<input id="hero_divider_url" type="text" [(ngModel)]="hero_divider_url" name="hero_divider_url">
<label class="btn btn-upload">
Upload
<input type="file" accept="image/*" (change)="onImageUpload($event, 'hero_divider_url')" [disabled]="loading">
</label>
</div>
</div>
</div>
<!-- Nonprofit -->
<div class="form-section">
<h3>Nonprofit</h3>
<div class="form-row">
<div class="form-group">
<label for="nonprofit_type">Nonprofit Type</label>
<input id="nonprofit_type" type="text" [(ngModel)]="nonprofit_type" name="nonprofit_type" placeholder="501(c)(3)">
</div>
<div class="form-group">
<label for="ein">EIN</label>
<input id="ein" type="text" [(ngModel)]="ein" name="ein" placeholder="84-XXXXXXX">
</div>
</div>
</div>
<!-- Contact -->
<div class="form-section">
<h3>Contact</h3>
<div class="form-group">
<label for="address">Address</label>
<textarea id="address" [(ngModel)]="address" name="address" rows="2"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="phone">Phone</label>
<input id="phone" type="text" [(ngModel)]="phone" name="phone">
</div>
<div class="form-group">
<label for="email">Email</label>
<input id="email" type="email" [(ngModel)]="email" name="email">
</div>
</div>
<div class="form-group">
<label for="website">Website</label>
<input id="website" type="text" [(ngModel)]="website" name="website">
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-cancel" (click)="onClose()">Cancel</button>
<button type="submit" class="btn btn-save" [disabled]="saving">
{{ saving ? 'Saving…' : 'Save Config' }}
</button>
</div>
</form>
</div>
</div>
@@ -0,0 +1,196 @@
@use '../../styles/variables' as *;
@use '../../styles/mixins' as *;
.form-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
@include flex-center;
z-index: $z-modal;
@include fade-in;
}
.form-dialog {
background: $neutral-white;
border-radius: $radius-lg;
padding: $spacing-xl;
width: 90%;
max-width: 560px;
box-shadow: $shadow-xl;
&--wide {
max-width: 720px;
max-height: 85vh;
overflow-y: auto;
}
}
.form-header {
@include flex-between;
margin-bottom: $spacing-lg;
h2 {
font-family: $font-heading;
color: $primary-blue;
margin: 0;
font-size: 1.4rem;
}
}
.btn-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: $neutral-medium;
line-height: 1;
padding: $spacing-xs;
&:hover {
color: $neutral-dark;
}
}
.form-error {
background: rgba($danger-red, 0.1);
color: $danger-red;
padding: $spacing-sm $spacing-md;
border-radius: $radius-md;
font-size: 0.875rem;
margin-bottom: $spacing-md;
text-align: center;
}
.form-success {
background: rgba(#2e7d32, 0.1);
color: #2e7d32;
padding: $spacing-sm $spacing-md;
border-radius: $radius-md;
font-size: 0.875rem;
margin-bottom: $spacing-md;
text-align: center;
}
.admin-form {
.form-section {
margin-bottom: $spacing-xl;
h3 {
font-family: $font-heading;
color: $primary-blue;
font-size: 1.1rem;
margin: 0 0 $spacing-md 0;
padding-bottom: $spacing-xs;
border-bottom: 1px solid $neutral-light;
}
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $spacing-md;
}
.form-group {
margin-bottom: $spacing-md;
label:not(.btn-upload) {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: $neutral-dark;
margin-bottom: $spacing-xs;
}
input, select, textarea {
width: 100%;
padding: $spacing-sm $spacing-md;
border: 1px solid $neutral-light;
border-radius: $radius-md;
font-size: 0.95rem;
transition: border-color $transition-fast;
box-sizing: border-box;
&:focus {
outline: none;
border-color: $primary-blue;
box-shadow: 0 0 0 3px rgba($primary-blue, 0.15);
}
}
}
.url-with-upload {
display: flex;
gap: $spacing-sm;
align-items: center;
input[type="text"] {
flex: 1;
}
}
.btn-upload {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
background: $neutral-light;
color: $neutral-dark;
border: none;
border-radius: $radius-md;
padding: $spacing-sm $spacing-md;
font-size: 0.875rem;
cursor: pointer;
transition: background $transition-fast;
white-space: nowrap;
&:hover {
background: $neutral-medium;
color: $neutral-white;
}
input[type="file"] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
width: 100%;
&:disabled {
cursor: not-allowed;
}
}
}
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: $spacing-sm;
margin-top: $spacing-md;
}
.btn-save {
@include button-style($primary-blue, $neutral-white, $primary-blue-light);
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.btn-cancel {
background: $neutral-light;
color: $neutral-dark;
border: none;
border-radius: $radius-md;
padding: $spacing-sm $spacing-lg;
font-size: 0.95rem;
cursor: pointer;
transition: background $transition-fast;
&:hover {
background: $neutral-medium;
color: $neutral-white;
}
}
@@ -0,0 +1,161 @@
import { Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { StationConfig } from '../interfaces/station-config';
import { StationConfigService } from '../services/station-config.service';
@Component({
selector: 'app-admin-station-form',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './admin-station-form.component.html',
styleUrl: './admin-station-form.component.scss',
})
export class AdminStationFormComponent implements OnChanges {
private stationConfigService = inject(StationConfigService);
@Input() editItem: StationConfig | null = null;
@Output() saved = new EventEmitter<void>();
@Output() closed = new EventEmitter<void>();
callsign = '';
name_primary = '';
name_secondary = '';
tagline = '';
frequency = '';
founded_year = 0;
nonprofit_type = '';
ein = '';
logo_url = '';
hero_background_url = '';
hero_icon_url = '';
hero_divider_url = '';
address = '';
phone = '';
email = '';
website = '';
loading = false;
saving = false;
error = '';
success = '';
ngOnChanges(changes: SimpleChanges): void {
if (changes['editItem'] && this.editItem) {
this.populate(this.editItem);
}
}
/** Populate form with existing station config. */
populate(config: StationConfig): void {
this.callsign = config.callsign;
this.name_primary = config.name_primary;
this.name_secondary = config.name_secondary;
this.tagline = config.tagline;
this.frequency = config.frequency;
this.founded_year = config.founded_year;
this.nonprofit_type = config.nonprofit_type;
this.ein = config.ein;
this.logo_url = config.logo_url;
this.hero_background_url = config.hero_background_url;
this.hero_icon_url = config.hero_icon_url;
this.hero_divider_url = config.hero_divider_url;
this.address = config.address;
this.phone = config.phone;
this.email = config.email;
this.website = config.website;
}
/** Reset form with current service values. */
reset(): void {
const config = this.stationConfigService.config();
this.populate(config);
this.error = '';
this.success = '';
this.loading = false;
}
/** Close the dialog without saving. */
onClose(): void {
this.closed.emit();
this.reset();
}
formatError(err: any): string {
if (err?.error?.detail) return err.error.detail;
if (err?.error?.message) return err.error.message;
if (err?.message) return err.message;
return 'Failed to save. Please try again.';
}
/** Handle image file upload and set the URL field. */
async onImageUpload(event: Event, field: string): Promise<void> {
const input = event.target as HTMLInputElement;
if (!input?.files?.length) return;
const file = input.files[0];
if (!file.type.startsWith('image/')) {
this.error = 'Only image files are allowed.';
return;
}
if (file.size > 5 * 1024 * 1024) {
this.error = 'File size exceeds 5 MB limit.';
return;
}
this.loading = true;
this.error = '';
try {
const url = await this.stationConfigService.uploadImage(file);
(this as any)[field] = url;
this.success = 'Image uploaded successfully.';
} catch (err: any) {
this.error = this.formatError(err);
} finally {
this.loading = false;
}
}
async onSubmit(): Promise<void> {
if (!this.name_primary || !this.callsign) {
this.error = 'Callsign and primary name are required.';
return;
}
this.saving = true;
this.error = '';
this.success = '';
const payload: Partial<StationConfig> = {
callsign: this.callsign,
name_primary: this.name_primary,
name_secondary: this.name_secondary,
tagline: this.tagline,
frequency: this.frequency,
founded_year: this.founded_year,
nonprofit_type: this.nonprofit_type,
ein: this.ein,
logo_url: this.logo_url,
hero_background_url: this.hero_background_url,
hero_icon_url: this.hero_icon_url,
hero_divider_url: this.hero_divider_url,
address: this.address,
phone: this.phone,
email: this.email,
website: this.website,
};
try {
await this.stationConfigService.updateConfig(payload);
this.success = 'Station config updated successfully.';
this.saved.emit();
} catch (err: any) {
this.error = this.formatError(err);
} finally {
this.saving = false;
}
}
}
+58
View File
@@ -22,6 +22,11 @@
[class.active]="activeTab() === 'tiers'" [class.active]="activeTab() === 'tiers'"
(click)="activeTab.set('tiers')" (click)="activeTab.set('tiers')"
>Tiers</button> >Tiers</button>
<button
class="tab-btn"
[class.active]="activeTab() === 'station'"
(click)="activeTab.set('station')"
>Station</button>
</div> </div>
<!-- Programs Tab --> <!-- Programs Tab -->
@@ -145,6 +150,56 @@
</div> </div>
} }
} }
<!-- 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>
</div>
}
}
</div> </div>
<!-- Form modals --> <!-- Form modals -->
@@ -157,4 +212,7 @@
@if (showTierForm) { @if (showTierForm) {
<app-admin-tier-form [editItem]="editingTier" (saved)="onTierSaved()" (closed)="onTierClosed()" /> <app-admin-tier-form [editItem]="editingTier" (saved)="onTierSaved()" (closed)="onTierClosed()" />
} }
@if (showStationForm) {
<app-admin-station-form [editItem]="stationConfig$ | async" (saved)="onStationSaved()" (closed)="onStationClosed()" />
}
</section> </section>
+27
View File
@@ -157,6 +157,33 @@
font-size: 1.1rem; 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;
}
}
.config-label {
font-weight: 600;
color: $neutral-medium;
width: 140px;
flex-shrink: 0;
font-size: 0.9rem;
}
.config-value {
color: $neutral-dark;
font-size: 0.95rem;
}
}
// ── Responsive ─────────────────────────────────────────── // ── Responsive ───────────────────────────────────────────
@include responsive(md) { @include responsive(md) {
+29 -1
View File
@@ -6,14 +6,17 @@ import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { Program } from '../interfaces/program'; import { Program } from '../interfaces/program';
import { Event } from '../interfaces/event'; import { Event } from '../interfaces/event';
import { Tier } from '../interfaces/tier'; import { Tier } from '../interfaces/tier';
import { StationConfig } from '../interfaces/station-config';
import { ProgramService } from '../services/program.service'; import { ProgramService } from '../services/program.service';
import { EventService } from '../services/event.service'; import { EventService } from '../services/event.service';
import { TierService } from '../services/tier.service'; import { TierService } from '../services/tier.service';
import { StationConfigService } from '../services/station-config.service';
import { AdminProgramFormComponent } from './admin-program-form.component'; import { AdminProgramFormComponent } from './admin-program-form.component';
import { AdminEventFormComponent } from './admin-event-form.component'; import { AdminEventFormComponent } from './admin-event-form.component';
import { AdminTierFormComponent } from './admin-tier-form.component'; import { AdminTierFormComponent } from './admin-tier-form.component';
import { AdminStationFormComponent } from './admin-station-form.component';
type TabKey = 'programs' | 'events' | 'tiers'; type TabKey = 'programs' | 'events' | 'tiers' | 'station';
@Component({ @Component({
selector: 'app-admin', selector: 'app-admin',
@@ -25,6 +28,7 @@ type TabKey = 'programs' | 'events' | 'tiers';
AdminProgramFormComponent, AdminProgramFormComponent,
AdminEventFormComponent, AdminEventFormComponent,
AdminTierFormComponent, AdminTierFormComponent,
AdminStationFormComponent,
], ],
templateUrl: './admin.component.html', templateUrl: './admin.component.html',
styleUrl: './admin.component.scss', styleUrl: './admin.component.scss',
@@ -33,6 +37,7 @@ export class AdminComponent implements OnInit {
private programService = inject(ProgramService); private programService = inject(ProgramService);
private eventService = inject(EventService); private eventService = inject(EventService);
private tierService = inject(TierService); private tierService = inject(TierService);
private stationConfigService = inject(StationConfigService);
activeTab = signal<TabKey>('programs'); activeTab = signal<TabKey>('programs');
@@ -45,10 +50,12 @@ export class AdminComponent implements OnInit {
readonly programs$ = new BehaviorSubject<Program[]>([]); readonly programs$ = new BehaviorSubject<Program[]>([]);
readonly events$ = new BehaviorSubject<Event[]>([]); readonly events$ = new BehaviorSubject<Event[]>([]);
readonly tiers$ = new BehaviorSubject<Tier[]>([]); readonly tiers$ = new BehaviorSubject<Tier[]>([]);
readonly stationConfig$ = new BehaviorSubject<StationConfig | null>(null);
showProgramForm = false; showProgramForm = false;
showEventForm = false; showEventForm = false;
showTierForm = false; showTierForm = false;
showStationForm = false;
ngOnInit(): void { ngOnInit(): void {
this.loadAll(); this.loadAll();
@@ -69,11 +76,17 @@ export class AdminComponent implements OnInit {
this.tiers$.next(data); this.tiers$.next(data);
} }
async reloadStationConfig(): Promise<void> {
await this.stationConfigService.load();
this.stationConfig$.next(this.stationConfigService.config());
}
async loadAll(): Promise<void> { async loadAll(): Promise<void> {
await Promise.all([ await Promise.all([
this.reloadPrograms(), this.reloadPrograms(),
this.reloadEvents(), this.reloadEvents(),
this.reloadTiers(), this.reloadTiers(),
this.reloadStationConfig(),
]); ]);
} }
@@ -163,4 +176,19 @@ export class AdminComponent implements OnInit {
await firstValueFrom(this.tierService.deleteTier(id)); await firstValueFrom(this.tierService.deleteTier(id));
await this.reloadTiers(); await this.reloadTiers();
} }
// ── Station Config ──────────────────────────────────────
openStationForm(): void {
this.showStationForm = true;
}
onStationSaved(): void {
this.reloadStationConfig();
this.showStationForm = false;
}
onStationClosed(): void {
this.showStationForm = false;
}
} }
+12 -1
View File
@@ -1,4 +1,4 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { APP_INITIALIZER, ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
@@ -8,6 +8,16 @@ import {
appConfigInitProvider, appConfigInitProvider,
appConfigProvider, appConfigProvider,
} from './services/app-config.service'; } from './services/app-config.service';
import { StationConfigService } from './services/station-config.service';
export const stationConfigInitProvider = {
provide: APP_INITIALIZER,
useFactory: (stationConfigService: StationConfigService) => {
return () => stationConfigService.load();
},
deps: [StationConfigService],
multi: true,
};
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
@@ -16,5 +26,6 @@ export const appConfig: ApplicationConfig = {
provideHttpClient(withInterceptors([authInterceptor])), provideHttpClient(withInterceptors([authInterceptor])),
appConfigProvider, appConfigProvider,
appConfigInitProvider, appConfigInitProvider,
stationConfigInitProvider,
], ],
}; };
+4 -4
View File
@@ -45,19 +45,19 @@
<div class="contact-info"> <div class="contact-info">
<div class="info-card"> <div class="info-card">
<h4><span aria-hidden="true">📍</span> Studio Location</h4> <h4><span aria-hidden="true">📍</span> Studio Location</h4>
<p>42 Pine Street<br>Mountain View, CO 80424</p> <p [innerHTML]="config().address"></p>
</div> </div>
<div class="info-card"> <div class="info-card">
<h4><span aria-hidden="true">📞</span> Phone</h4> <h4><span aria-hidden="true">📞</span> Phone</h4>
<p>(970) 555-0198<br>MonFri 8 AM 6 MT</p> <p>{{ config().phone }}<br>MonFri 8 AM 6 MT</p>
</div> </div>
<div class="info-card"> <div class="info-card">
<h4><span aria-hidden="true">✉️</span> Email</h4> <h4><span aria-hidden="true">✉️</span> Email</h4>
<p><a href="mailto:hello@kmountainflower.org">hello@kmountainflower.org</a></p> <p><a [href]="'mailto:' + config().email">{{ config().email }}</a></p>
</div> </div>
<div class="info-card"> <div class="info-card">
<h4><span aria-hidden="true">📡</span> Frequencies</h4> <h4><span aria-hidden="true">📡</span> Frequencies</h4>
<p>98.7 FM — Mountain Region<br>Stream: kmountainflower.org/live</p> <p>{{ config().frequency }} — Mountain Region<br>Stream: {{ config().website }}/live</p>
</div> </div>
</div> </div>
</div> </div>
+5 -1
View File
@@ -1,6 +1,8 @@
import { Component } from '@angular/core'; import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { StationConfigService } from '../services/station-config.service';
@Component({ @Component({
selector: 'app-contact', selector: 'app-contact',
standalone: true, standalone: true,
@@ -9,6 +11,8 @@ import { FormsModule } from '@angular/forms';
styleUrl: './contact.component.scss', styleUrl: './contact.component.scss',
}) })
export class ContactComponent { export class ContactComponent {
readonly config = inject(StationConfigService).config;
onSubmit(): void { onSubmit(): void {
alert('Thank you for your message! We will get back to you shortly.'); alert('Thank you for your message! We will get back to you shortly.');
} }
+5 -5
View File
@@ -3,10 +3,10 @@
<div class="donate-banner"> <div class="donate-banner">
<div class="donate-banner-overlay"> <div class="donate-banner-overlay">
<div class="container donate-banner-content"> <div class="container donate-banner-content">
<img src="svg/orange-flower.svg" alt="" class="donate-flower-hero" aria-hidden="true"> <img [src]="config().hero_icon_url" alt="" class="donate-flower-hero" aria-hidden="true">
<h2>Keep the Music <span class="text-accent">Free</span></h2> <h2>Keep the Music <span class="text-accent">Free</span></h2>
<p class="donate-subtitle"> <p class="donate-subtitle">
KMountain Flower Radio is 100% listener-funded and independent. {{ config().name_primary }} {{ config().name_secondary }} Radio is 100% listener-funded and independent.
Your gift keeps our signal strong and our airwaves free for everyone. Your gift keeps our signal strong and our airwaves free for everyone.
</p> </p>
</div> </div>
@@ -42,12 +42,12 @@
<!-- One-time donation --> <!-- One-time donation -->
<div class="donate-one-time"> <div class="donate-one-time">
<div class="donate-flower-divider" aria-hidden="true"> <div class="donate-flower-divider" aria-hidden="true">
<img src="svg/small-flower.svg" alt=""> <img [src]="config().logo_url" alt="">
</div> </div>
<h3>Or Make a One-Time Gift</h3> <h3>Or Make a One-Time Gift</h3>
<p class="section-intro"> <p class="section-intro">
Every dollar matters — from $5 to $5,000. No amount is too small to make Every dollar matters — from $5 to $5,000. No amount is too small to make
a difference. All donations are tax-deductible (EIN: 84-XXXXXXX). a difference. All donations are tax-deductible (EIN: {{ config().ein }}).
</p> </p>
<a href="#" class="btn btn-lg">Give Any Amount</a> <a href="#" class="btn btn-lg">Give Any Amount</a>
</div> </div>
@@ -57,7 +57,7 @@
<div class="why-grid"> <div class="why-grid">
<div class="why-item"> <div class="why-item">
<span class="why-icon">&#x1F4FB;</span> <span class="why-icon">&#x1F4FB;</span>
<strong>98.7 FM Signal</strong> <strong>{{ config().frequency }} Signal</strong>
<p>Covering 3 mountain counties and the valleys between</p> <p>Covering 3 mountain counties and the valleys between</p>
</div> </div>
<div class="why-item"> <div class="why-item">
+2
View File
@@ -4,6 +4,7 @@ import { map, Observable, startWith } from 'rxjs';
import { TierService } from '../services/tier.service'; import { TierService } from '../services/tier.service';
import { Tier } from '../interfaces/tier'; import { Tier } from '../interfaces/tier';
import { StationConfigService } from '../services/station-config.service';
interface TiersState { interface TiersState {
loading: boolean; loading: boolean;
@@ -19,6 +20,7 @@ interface TiersState {
}) })
export class DonateComponent { export class DonateComponent {
private tierService = inject(TierService); private tierService = inject(TierService);
readonly config = inject(StationConfigService).config;
/** Async pipe drives the template — guarantees change detection on every emission. */ /** Async pipe drives the template — guarantees change detection on every emission. */
readonly tiers$: Observable<TiersState> = readonly tiers$: Observable<TiersState> =
+6 -6
View File
@@ -2,17 +2,17 @@
<div class="container"> <div class="container">
<div class="footer-top"> <div class="footer-top">
<div class="footer-brand"> <div class="footer-brand">
<img src="svg/small-flower.svg" alt="" class="footer-flower" aria-hidden="true"> <img [src]="config().logo_url" alt="" class="footer-flower" aria-hidden="true">
<h3> <h3>
<span class="brand-m">K</span>MOUNTAIN <span class="brand-m">{{ config().name_primary | slice:0:1 }}</span>{{ config().name_primary | slice:1 }}
<span class="brand-a">FLOWER</span> Radio <span class="brand-a">{{ config().name_secondary }}</span> Radio
</h3> </h3>
<p class="footer-tagline"> <p class="footer-tagline">
Community-powered nonprofit public radio — free to stream, funded by you. Community-powered nonprofit public radio — free to stream, funded by you.
</p> </p>
<p class="footer-legal"> <p class="footer-legal">
KMountain Flower Radio is a 501(c)(3) nonprofit organization.<br> {{ config().name_primary }} {{ config().name_secondary }} Radio is a {{ config().nonprofit_type }} nonprofit organization.<br>
EIN: 84-XXXXXXX. All donations are tax-deductible. EIN: {{ config().ein }}. All donations are tax-deductible.
</p> </p>
</div> </div>
@@ -49,7 +49,7 @@
</div> </div>
<div class="footer-bottom"> <div class="footer-bottom">
<p>&copy; 19872026 KMountain Flower Radio. All rights reserved.</p> <p>&copy; {{ config().founded_year }}{{ currentYear() }} {{ config().name_primary }} {{ config().name_secondary }} Radio. All rights reserved.</p>
<p class="footer-made"> <p class="footer-made">
Made with 🧡 in the mountains Made with 🧡 in the mountains
</p> </p>
+10 -3
View File
@@ -1,10 +1,17 @@
import { Component } from '@angular/core'; import { Component, inject, computed } from '@angular/core';
import { SlicePipe } from '@angular/common';
import { RouterLink } from '@angular/router';
import { StationConfigService } from '../services/station-config.service';
@Component({ @Component({
selector: 'app-footer', selector: 'app-footer',
standalone: true, standalone: true,
imports: [], imports: [RouterLink, SlicePipe],
templateUrl: './footer.component.html', templateUrl: './footer.component.html',
styleUrl: './footer.component.scss', styleUrl: './footer.component.scss',
}) })
export class FooterComponent {} export class FooterComponent {
readonly config = inject(StationConfigService).config;
readonly currentYear = computed(() => new Date().getFullYear());
}
+7 -7
View File
@@ -1,7 +1,7 @@
<section class="hero"> <section class="hero">
<!-- Mountain background layer --> <!-- Mountain background layer -->
<div class="hero-mountains" aria-hidden="true"> <div class="hero-mountains" aria-hidden="true">
<img src="svg/mountain-landscape.svg" alt=""> <img [src]="config().hero_background_url" alt="">
</div> </div>
<!-- Gradient overlay --> <!-- Gradient overlay -->
@@ -11,18 +11,18 @@
<div class="hero-content container"> <div class="hero-content container">
<div class="hero-inner"> <div class="hero-inner">
<div class="hero-icon"> <div class="hero-icon">
<img src="svg/orange-flower.svg" alt="" class="hero-flower"> <img [src]="config().hero_icon_url" alt="" class="hero-flower">
</div> </div>
<h1 class="hero-title"> <h1 class="hero-title">
<span class="hero-line1">KMountain</span> <span class="hero-line1">{{ config().name_primary }}</span>
<span class="hero-line2 text-accent">Flower Radio</span> <span class="hero-line2 text-accent">{{ config().name_secondary }}</span>
</h1> </h1>
<p class="hero-subtitle"> <p class="hero-subtitle">
Your community-powered, nonprofit radio station — bringing music, Your community-powered, nonprofit radio station — bringing music,
stories, and connection to the mountain towns and beyond since 1987. stories, and connection to the mountain towns and beyond since {{ config().founded_year }}.
</p> </p>
<p class="hero-subtitle hero-tagline"> <p class="hero-subtitle hero-tagline">
Free to stream. Funded by you. Always on-air. {{ config().tagline }}
</p> </p>
<div class="hero-actions"> <div class="hero-actions">
<a routerLink="/donate" class="btn btn-hero-primary">Support the Station</a> <a routerLink="/donate" class="btn btn-hero-primary">Support the Station</a>
@@ -37,6 +37,6 @@
<!-- Mountain divider at bottom --> <!-- Mountain divider at bottom -->
<div class="hero-divider" aria-hidden="true"> <div class="hero-divider" aria-hidden="true">
<img src="svg/mountain-divider.svg" alt=""> <img [src]="config().hero_divider_url" alt="">
</div> </div>
</section> </section>
+8 -3
View File
@@ -1,10 +1,15 @@
import { Component } from '@angular/core'; import { Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { StationConfigService } from '../services/station-config.service';
@Component({ @Component({
selector: 'app-hero', selector: 'app-hero',
standalone: true, standalone: true,
imports: [], imports: [RouterLink],
templateUrl: './hero.component.html', templateUrl: './hero.component.html',
styleUrl: './hero.component.scss', styleUrl: './hero.component.scss',
}) })
export class HeroComponent {} export class HeroComponent {
readonly config = inject(StationConfigService).config;
}
+20
View File
@@ -0,0 +1,20 @@
export interface StationConfig {
id: number;
key: string;
callsign: string;
name_primary: string;
name_secondary: string;
tagline: string;
frequency: string;
founded_year: number;
nonprofit_type: string;
ein: string;
logo_url: string;
hero_background_url: string;
hero_icon_url: string;
hero_divider_url: string;
address: string;
phone: string;
email: string;
website: string;
}
+2 -2
View File
@@ -1,9 +1,9 @@
<section class="login-section"> <section class="login-section">
<div class="login-card"> <div class="login-card">
<div class="login-header"> <div class="login-header">
<img src="svg/small-flower.svg" alt="" class="login-flower" aria-hidden="true"> <img [src]="config().logo_url" alt="" class="login-flower" aria-hidden="true">
<h1>Sign In</h1> <h1>Sign In</h1>
<p>Admin access for KMountain Flower Radio</p> <p>Admin access for {{ config().name_primary }} {{ config().name_secondary }} Radio</p>
</div> </div>
<form (ngSubmit)="onSubmit()" class="login-form"> <form (ngSubmit)="onSubmit()" class="login-form">
+2
View File
@@ -5,6 +5,7 @@ import { Router } from '@angular/router';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { AuthService } from '../services/auth.service'; import { AuthService } from '../services/auth.service';
import { StationConfigService } from '../services/station-config.service';
@Component({ @Component({
selector: 'app-login', selector: 'app-login',
@@ -17,6 +18,7 @@ export class LoginComponent {
private auth = inject(AuthService); private auth = inject(AuthService);
private router = inject(Router); private router = inject(Router);
private cdr = inject(ChangeDetectorRef); private cdr = inject(ChangeDetectorRef);
readonly config = inject(StationConfigService).config;
username = ''; username = '';
password = ''; password = '';
+4 -4
View File
@@ -1,12 +1,12 @@
<nav class="navbar" role="navigation" aria-label="Main navigation"> <nav class="navbar" role="navigation" aria-label="Main navigation">
<div class="container navbar-inner"> <div class="container navbar-inner">
<a routerLink="/" class="navbar-brand" routerLinkActive="active"> <a routerLink="/" class="navbar-brand" routerLinkActive="active">
<img src="svg/small-flower.svg" alt="" class="brand-flower" aria-hidden="true"> <img [src]="config().logo_url" alt="" class="brand-flower" aria-hidden="true">
<span class="brand-name"> <span class="brand-name">
<span class="brand-mountain">K</span>MOUNTAIN <span class="brand-mountain">{{ config().name_primary | slice:0:1 }}</span>{{ config().name_primary | uppercase }}
<span class="brand-accent">FLOWER</span> <span class="brand-accent">{{ config().name_secondary | uppercase }}</span>
</span> </span>
<span class="brand-tagline">Community Radio — Est. 1987</span> <span class="brand-tagline">{{ config().tagline }}</span>
</a> </a>
<button class="navbar-toggle" aria-label="Toggle menu" (click)="toggleMenu()"> <button class="navbar-toggle" aria-label="Toggle menu" (click)="toggleMenu()">
+7 -3
View File
@@ -1,14 +1,15 @@
import { Component, DestroyRef, inject, OnInit } from '@angular/core'; import { Component, DestroyRef, inject, computed, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule, SlicePipe, UpperCasePipe } from '@angular/common';
import { RouterLink, RouterLinkActive, Router } from '@angular/router'; import { RouterLink, RouterLinkActive, Router } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { AuthService } from '../services/auth.service'; import { AuthService } from '../services/auth.service';
import { StationConfigService } from '../services/station-config.service';
@Component({ @Component({
selector: 'app-navbar', selector: 'app-navbar',
standalone: true, standalone: true,
imports: [CommonModule, RouterLink, RouterLinkActive], imports: [CommonModule, RouterLink, RouterLinkActive, SlicePipe, UpperCasePipe],
templateUrl: './navbar.component.html', templateUrl: './navbar.component.html',
styleUrl: './navbar.component.scss', styleUrl: './navbar.component.scss',
}) })
@@ -16,6 +17,9 @@ export class NavbarComponent implements OnInit {
private auth = inject(AuthService); private auth = inject(AuthService);
private router = inject(Router); private router = inject(Router);
private destroyRef = inject(DestroyRef); private destroyRef = inject(DestroyRef);
private stationConfigService = inject(StationConfigService);
readonly config = computed(() => this.stationConfigService.config());
menuOpen = false; menuOpen = false;
+2 -2
View File
@@ -1,11 +1,11 @@
<section class="schedule"> <section class="schedule">
<div class="container"> <div class="container">
<div class="schedule-header"> <div class="schedule-header">
<img src="svg/small-flower.svg" alt="" class="schedule-flower" aria-hidden="true"> <img [src]="config().logo_url" alt="" class="schedule-flower" aria-hidden="true">
<h2>Program <span class="text-accent">Schedule</span></h2> <h2>Program <span class="text-accent">Schedule</span></h2>
<p class="section-intro"> <p class="section-intro">
All times are local (Mountain Time). Every show is free to listen to All times are local (Mountain Time). Every show is free to listen to
live at 98.7 FM or via our online stream. live at {{ config().frequency }} or via our online stream.
</p> </p>
</div> </div>
+3 -1
View File
@@ -4,6 +4,7 @@ import { map, Observable, startWith } from 'rxjs';
import { ProgramService } from '../services/program.service'; import { ProgramService } from '../services/program.service';
import { Program } from '../interfaces/program'; import { Program } from '../interfaces/program';
import { StationConfigService } from '../services/station-config.service';
interface DayGroup { interface DayGroup {
day_label: string; day_label: string;
@@ -36,12 +37,13 @@ function groupByDay(programs: Program[]): DayGroup[] {
@Component({ @Component({
selector: 'app-schedule', selector: 'app-schedule',
standalone: true, standalone: true,
imports: [AsyncPipe, NgFor], imports: [AsyncPipe],
templateUrl: './schedule.component.html', templateUrl: './schedule.component.html',
styleUrl: './schedule.component.scss', styleUrl: './schedule.component.scss',
}) })
export class ScheduleComponent { export class ScheduleComponent {
private programService = inject(ProgramService); private programService = inject(ProgramService);
readonly config = inject(StationConfigService).config;
/** Async pipe drives the template — guarantees change detection on every emission. */ /** Async pipe drives the template — guarantees change detection on every emission. */
readonly schedule$: Observable<ScheduleState> = readonly schedule$: Observable<ScheduleState> =
@@ -0,0 +1,67 @@
import { Injectable, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { StationConfig } from '../interfaces/station-config';
import { getAppConfig } from './app-config.service';
/** Hardcoded defaults — used when the API is unavailable (dev fallback). */
const DEFAULT_CONFIG: StationConfig = {
id: 0,
key: 'default',
callsign: 'KMTN',
name_primary: 'KMountain',
name_secondary: 'Flower Radio',
tagline: 'Community Radio — Est. 1987',
frequency: '98.7 FM',
founded_year: 1987,
nonprofit_type: '501(c)(3)',
ein: '84-XXXXXXX',
logo_url: 'svg/small-flower.svg',
hero_background_url: 'svg/mountain-landscape.svg',
hero_icon_url: 'svg/orange-flower.svg',
hero_divider_url: 'svg/mountain-divider.svg',
address: '42 Pine Street\nMountain View, CO 80424',
phone: '(970) 555-0198',
email: 'hello@kmountainflower.org',
website: 'kmountainflower.org',
};
@Injectable({
providedIn: 'root',
})
export class StationConfigService {
private http = inject(HttpClient);
private baseUrl = `${getAppConfig().apiBaseUrl}/api/station-config`;
readonly config = signal<StationConfig>(DEFAULT_CONFIG);
/** Load station config from the API. Falls back to defaults on error. */
async load(): Promise<void> {
try {
const data = await firstValueFrom(this.http.get<StationConfig>(this.baseUrl));
this.config.set(data);
} catch {
// Keep the default config if the API is unavailable.
console.warn('StationConfigService: Failed to load config, using defaults.');
}
}
/** Update station config (admin only). */
async updateConfig(payload: Partial<StationConfig>): Promise<void> {
const data = await firstValueFrom(
this.http.put<StationConfig>(this.baseUrl, payload),
);
this.config.set(data);
}
/** Upload an image file for station branding (admin only). Returns the relative URL. */
async uploadImage(file: File): Promise<string> {
const formData = new FormData();
formData.append('file', file);
const response = await firstValueFrom(
this.http.post<{ url: string }>(`${this.baseUrl}/upload`, formData),
);
return response.url;
}
}