diff --git a/.claude/plans/station-config-plan.md b/.claude/plans/station-config-plan.md new file mode 100644 index 0000000..9721662 --- /dev/null +++ b/.claude/plans/station-config-plan.md @@ -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` 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/."}`. 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(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)` — 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) { }`. + +--- + +## 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 `
`), 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 diff --git a/backend/__pycache__/seed.cpython-312.pyc b/backend/__pycache__/seed.cpython-312.pyc new file mode 100644 index 0000000..1c25c33 Binary files /dev/null and b/backend/__pycache__/seed.cpython-312.pyc differ diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc index dab4c2e..3ee04a6 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 e304f65..026edc8 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 8272a8b..c9e4a75 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/api/__pycache__/admin.cpython-312.pyc b/backend/app/api/__pycache__/admin.cpython-312.pyc index 79edb2a..1c12518 100644 Binary files a/backend/app/api/__pycache__/admin.cpython-312.pyc and b/backend/app/api/__pycache__/admin.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 new file mode 100644 index 0000000..7a1a2c6 Binary files /dev/null and b/backend/app/api/__pycache__/station_config.cpython-312.pyc differ diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 1d2c659..7ed693c 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -2,7 +2,7 @@ from fastapi import APIRouter from app.config import settings from app.database import get_session -from app.models import DonationTier, Event, Program +from app.models import DonationTier, Event, Program, StationConfig router = APIRouter() @@ -20,6 +20,7 @@ async def reset_database(): await session.execute(Program.__table__.delete()) await session.execute(Event.__table__.delete()) await session.execute(DonationTier.__table__.delete()) + await session.execute(StationConfig.__table__.delete()) await session.commit() await run_seed() diff --git a/backend/app/api/station_config.py b/backend/app/api/station_config.py new file mode 100644 index 0000000..1771e65 --- /dev/null +++ b/backend/app/api/station_config.py @@ -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}"} diff --git a/backend/app/main.py b/backend/app/main.py index aeb1eee..4dc0a10 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,14 +1,31 @@ +import os from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles from sqlalchemy import func, select from app.config import settings 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.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 @@ -25,6 +42,11 @@ async def lifespan(app: FastAPI): print(" → Database is empty — running seed...") from seed import seed as 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 if settings.ADMIN_USERNAME and settings.ADMIN_PASSWORD: @@ -46,6 +68,10 @@ async def lifespan(app: FastAPI): await session.commit() 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 @@ -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(events.router, prefix="/api/events", tags=["events"]) 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) if settings.ENV != "production": diff --git a/backend/app/models.py b/backend/app/models.py index 6defaa3..7b93e09 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -49,3 +49,34 @@ class DonationTier(Base): icon = Column(String(10), nullable=False) benefits = Column(JSON, 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") diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 4d2044e..68ea471 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -112,3 +112,47 @@ class TierResponse(BaseModel): display_order: int 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 diff --git a/backend/seed.py b/backend/seed.py index 9c094ce..ab2f416 100644 --- a/backend/seed.py +++ b/backend/seed.py @@ -14,7 +14,7 @@ from datetime import date from sqlalchemy import select from app.database import async_session -from app.models import Program, Event, DonationTier +from app.models import Program, Event, DonationTier, StationConfig # ── 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 ──────────────────────────────────────────────── @@ -141,6 +161,19 @@ async def _upsert_tier(session, data: dict) -> None: 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 ────────────────────────────────────────────────── async def seed() -> None: @@ -161,6 +194,10 @@ async def seed() -> None: await session.commit() 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: """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(Event.__table__.delete()) await session.execute(DonationTier.__table__.delete()) + await session.execute(StationConfig.__table__.delete()) await session.commit() print(" ✓ Truncated all tables") diff --git a/src/app/about/about.component.html b/src/app/about/about.component.html index 8bac987..eaa1add 100644 --- a/src/app/about/about.component.html +++ b/src/app/about/about.component.html @@ -1,10 +1,10 @@
- -

About KMountain Flower

+ +

About {{ config().name_primary }} {{ config().name_secondary }}

- 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 music, local news, and the stories that bind mountain towns together.

@@ -33,7 +33,7 @@
📡

Our Reach

- 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 — and our welcome — goes everywhere.

@@ -51,11 +51,11 @@

Become a Member

- 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 knowing they helped make this possible.

diff --git a/src/app/about/about.component.ts b/src/app/about/about.component.ts index 7c59ee5..bd74414 100644 --- a/src/app/about/about.component.ts +++ b/src/app/about/about.component.ts @@ -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({ selector: 'app-about', standalone: true, - imports: [], + imports: [RouterLink], templateUrl: './about.component.html', styleUrl: './about.component.scss', }) -export class AboutComponent {} +export class AboutComponent { + readonly config = inject(StationConfigService).config; +} diff --git a/src/app/admin/admin-station-form.component.html b/src/app/admin/admin-station-form.component.html new file mode 100644 index 0000000..ebc7c9f --- /dev/null +++ b/src/app/admin/admin-station-form.component.html @@ -0,0 +1,141 @@ +
+
+
+

Edit Station Config

+ +
+ + @if (error) { + + } + @if (success) { + + } + +
+ + +
+

Identity

+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+

Images

+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ + +
+

Nonprofit

+
+
+ + +
+
+ + +
+
+
+ + +
+

Contact

+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+ + +
+
+
+
diff --git a/src/app/admin/admin-station-form.component.scss b/src/app/admin/admin-station-form.component.scss new file mode 100644 index 0000000..fedc857 --- /dev/null +++ b/src/app/admin/admin-station-form.component.scss @@ -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; + } +} diff --git a/src/app/admin/admin-station-form.component.ts b/src/app/admin/admin-station-form.component.ts new file mode 100644 index 0000000..e678a2f --- /dev/null +++ b/src/app/admin/admin-station-form.component.ts @@ -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(); + @Output() closed = new EventEmitter(); + + 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 { + 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 { + 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 = { + 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; + } + } +} diff --git a/src/app/admin/admin.component.html b/src/app/admin/admin.component.html index 464d63f..61dc63e 100644 --- a/src/app/admin/admin.component.html +++ b/src/app/admin/admin.component.html @@ -22,6 +22,11 @@ [class.active]="activeTab() === 'tiers'" (click)="activeTab.set('tiers')" >Tiers +
@@ -145,6 +150,56 @@
} } + + @if (activeTab() === 'station') { + @if (stationConfig$ | async; as config) { +
+
+

Station Configuration

+ +
+ +
+
+ Callsign + {{ config.callsign }} +
+
+ Name + {{ config.name_primary }} {{ config.name_secondary }} +
+
+ Tagline + {{ config.tagline }} +
+
+ Frequency + {{ config.frequency }} +
+
+ Founded + {{ config.founded_year }} +
+
+ Nonprofit + {{ config.nonprofit_type }} — EIN {{ config.ein }} +
+
+ Email + {{ config.email }} +
+
+ Phone + {{ config.phone }} +
+
+ Website + {{ config.website }} +
+
+
+ } + }
@@ -157,4 +212,7 @@ @if (showTierForm) { } + @if (showStationForm) { + + }
diff --git a/src/app/admin/admin.component.scss b/src/app/admin/admin.component.scss index 229c900..77a45d2 100644 --- a/src/app/admin/admin.component.scss +++ b/src/app/admin/admin.component.scss @@ -157,6 +157,33 @@ 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 ─────────────────────────────────────────── @include responsive(md) { diff --git a/src/app/admin/admin.component.ts b/src/app/admin/admin.component.ts index 1225f8c..18efcd0 100644 --- a/src/app/admin/admin.component.ts +++ b/src/app/admin/admin.component.ts @@ -6,14 +6,17 @@ import { BehaviorSubject, firstValueFrom } from 'rxjs'; import { Program } from '../interfaces/program'; import { Event } from '../interfaces/event'; import { Tier } from '../interfaces/tier'; +import { StationConfig } from '../interfaces/station-config'; import { ProgramService } from '../services/program.service'; import { EventService } from '../services/event.service'; import { TierService } from '../services/tier.service'; +import { StationConfigService } from '../services/station-config.service'; import { AdminProgramFormComponent } from './admin-program-form.component'; import { AdminEventFormComponent } from './admin-event-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({ selector: 'app-admin', @@ -25,6 +28,7 @@ type TabKey = 'programs' | 'events' | 'tiers'; AdminProgramFormComponent, AdminEventFormComponent, AdminTierFormComponent, + AdminStationFormComponent, ], templateUrl: './admin.component.html', styleUrl: './admin.component.scss', @@ -33,6 +37,7 @@ export class AdminComponent implements OnInit { private programService = inject(ProgramService); private eventService = inject(EventService); private tierService = inject(TierService); + private stationConfigService = inject(StationConfigService); activeTab = signal('programs'); @@ -45,10 +50,12 @@ export class AdminComponent implements OnInit { readonly programs$ = new BehaviorSubject([]); readonly events$ = new BehaviorSubject([]); readonly tiers$ = new BehaviorSubject([]); + readonly stationConfig$ = new BehaviorSubject(null); showProgramForm = false; showEventForm = false; showTierForm = false; + showStationForm = false; ngOnInit(): void { this.loadAll(); @@ -69,11 +76,17 @@ export class AdminComponent implements OnInit { this.tiers$.next(data); } + async reloadStationConfig(): Promise { + await this.stationConfigService.load(); + this.stationConfig$.next(this.stationConfigService.config()); + } + async loadAll(): Promise { await Promise.all([ this.reloadPrograms(), this.reloadEvents(), this.reloadTiers(), + this.reloadStationConfig(), ]); } @@ -163,4 +176,19 @@ export class AdminComponent implements OnInit { await firstValueFrom(this.tierService.deleteTier(id)); await this.reloadTiers(); } + + // ── Station Config ────────────────────────────────────── + + openStationForm(): void { + this.showStationForm = true; + } + + onStationSaved(): void { + this.reloadStationConfig(); + this.showStationForm = false; + } + + onStationClosed(): void { + this.showStationForm = false; + } } diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 4f19d13..a59b476 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -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 { provideRouter } from '@angular/router'; @@ -8,6 +8,16 @@ import { appConfigInitProvider, appConfigProvider, } 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 = { providers: [ @@ -16,5 +26,6 @@ export const appConfig: ApplicationConfig = { provideHttpClient(withInterceptors([authInterceptor])), appConfigProvider, appConfigInitProvider, + stationConfigInitProvider, ], }; diff --git a/src/app/contact/contact.component.html b/src/app/contact/contact.component.html index f2660e2..ae1a6ed 100644 --- a/src/app/contact/contact.component.html +++ b/src/app/contact/contact.component.html @@ -45,19 +45,19 @@

Studio Location

-

42 Pine Street
Mountain View, CO 80424

+

Phone

-

(970) 555-0198
Mon–Fri 8 AM – 6 MT

+

{{ config().phone }}
Mon–Fri 8 AM – 6 MT

Frequencies

-

98.7 FM — Mountain Region
Stream: kmountainflower.org/live

+

{{ config().frequency }} — Mountain Region
Stream: {{ config().website }}/live

diff --git a/src/app/contact/contact.component.ts b/src/app/contact/contact.component.ts index 8a2e14e..230b036 100644 --- a/src/app/contact/contact.component.ts +++ b/src/app/contact/contact.component.ts @@ -1,6 +1,8 @@ -import { Component } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { StationConfigService } from '../services/station-config.service'; + @Component({ selector: 'app-contact', standalone: true, @@ -9,6 +11,8 @@ import { FormsModule } from '@angular/forms'; styleUrl: './contact.component.scss', }) export class ContactComponent { + readonly config = inject(StationConfigService).config; + onSubmit(): void { alert('Thank you for your message! We will get back to you shortly.'); } diff --git a/src/app/donate/donate.component.html b/src/app/donate/donate.component.html index 5248a92..76f28a9 100644 --- a/src/app/donate/donate.component.html +++ b/src/app/donate/donate.component.html @@ -3,10 +3,10 @@