working basic site admin
This commit is contained in:
@@ -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.
Binary file not shown.
@@ -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()
|
||||
|
||||
@@ -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
@@ -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":
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
+39
-1
@@ -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")
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<section class="about">
|
||||
<div class="container">
|
||||
<div class="about-intro">
|
||||
<img src="svg/orange-flower.svg" alt="" class="about-flower-decor" aria-hidden="true">
|
||||
<h2>About <span class="text-accent">KMountain Flower</span></h2>
|
||||
<img [src]="config().hero_icon_url" alt="" class="about-flower-decor" aria-hidden="true">
|
||||
<h2>About <span class="text-accent">{{ config().name_primary }} {{ config().name_secondary }}</span></h2>
|
||||
<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
|
||||
music, local news, and the stories that bind mountain towns together.
|
||||
</p>
|
||||
@@ -33,7 +33,7 @@
|
||||
<div class="card-icon">📡</div>
|
||||
<h3>Our Reach</h3>
|
||||
<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
|
||||
— and our welcome — goes everywhere.
|
||||
</p>
|
||||
@@ -51,11 +51,11 @@
|
||||
|
||||
<div class="about-cta">
|
||||
<div class="about-flower-divider" aria-hidden="true">
|
||||
<img src="svg/small-flower.svg" alt="">
|
||||
<img [src]="config().logo_url" alt="">
|
||||
</div>
|
||||
<h3>Become a Member</h3>
|
||||
<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
|
||||
knowing they helped make this possible.
|
||||
</p>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()">×</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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,11 @@
|
||||
[class.active]="activeTab() === 'tiers'"
|
||||
(click)="activeTab.set('tiers')"
|
||||
>Tiers</button>
|
||||
<button
|
||||
class="tab-btn"
|
||||
[class.active]="activeTab() === 'station'"
|
||||
(click)="activeTab.set('station')"
|
||||
>Station</button>
|
||||
</div>
|
||||
|
||||
<!-- Programs Tab -->
|
||||
@@ -145,6 +150,56 @@
|
||||
</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>
|
||||
|
||||
<!-- Form modals -->
|
||||
@@ -157,4 +212,7 @@
|
||||
@if (showTierForm) {
|
||||
<app-admin-tier-form [editItem]="editingTier" (saved)="onTierSaved()" (closed)="onTierClosed()" />
|
||||
}
|
||||
@if (showStationForm) {
|
||||
<app-admin-station-form [editItem]="stationConfig$ | async" (saved)="onStationSaved()" (closed)="onStationClosed()" />
|
||||
}
|
||||
</section>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<TabKey>('programs');
|
||||
|
||||
@@ -45,10 +50,12 @@ export class AdminComponent implements OnInit {
|
||||
readonly programs$ = new BehaviorSubject<Program[]>([]);
|
||||
readonly events$ = new BehaviorSubject<Event[]>([]);
|
||||
readonly tiers$ = new BehaviorSubject<Tier[]>([]);
|
||||
readonly stationConfig$ = new BehaviorSubject<StationConfig | null>(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<void> {
|
||||
await this.stationConfigService.load();
|
||||
this.stationConfig$.next(this.stationConfigService.config());
|
||||
}
|
||||
|
||||
async loadAll(): Promise<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -45,19 +45,19 @@
|
||||
<div class="contact-info">
|
||||
<div class="info-card">
|
||||
<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 class="info-card">
|
||||
<h4><span aria-hidden="true">📞</span> Phone</h4>
|
||||
<p>(970) 555-0198<br>Mon–Fri 8 AM – 6 MT</p>
|
||||
<p>{{ config().phone }}<br>Mon–Fri 8 AM – 6 MT</p>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<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 class="info-card">
|
||||
<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>
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
<div class="donate-banner">
|
||||
<div class="donate-banner-overlay">
|
||||
<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>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
@@ -42,12 +42,12 @@
|
||||
<!-- One-time donation -->
|
||||
<div class="donate-one-time">
|
||||
<div class="donate-flower-divider" aria-hidden="true">
|
||||
<img src="svg/small-flower.svg" alt="">
|
||||
<img [src]="config().logo_url" alt="">
|
||||
</div>
|
||||
<h3>Or Make a One-Time Gift</h3>
|
||||
<p class="section-intro">
|
||||
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>
|
||||
<a href="#" class="btn btn-lg">Give Any Amount</a>
|
||||
</div>
|
||||
@@ -57,7 +57,7 @@
|
||||
<div class="why-grid">
|
||||
<div class="why-item">
|
||||
<span class="why-icon">📻</span>
|
||||
<strong>98.7 FM Signal</strong>
|
||||
<strong>{{ config().frequency }} Signal</strong>
|
||||
<p>Covering 3 mountain counties and the valleys between</p>
|
||||
</div>
|
||||
<div class="why-item">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { map, Observable, startWith } from 'rxjs';
|
||||
|
||||
import { TierService } from '../services/tier.service';
|
||||
import { Tier } from '../interfaces/tier';
|
||||
import { StationConfigService } from '../services/station-config.service';
|
||||
|
||||
interface TiersState {
|
||||
loading: boolean;
|
||||
@@ -19,6 +20,7 @@ interface TiersState {
|
||||
})
|
||||
export class DonateComponent {
|
||||
private tierService = inject(TierService);
|
||||
readonly config = inject(StationConfigService).config;
|
||||
|
||||
/** Async pipe drives the template — guarantees change detection on every emission. */
|
||||
readonly tiers$: Observable<TiersState> =
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
<div class="container">
|
||||
<div class="footer-top">
|
||||
<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>
|
||||
<span class="brand-m">K</span>MOUNTAIN
|
||||
<span class="brand-a">FLOWER</span> Radio
|
||||
<span class="brand-m">{{ config().name_primary | slice:0:1 }}</span>{{ config().name_primary | slice:1 }}
|
||||
<span class="brand-a">{{ config().name_secondary }}</span> Radio
|
||||
</h3>
|
||||
<p class="footer-tagline">
|
||||
Community-powered nonprofit public radio — free to stream, funded by you.
|
||||
</p>
|
||||
<p class="footer-legal">
|
||||
KMountain Flower Radio is a 501(c)(3) nonprofit organization.<br>
|
||||
EIN: 84-XXXXXXX. All donations are tax-deductible.
|
||||
{{ config().name_primary }} {{ config().name_secondary }} Radio is a {{ config().nonprofit_type }} nonprofit organization.<br>
|
||||
EIN: {{ config().ein }}. All donations are tax-deductible.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
</div>
|
||||
|
||||
<div class="footer-bottom">
|
||||
<p>© 1987–2026 KMountain Flower Radio. All rights reserved.</p>
|
||||
<p>© {{ config().founded_year }}–{{ currentYear() }} {{ config().name_primary }} {{ config().name_secondary }} Radio. All rights reserved.</p>
|
||||
<p class="footer-made">
|
||||
Made with 🧡 in the mountains
|
||||
</p>
|
||||
|
||||
@@ -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({
|
||||
selector: 'app-footer',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
imports: [RouterLink, SlicePipe],
|
||||
templateUrl: './footer.component.html',
|
||||
styleUrl: './footer.component.scss',
|
||||
})
|
||||
export class FooterComponent {}
|
||||
export class FooterComponent {
|
||||
readonly config = inject(StationConfigService).config;
|
||||
readonly currentYear = computed(() => new Date().getFullYear());
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<section class="hero">
|
||||
<!-- Mountain background layer -->
|
||||
<div class="hero-mountains" aria-hidden="true">
|
||||
<img src="svg/mountain-landscape.svg" alt="">
|
||||
<img [src]="config().hero_background_url" alt="">
|
||||
</div>
|
||||
|
||||
<!-- Gradient overlay -->
|
||||
@@ -11,18 +11,18 @@
|
||||
<div class="hero-content container">
|
||||
<div class="hero-inner">
|
||||
<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>
|
||||
<h1 class="hero-title">
|
||||
<span class="hero-line1">KMountain</span>
|
||||
<span class="hero-line2 text-accent">Flower Radio</span>
|
||||
<span class="hero-line1">{{ config().name_primary }}</span>
|
||||
<span class="hero-line2 text-accent">{{ config().name_secondary }}</span>
|
||||
</h1>
|
||||
<p class="hero-subtitle">
|
||||
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 class="hero-subtitle hero-tagline">
|
||||
Free to stream. Funded by you. Always on-air.
|
||||
{{ config().tagline }}
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<a routerLink="/donate" class="btn btn-hero-primary">Support the Station</a>
|
||||
@@ -37,6 +37,6 @@
|
||||
|
||||
<!-- Mountain divider at bottom -->
|
||||
<div class="hero-divider" aria-hidden="true">
|
||||
<img src="svg/mountain-divider.svg" alt="">
|
||||
<img [src]="config().hero_divider_url" alt="">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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-hero',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
imports: [RouterLink],
|
||||
templateUrl: './hero.component.html',
|
||||
styleUrl: './hero.component.scss',
|
||||
})
|
||||
export class HeroComponent {}
|
||||
export class HeroComponent {
|
||||
readonly config = inject(StationConfigService).config;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
<section class="login-section">
|
||||
<div class="login-card">
|
||||
<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>
|
||||
<p>Admin access for KMountain Flower Radio</p>
|
||||
<p>Admin access for {{ config().name_primary }} {{ config().name_secondary }} Radio</p>
|
||||
</div>
|
||||
|
||||
<form (ngSubmit)="onSubmit()" class="login-form">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Router } from '@angular/router';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { StationConfigService } from '../services/station-config.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
@@ -17,6 +18,7 @@ export class LoginComponent {
|
||||
private auth = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
private cdr = inject(ChangeDetectorRef);
|
||||
readonly config = inject(StationConfigService).config;
|
||||
|
||||
username = '';
|
||||
password = '';
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<nav class="navbar" role="navigation" aria-label="Main navigation">
|
||||
<div class="container navbar-inner">
|
||||
<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-mountain">K</span>MOUNTAIN
|
||||
<span class="brand-accent">FLOWER</span>
|
||||
<span class="brand-mountain">{{ config().name_primary | slice:0:1 }}</span>{{ config().name_primary | uppercase }}
|
||||
<span class="brand-accent">{{ config().name_secondary | uppercase }}</span>
|
||||
</span>
|
||||
<span class="brand-tagline">Community Radio — Est. 1987</span>
|
||||
<span class="brand-tagline">{{ config().tagline }}</span>
|
||||
</a>
|
||||
|
||||
<button class="navbar-toggle" aria-label="Toggle menu" (click)="toggleMenu()">
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { Component, DestroyRef, inject, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, DestroyRef, inject, computed, OnInit } from '@angular/core';
|
||||
import { CommonModule, SlicePipe, UpperCasePipe } from '@angular/common';
|
||||
import { RouterLink, RouterLinkActive, Router } from '@angular/router';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { StationConfigService } from '../services/station-config.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-navbar',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, RouterLinkActive],
|
||||
imports: [CommonModule, RouterLink, RouterLinkActive, SlicePipe, UpperCasePipe],
|
||||
templateUrl: './navbar.component.html',
|
||||
styleUrl: './navbar.component.scss',
|
||||
})
|
||||
@@ -16,6 +17,9 @@ export class NavbarComponent implements OnInit {
|
||||
private auth = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
private stationConfigService = inject(StationConfigService);
|
||||
|
||||
readonly config = computed(() => this.stationConfigService.config());
|
||||
|
||||
menuOpen = false;
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<section class="schedule">
|
||||
<div class="container">
|
||||
<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>
|
||||
<p class="section-intro">
|
||||
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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { map, Observable, startWith } from 'rxjs';
|
||||
|
||||
import { ProgramService } from '../services/program.service';
|
||||
import { Program } from '../interfaces/program';
|
||||
import { StationConfigService } from '../services/station-config.service';
|
||||
|
||||
interface DayGroup {
|
||||
day_label: string;
|
||||
@@ -36,12 +37,13 @@ function groupByDay(programs: Program[]): DayGroup[] {
|
||||
@Component({
|
||||
selector: 'app-schedule',
|
||||
standalone: true,
|
||||
imports: [AsyncPipe, NgFor],
|
||||
imports: [AsyncPipe],
|
||||
templateUrl: './schedule.component.html',
|
||||
styleUrl: './schedule.component.scss',
|
||||
})
|
||||
export class ScheduleComponent {
|
||||
private programService = inject(ProgramService);
|
||||
readonly config = inject(StationConfigService).config;
|
||||
|
||||
/** Async pipe drives the template — guarantees change detection on every emission. */
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user