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.config import settings
|
||||||
from app.database import get_session
|
from app.database import get_session
|
||||||
from app.models import DonationTier, Event, Program
|
from app.models import DonationTier, Event, Program, StationConfig
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ async def reset_database():
|
|||||||
await session.execute(Program.__table__.delete())
|
await session.execute(Program.__table__.delete())
|
||||||
await session.execute(Event.__table__.delete())
|
await session.execute(Event.__table__.delete())
|
||||||
await session.execute(DonationTier.__table__.delete())
|
await session.execute(DonationTier.__table__.delete())
|
||||||
|
await session.execute(StationConfig.__table__.delete())
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
await run_seed()
|
await run_seed()
|
||||||
|
|||||||
@@ -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 contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import async_session, engine
|
from app.database import async_session, engine
|
||||||
from app.models import Base, Program
|
from app.models import Base, Program, StationConfig
|
||||||
from app.user_models import User
|
from app.user_models import User
|
||||||
from app.api import programs, events, tiers, auth
|
from app.api import programs, events, tiers, auth, station_config
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_station_config(session):
|
||||||
|
"""Ensure the singleton station config row exists (idempotent)."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(StationConfig).where(StationConfig.key == "default")
|
||||||
|
)
|
||||||
|
if result.scalar_one_or_none() is None:
|
||||||
|
print(" → Station config missing — seeding defaults...")
|
||||||
|
from seed import seed as run_seed
|
||||||
|
# Only seed the station config, not the full dataset
|
||||||
|
from seed import STATION_CONFIG, _upsert_station_config
|
||||||
|
await _upsert_station_config(session, STATION_CONFIG)
|
||||||
|
await session.commit()
|
||||||
|
print(" ✓ Station config seeded")
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -25,6 +42,11 @@ async def lifespan(app: FastAPI):
|
|||||||
print(" → Database is empty — running seed...")
|
print(" → Database is empty — running seed...")
|
||||||
from seed import seed as run_seed
|
from seed import seed as run_seed
|
||||||
await run_seed()
|
await run_seed()
|
||||||
|
return # Full seed already included station config
|
||||||
|
|
||||||
|
# Ensure station config exists (runs even if DB already had data)
|
||||||
|
async with async_session() as session:
|
||||||
|
await _ensure_station_config(session)
|
||||||
|
|
||||||
# Bootstrap admin user if configured
|
# Bootstrap admin user if configured
|
||||||
if settings.ADMIN_USERNAME and settings.ADMIN_PASSWORD:
|
if settings.ADMIN_USERNAME and settings.ADMIN_PASSWORD:
|
||||||
@@ -46,6 +68,10 @@ async def lifespan(app: FastAPI):
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
print(f" → Bootstrap admin created: {settings.ADMIN_USERNAME}")
|
print(f" → Bootstrap admin created: {settings.ADMIN_USERNAME}")
|
||||||
|
|
||||||
|
# Ensure uploads directory exists
|
||||||
|
uploads_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "uploads")
|
||||||
|
os.makedirs(uploads_dir, exist_ok=True)
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
@@ -69,6 +95,13 @@ app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
|||||||
app.include_router(programs.router, prefix="/api/programs", tags=["programs"])
|
app.include_router(programs.router, prefix="/api/programs", tags=["programs"])
|
||||||
app.include_router(events.router, prefix="/api/events", tags=["events"])
|
app.include_router(events.router, prefix="/api/events", tags=["events"])
|
||||||
app.include_router(tiers.router, prefix="/api/tiers", tags=["tiers"])
|
app.include_router(tiers.router, prefix="/api/tiers", tags=["tiers"])
|
||||||
|
app.include_router(station_config.router, prefix="/api/station-config", tags=["station-config"])
|
||||||
|
|
||||||
|
# Serve uploaded files (dev only — Nginx handles this in production)
|
||||||
|
if settings.ENV != "production":
|
||||||
|
uploads_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "uploads")
|
||||||
|
if os.path.exists(uploads_dir):
|
||||||
|
app.mount("/uploads", StaticFiles(directory=uploads_dir), name="uploads")
|
||||||
|
|
||||||
# Dev-only admin router (disabled in production)
|
# Dev-only admin router (disabled in production)
|
||||||
if settings.ENV != "production":
|
if settings.ENV != "production":
|
||||||
|
|||||||
@@ -49,3 +49,34 @@ class DonationTier(Base):
|
|||||||
icon = Column(String(10), nullable=False)
|
icon = Column(String(10), nullable=False)
|
||||||
benefits = Column(JSON, nullable=False)
|
benefits = Column(JSON, nullable=False)
|
||||||
display_order = Column(Integer, nullable=False)
|
display_order = Column(Integer, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class StationConfig(Base):
|
||||||
|
__tablename__ = "station_config"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
key = Column(String(20), unique=True, nullable=False, default="default")
|
||||||
|
|
||||||
|
# Identity
|
||||||
|
callsign = Column(String(10), nullable=False, default="KMTN")
|
||||||
|
name_primary = Column(String(100), nullable=False, default="KMountain")
|
||||||
|
name_secondary = Column(String(100), nullable=False, default="Flower Radio")
|
||||||
|
tagline = Column(String(200), nullable=False, default="Community Radio — Est. 1987")
|
||||||
|
frequency = Column(String(10), nullable=False, default="98.7 FM")
|
||||||
|
founded_year = Column(Integer, nullable=False, default=1987)
|
||||||
|
|
||||||
|
# Nonprofit
|
||||||
|
nonprofit_type = Column(String(10), nullable=False, default="501(c)(3)")
|
||||||
|
ein = Column(String(20), nullable=False, default="84-XXXXXXX")
|
||||||
|
|
||||||
|
# Images (URL strings relative to app root)
|
||||||
|
logo_url = Column(String(500), nullable=False, default="svg/small-flower.svg")
|
||||||
|
hero_background_url = Column(String(500), nullable=False, default="svg/mountain-landscape.svg")
|
||||||
|
hero_icon_url = Column(String(500), nullable=False, default="svg/orange-flower.svg")
|
||||||
|
hero_divider_url = Column(String(500), nullable=False, default="svg/mountain-divider.svg")
|
||||||
|
|
||||||
|
# Contact
|
||||||
|
address = Column(Text, nullable=False, default="42 Pine Street\nMountain View, CO 80424")
|
||||||
|
phone = Column(String(30), nullable=False, default="(970) 555-0198")
|
||||||
|
email = Column(String(100), nullable=False, default="hello@kmountainflower.org")
|
||||||
|
website = Column(String(200), nullable=False, default="kmountainflower.org")
|
||||||
|
|||||||
@@ -112,3 +112,47 @@ class TierResponse(BaseModel):
|
|||||||
display_order: int
|
display_order: int
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── StationConfig ─────────────────────────────────────────
|
||||||
|
|
||||||
|
class StationConfigResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
key: str
|
||||||
|
callsign: str
|
||||||
|
name_primary: str
|
||||||
|
name_secondary: str
|
||||||
|
tagline: str
|
||||||
|
frequency: str
|
||||||
|
founded_year: int
|
||||||
|
nonprofit_type: str
|
||||||
|
ein: str
|
||||||
|
logo_url: str
|
||||||
|
hero_background_url: str
|
||||||
|
hero_icon_url: str
|
||||||
|
hero_divider_url: str
|
||||||
|
address: str
|
||||||
|
phone: str
|
||||||
|
email: str
|
||||||
|
website: str
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class StationConfigUpdate(BaseModel):
|
||||||
|
callsign: Optional[str] = None
|
||||||
|
name_primary: Optional[str] = None
|
||||||
|
name_secondary: Optional[str] = None
|
||||||
|
tagline: Optional[str] = None
|
||||||
|
frequency: Optional[str] = None
|
||||||
|
founded_year: Optional[int] = None
|
||||||
|
nonprofit_type: Optional[str] = None
|
||||||
|
ein: Optional[str] = None
|
||||||
|
logo_url: Optional[str] = None
|
||||||
|
hero_background_url: Optional[str] = None
|
||||||
|
hero_icon_url: Optional[str] = None
|
||||||
|
hero_divider_url: Optional[str] = None
|
||||||
|
address: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
website: Optional[str] = None
|
||||||
|
|||||||
+39
-1
@@ -14,7 +14,7 @@ from datetime import date
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.database import async_session
|
from app.database import async_session
|
||||||
from app.models import Program, Event, DonationTier
|
from app.models import Program, Event, DonationTier, StationConfig
|
||||||
|
|
||||||
# ── Seed data ──────────────────────────────────────────────
|
# ── Seed data ──────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -93,6 +93,26 @@ TIERS: list[dict] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
STATION_CONFIG: dict = {
|
||||||
|
"key": "default",
|
||||||
|
"callsign": "KMTN",
|
||||||
|
"name_primary": "KMountain",
|
||||||
|
"name_secondary": "Flower Radio",
|
||||||
|
"tagline": "Community Radio — Est. 1987",
|
||||||
|
"frequency": "98.7 FM",
|
||||||
|
"founded_year": 1987,
|
||||||
|
"nonprofit_type": "501(c)(3)",
|
||||||
|
"ein": "84-XXXXXXX",
|
||||||
|
"logo_url": "svg/small-flower.svg",
|
||||||
|
"hero_background_url": "svg/mountain-landscape.svg",
|
||||||
|
"hero_icon_url": "svg/orange-flower.svg",
|
||||||
|
"hero_divider_url": "svg/mountain-divider.svg",
|
||||||
|
"address": "42 Pine Street\nMountain View, CO 80424",
|
||||||
|
"phone": "(970) 555-0198",
|
||||||
|
"email": "hello@kmountainflower.org",
|
||||||
|
"website": "kmountainflower.org",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── Helpers ────────────────────────────────────────────────
|
# ── Helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -141,6 +161,19 @@ async def _upsert_tier(session, data: dict) -> None:
|
|||||||
session.add(DonationTier(**data))
|
session.add(DonationTier(**data))
|
||||||
|
|
||||||
|
|
||||||
|
async def _upsert_station_config(session, data: dict) -> None:
|
||||||
|
"""Get-or-create the singleton station config (key='default')."""
|
||||||
|
existing = await session.execute(
|
||||||
|
select(StationConfig).where(StationConfig.key == "default")
|
||||||
|
)
|
||||||
|
config = existing.scalar_one_or_none()
|
||||||
|
if config:
|
||||||
|
for key, value in data.items():
|
||||||
|
setattr(config, key, value)
|
||||||
|
else:
|
||||||
|
session.add(StationConfig(**data))
|
||||||
|
|
||||||
|
|
||||||
# ── Main ──────────────────────────────────────────────────
|
# ── Main ──────────────────────────────────────────────────
|
||||||
|
|
||||||
async def seed() -> None:
|
async def seed() -> None:
|
||||||
@@ -161,6 +194,10 @@ async def seed() -> None:
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
print(f" ✓ Seeded {len(TIERS)} donation tiers")
|
print(f" ✓ Seeded {len(TIERS)} donation tiers")
|
||||||
|
|
||||||
|
await _upsert_station_config(session, STATION_CONFIG)
|
||||||
|
await session.commit()
|
||||||
|
print(" ✓ Seeded station config")
|
||||||
|
|
||||||
|
|
||||||
async def truncate_all() -> None:
|
async def truncate_all() -> None:
|
||||||
"""Remove all seed data from the database."""
|
"""Remove all seed data from the database."""
|
||||||
@@ -168,6 +205,7 @@ async def truncate_all() -> None:
|
|||||||
await session.execute(Program.__table__.delete())
|
await session.execute(Program.__table__.delete())
|
||||||
await session.execute(Event.__table__.delete())
|
await session.execute(Event.__table__.delete())
|
||||||
await session.execute(DonationTier.__table__.delete())
|
await session.execute(DonationTier.__table__.delete())
|
||||||
|
await session.execute(StationConfig.__table__.delete())
|
||||||
await session.commit()
|
await session.commit()
|
||||||
print(" ✓ Truncated all tables")
|
print(" ✓ Truncated all tables")
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<section class="about">
|
<section class="about">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="about-intro">
|
<div class="about-intro">
|
||||||
<img src="svg/orange-flower.svg" alt="" class="about-flower-decor" aria-hidden="true">
|
<img [src]="config().hero_icon_url" alt="" class="about-flower-decor" aria-hidden="true">
|
||||||
<h2>About <span class="text-accent">KMountain Flower</span></h2>
|
<h2>About <span class="text-accent">{{ config().name_primary }} {{ config().name_secondary }}</span></h2>
|
||||||
<p class="section-intro">
|
<p class="section-intro">
|
||||||
For nearly four decades, KMountain Flower has been the voice of our
|
For nearly four decades, {{ config().name_primary }} {{ config().name_secondary }} has been the voice of our
|
||||||
community — a nonprofit, listener-supported radio station dedicated to
|
community — a nonprofit, listener-supported radio station dedicated to
|
||||||
music, local news, and the stories that bind mountain towns together.
|
music, local news, and the stories that bind mountain towns together.
|
||||||
</p>
|
</p>
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
<div class="card-icon">📡</div>
|
<div class="card-icon">📡</div>
|
||||||
<h3>Our Reach</h3>
|
<h3>Our Reach</h3>
|
||||||
<p>
|
<p>
|
||||||
Over the air on 98.7 FM and streaming live worldwide. Whether you're
|
Over the air on {{ config().frequency }} and streaming live worldwide. Whether you're
|
||||||
hiking the ridgeline or on the other side of the planet, our signal
|
hiking the ridgeline or on the other side of the planet, our signal
|
||||||
— and our welcome — goes everywhere.
|
— and our welcome — goes everywhere.
|
||||||
</p>
|
</p>
|
||||||
@@ -51,11 +51,11 @@
|
|||||||
|
|
||||||
<div class="about-cta">
|
<div class="about-cta">
|
||||||
<div class="about-flower-divider" aria-hidden="true">
|
<div class="about-flower-divider" aria-hidden="true">
|
||||||
<img src="svg/small-flower.svg" alt="">
|
<img [src]="config().logo_url" alt="">
|
||||||
</div>
|
</div>
|
||||||
<h3>Become a Member</h3>
|
<h3>Become a Member</h3>
|
||||||
<p class="section-intro">
|
<p class="section-intro">
|
||||||
As a 501(c)(3) nonprofit, every donation keeps our airwaves free and our
|
As a {{ config().nonprofit_type }} nonprofit, every donation keeps our airwaves free and our
|
||||||
signal strong. Members receive exclusive perks and the satisfaction of
|
signal strong. Members receive exclusive perks and the satisfaction of
|
||||||
knowing they helped make this possible.
|
knowing they helped make this possible.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
|
||||||
|
import { StationConfigService } from '../services/station-config.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-about',
|
selector: 'app-about',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [],
|
imports: [RouterLink],
|
||||||
templateUrl: './about.component.html',
|
templateUrl: './about.component.html',
|
||||||
styleUrl: './about.component.scss',
|
styleUrl: './about.component.scss',
|
||||||
})
|
})
|
||||||
export class AboutComponent {}
|
export class AboutComponent {
|
||||||
|
readonly config = inject(StationConfigService).config;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<div class="form-overlay" (click)="onClose()">
|
||||||
|
<div class="form-dialog form-dialog--wide" (click)="$event.stopPropagation()">
|
||||||
|
<div class="form-header">
|
||||||
|
<h2>Edit Station Config</h2>
|
||||||
|
<button type="button" class="btn-close" (click)="onClose()">×</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'"
|
[class.active]="activeTab() === 'tiers'"
|
||||||
(click)="activeTab.set('tiers')"
|
(click)="activeTab.set('tiers')"
|
||||||
>Tiers</button>
|
>Tiers</button>
|
||||||
|
<button
|
||||||
|
class="tab-btn"
|
||||||
|
[class.active]="activeTab() === 'station'"
|
||||||
|
(click)="activeTab.set('station')"
|
||||||
|
>Station</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Programs Tab -->
|
<!-- Programs Tab -->
|
||||||
@@ -145,6 +150,56 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
<!-- Station Tab -->
|
||||||
|
@if (activeTab() === 'station') {
|
||||||
|
@if (stationConfig$ | async; as config) {
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="tab-toolbar">
|
||||||
|
<h2>Station Configuration</h2>
|
||||||
|
<button class="btn btn-add" (click)="openStationForm()">Edit Config</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="station-config-view">
|
||||||
|
<div class="config-row">
|
||||||
|
<span class="config-label">Callsign</span>
|
||||||
|
<span class="config-value">{{ config.callsign }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="config-row">
|
||||||
|
<span class="config-label">Name</span>
|
||||||
|
<span class="config-value">{{ config.name_primary }} {{ config.name_secondary }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="config-row">
|
||||||
|
<span class="config-label">Tagline</span>
|
||||||
|
<span class="config-value">{{ config.tagline }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="config-row">
|
||||||
|
<span class="config-label">Frequency</span>
|
||||||
|
<span class="config-value">{{ config.frequency }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="config-row">
|
||||||
|
<span class="config-label">Founded</span>
|
||||||
|
<span class="config-value">{{ config.founded_year }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="config-row">
|
||||||
|
<span class="config-label">Nonprofit</span>
|
||||||
|
<span class="config-value">{{ config.nonprofit_type }} — EIN {{ config.ein }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="config-row">
|
||||||
|
<span class="config-label">Email</span>
|
||||||
|
<span class="config-value">{{ config.email }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="config-row">
|
||||||
|
<span class="config-label">Phone</span>
|
||||||
|
<span class="config-value">{{ config.phone }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="config-row">
|
||||||
|
<span class="config-label">Website</span>
|
||||||
|
<span class="config-value">{{ config.website }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Form modals -->
|
<!-- Form modals -->
|
||||||
@@ -157,4 +212,7 @@
|
|||||||
@if (showTierForm) {
|
@if (showTierForm) {
|
||||||
<app-admin-tier-form [editItem]="editingTier" (saved)="onTierSaved()" (closed)="onTierClosed()" />
|
<app-admin-tier-form [editItem]="editingTier" (saved)="onTierSaved()" (closed)="onTierClosed()" />
|
||||||
}
|
}
|
||||||
|
@if (showStationForm) {
|
||||||
|
<app-admin-station-form [editItem]="stationConfig$ | async" (saved)="onStationSaved()" (closed)="onStationClosed()" />
|
||||||
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -157,6 +157,33 @@
|
|||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Station config view ──────────────────────────────────
|
||||||
|
|
||||||
|
.station-config-view {
|
||||||
|
.config-row {
|
||||||
|
display: flex;
|
||||||
|
padding: $spacing-sm 0;
|
||||||
|
border-bottom: 1px solid $neutral-light;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: $neutral-medium;
|
||||||
|
width: 140px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-value {
|
||||||
|
color: $neutral-dark;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Responsive ───────────────────────────────────────────
|
// ── Responsive ───────────────────────────────────────────
|
||||||
|
|
||||||
@include responsive(md) {
|
@include responsive(md) {
|
||||||
|
|||||||
@@ -6,14 +6,17 @@ import { BehaviorSubject, firstValueFrom } from 'rxjs';
|
|||||||
import { Program } from '../interfaces/program';
|
import { Program } from '../interfaces/program';
|
||||||
import { Event } from '../interfaces/event';
|
import { Event } from '../interfaces/event';
|
||||||
import { Tier } from '../interfaces/tier';
|
import { Tier } from '../interfaces/tier';
|
||||||
|
import { StationConfig } from '../interfaces/station-config';
|
||||||
import { ProgramService } from '../services/program.service';
|
import { ProgramService } from '../services/program.service';
|
||||||
import { EventService } from '../services/event.service';
|
import { EventService } from '../services/event.service';
|
||||||
import { TierService } from '../services/tier.service';
|
import { TierService } from '../services/tier.service';
|
||||||
|
import { StationConfigService } from '../services/station-config.service';
|
||||||
import { AdminProgramFormComponent } from './admin-program-form.component';
|
import { AdminProgramFormComponent } from './admin-program-form.component';
|
||||||
import { AdminEventFormComponent } from './admin-event-form.component';
|
import { AdminEventFormComponent } from './admin-event-form.component';
|
||||||
import { AdminTierFormComponent } from './admin-tier-form.component';
|
import { AdminTierFormComponent } from './admin-tier-form.component';
|
||||||
|
import { AdminStationFormComponent } from './admin-station-form.component';
|
||||||
|
|
||||||
type TabKey = 'programs' | 'events' | 'tiers';
|
type TabKey = 'programs' | 'events' | 'tiers' | 'station';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin',
|
selector: 'app-admin',
|
||||||
@@ -25,6 +28,7 @@ type TabKey = 'programs' | 'events' | 'tiers';
|
|||||||
AdminProgramFormComponent,
|
AdminProgramFormComponent,
|
||||||
AdminEventFormComponent,
|
AdminEventFormComponent,
|
||||||
AdminTierFormComponent,
|
AdminTierFormComponent,
|
||||||
|
AdminStationFormComponent,
|
||||||
],
|
],
|
||||||
templateUrl: './admin.component.html',
|
templateUrl: './admin.component.html',
|
||||||
styleUrl: './admin.component.scss',
|
styleUrl: './admin.component.scss',
|
||||||
@@ -33,6 +37,7 @@ export class AdminComponent implements OnInit {
|
|||||||
private programService = inject(ProgramService);
|
private programService = inject(ProgramService);
|
||||||
private eventService = inject(EventService);
|
private eventService = inject(EventService);
|
||||||
private tierService = inject(TierService);
|
private tierService = inject(TierService);
|
||||||
|
private stationConfigService = inject(StationConfigService);
|
||||||
|
|
||||||
activeTab = signal<TabKey>('programs');
|
activeTab = signal<TabKey>('programs');
|
||||||
|
|
||||||
@@ -45,10 +50,12 @@ export class AdminComponent implements OnInit {
|
|||||||
readonly programs$ = new BehaviorSubject<Program[]>([]);
|
readonly programs$ = new BehaviorSubject<Program[]>([]);
|
||||||
readonly events$ = new BehaviorSubject<Event[]>([]);
|
readonly events$ = new BehaviorSubject<Event[]>([]);
|
||||||
readonly tiers$ = new BehaviorSubject<Tier[]>([]);
|
readonly tiers$ = new BehaviorSubject<Tier[]>([]);
|
||||||
|
readonly stationConfig$ = new BehaviorSubject<StationConfig | null>(null);
|
||||||
|
|
||||||
showProgramForm = false;
|
showProgramForm = false;
|
||||||
showEventForm = false;
|
showEventForm = false;
|
||||||
showTierForm = false;
|
showTierForm = false;
|
||||||
|
showStationForm = false;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.loadAll();
|
this.loadAll();
|
||||||
@@ -69,11 +76,17 @@ export class AdminComponent implements OnInit {
|
|||||||
this.tiers$.next(data);
|
this.tiers$.next(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reloadStationConfig(): Promise<void> {
|
||||||
|
await this.stationConfigService.load();
|
||||||
|
this.stationConfig$.next(this.stationConfigService.config());
|
||||||
|
}
|
||||||
|
|
||||||
async loadAll(): Promise<void> {
|
async loadAll(): Promise<void> {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.reloadPrograms(),
|
this.reloadPrograms(),
|
||||||
this.reloadEvents(),
|
this.reloadEvents(),
|
||||||
this.reloadTiers(),
|
this.reloadTiers(),
|
||||||
|
this.reloadStationConfig(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,4 +176,19 @@ export class AdminComponent implements OnInit {
|
|||||||
await firstValueFrom(this.tierService.deleteTier(id));
|
await firstValueFrom(this.tierService.deleteTier(id));
|
||||||
await this.reloadTiers();
|
await this.reloadTiers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Station Config ──────────────────────────────────────
|
||||||
|
|
||||||
|
openStationForm(): void {
|
||||||
|
this.showStationForm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
onStationSaved(): void {
|
||||||
|
this.reloadStationConfig();
|
||||||
|
this.showStationForm = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onStationClosed(): void {
|
||||||
|
this.showStationForm = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-1
@@ -1,4 +1,4 @@
|
|||||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
import { APP_INITIALIZER, ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
|
|
||||||
@@ -8,6 +8,16 @@ import {
|
|||||||
appConfigInitProvider,
|
appConfigInitProvider,
|
||||||
appConfigProvider,
|
appConfigProvider,
|
||||||
} from './services/app-config.service';
|
} from './services/app-config.service';
|
||||||
|
import { StationConfigService } from './services/station-config.service';
|
||||||
|
|
||||||
|
export const stationConfigInitProvider = {
|
||||||
|
provide: APP_INITIALIZER,
|
||||||
|
useFactory: (stationConfigService: StationConfigService) => {
|
||||||
|
return () => stationConfigService.load();
|
||||||
|
},
|
||||||
|
deps: [StationConfigService],
|
||||||
|
multi: true,
|
||||||
|
};
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
@@ -16,5 +26,6 @@ export const appConfig: ApplicationConfig = {
|
|||||||
provideHttpClient(withInterceptors([authInterceptor])),
|
provideHttpClient(withInterceptors([authInterceptor])),
|
||||||
appConfigProvider,
|
appConfigProvider,
|
||||||
appConfigInitProvider,
|
appConfigInitProvider,
|
||||||
|
stationConfigInitProvider,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -45,19 +45,19 @@
|
|||||||
<div class="contact-info">
|
<div class="contact-info">
|
||||||
<div class="info-card">
|
<div class="info-card">
|
||||||
<h4><span aria-hidden="true">📍</span> Studio Location</h4>
|
<h4><span aria-hidden="true">📍</span> Studio Location</h4>
|
||||||
<p>42 Pine Street<br>Mountain View, CO 80424</p>
|
<p [innerHTML]="config().address"></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-card">
|
<div class="info-card">
|
||||||
<h4><span aria-hidden="true">📞</span> Phone</h4>
|
<h4><span aria-hidden="true">📞</span> Phone</h4>
|
||||||
<p>(970) 555-0198<br>Mon–Fri 8 AM – 6 MT</p>
|
<p>{{ config().phone }}<br>Mon–Fri 8 AM – 6 MT</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-card">
|
<div class="info-card">
|
||||||
<h4><span aria-hidden="true">✉️</span> Email</h4>
|
<h4><span aria-hidden="true">✉️</span> Email</h4>
|
||||||
<p><a href="mailto:hello@kmountainflower.org">hello@kmountainflower.org</a></p>
|
<p><a [href]="'mailto:' + config().email">{{ config().email }}</a></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-card">
|
<div class="info-card">
|
||||||
<h4><span aria-hidden="true">📡</span> Frequencies</h4>
|
<h4><span aria-hidden="true">📡</span> Frequencies</h4>
|
||||||
<p>98.7 FM — Mountain Region<br>Stream: kmountainflower.org/live</p>
|
<p>{{ config().frequency }} — Mountain Region<br>Stream: {{ config().website }}/live</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
|
||||||
|
import { StationConfigService } from '../services/station-config.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-contact',
|
selector: 'app-contact',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -9,6 +11,8 @@ import { FormsModule } from '@angular/forms';
|
|||||||
styleUrl: './contact.component.scss',
|
styleUrl: './contact.component.scss',
|
||||||
})
|
})
|
||||||
export class ContactComponent {
|
export class ContactComponent {
|
||||||
|
readonly config = inject(StationConfigService).config;
|
||||||
|
|
||||||
onSubmit(): void {
|
onSubmit(): void {
|
||||||
alert('Thank you for your message! We will get back to you shortly.');
|
alert('Thank you for your message! We will get back to you shortly.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
<div class="donate-banner">
|
<div class="donate-banner">
|
||||||
<div class="donate-banner-overlay">
|
<div class="donate-banner-overlay">
|
||||||
<div class="container donate-banner-content">
|
<div class="container donate-banner-content">
|
||||||
<img src="svg/orange-flower.svg" alt="" class="donate-flower-hero" aria-hidden="true">
|
<img [src]="config().hero_icon_url" alt="" class="donate-flower-hero" aria-hidden="true">
|
||||||
<h2>Keep the Music <span class="text-accent">Free</span></h2>
|
<h2>Keep the Music <span class="text-accent">Free</span></h2>
|
||||||
<p class="donate-subtitle">
|
<p class="donate-subtitle">
|
||||||
KMountain Flower Radio is 100% listener-funded and independent.
|
{{ config().name_primary }} {{ config().name_secondary }} Radio is 100% listener-funded and independent.
|
||||||
Your gift keeps our signal strong and our airwaves free for everyone.
|
Your gift keeps our signal strong and our airwaves free for everyone.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,12 +42,12 @@
|
|||||||
<!-- One-time donation -->
|
<!-- One-time donation -->
|
||||||
<div class="donate-one-time">
|
<div class="donate-one-time">
|
||||||
<div class="donate-flower-divider" aria-hidden="true">
|
<div class="donate-flower-divider" aria-hidden="true">
|
||||||
<img src="svg/small-flower.svg" alt="">
|
<img [src]="config().logo_url" alt="">
|
||||||
</div>
|
</div>
|
||||||
<h3>Or Make a One-Time Gift</h3>
|
<h3>Or Make a One-Time Gift</h3>
|
||||||
<p class="section-intro">
|
<p class="section-intro">
|
||||||
Every dollar matters — from $5 to $5,000. No amount is too small to make
|
Every dollar matters — from $5 to $5,000. No amount is too small to make
|
||||||
a difference. All donations are tax-deductible (EIN: 84-XXXXXXX).
|
a difference. All donations are tax-deductible (EIN: {{ config().ein }}).
|
||||||
</p>
|
</p>
|
||||||
<a href="#" class="btn btn-lg">Give Any Amount</a>
|
<a href="#" class="btn btn-lg">Give Any Amount</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
<div class="why-grid">
|
<div class="why-grid">
|
||||||
<div class="why-item">
|
<div class="why-item">
|
||||||
<span class="why-icon">📻</span>
|
<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>
|
<p>Covering 3 mountain counties and the valleys between</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="why-item">
|
<div class="why-item">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { map, Observable, startWith } from 'rxjs';
|
|||||||
|
|
||||||
import { TierService } from '../services/tier.service';
|
import { TierService } from '../services/tier.service';
|
||||||
import { Tier } from '../interfaces/tier';
|
import { Tier } from '../interfaces/tier';
|
||||||
|
import { StationConfigService } from '../services/station-config.service';
|
||||||
|
|
||||||
interface TiersState {
|
interface TiersState {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -19,6 +20,7 @@ interface TiersState {
|
|||||||
})
|
})
|
||||||
export class DonateComponent {
|
export class DonateComponent {
|
||||||
private tierService = inject(TierService);
|
private tierService = inject(TierService);
|
||||||
|
readonly config = inject(StationConfigService).config;
|
||||||
|
|
||||||
/** Async pipe drives the template — guarantees change detection on every emission. */
|
/** Async pipe drives the template — guarantees change detection on every emission. */
|
||||||
readonly tiers$: Observable<TiersState> =
|
readonly tiers$: Observable<TiersState> =
|
||||||
|
|||||||
@@ -2,17 +2,17 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="footer-top">
|
<div class="footer-top">
|
||||||
<div class="footer-brand">
|
<div class="footer-brand">
|
||||||
<img src="svg/small-flower.svg" alt="" class="footer-flower" aria-hidden="true">
|
<img [src]="config().logo_url" alt="" class="footer-flower" aria-hidden="true">
|
||||||
<h3>
|
<h3>
|
||||||
<span class="brand-m">K</span>MOUNTAIN
|
<span class="brand-m">{{ config().name_primary | slice:0:1 }}</span>{{ config().name_primary | slice:1 }}
|
||||||
<span class="brand-a">FLOWER</span> Radio
|
<span class="brand-a">{{ config().name_secondary }}</span> Radio
|
||||||
</h3>
|
</h3>
|
||||||
<p class="footer-tagline">
|
<p class="footer-tagline">
|
||||||
Community-powered nonprofit public radio — free to stream, funded by you.
|
Community-powered nonprofit public radio — free to stream, funded by you.
|
||||||
</p>
|
</p>
|
||||||
<p class="footer-legal">
|
<p class="footer-legal">
|
||||||
KMountain Flower Radio is a 501(c)(3) nonprofit organization.<br>
|
{{ config().name_primary }} {{ config().name_secondary }} Radio is a {{ config().nonprofit_type }} nonprofit organization.<br>
|
||||||
EIN: 84-XXXXXXX. All donations are tax-deductible.
|
EIN: {{ config().ein }}. All donations are tax-deductible.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="footer-bottom">
|
<div class="footer-bottom">
|
||||||
<p>© 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">
|
<p class="footer-made">
|
||||||
Made with 🧡 in the mountains
|
Made with 🧡 in the mountains
|
||||||
</p>
|
</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({
|
@Component({
|
||||||
selector: 'app-footer',
|
selector: 'app-footer',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [],
|
imports: [RouterLink, SlicePipe],
|
||||||
templateUrl: './footer.component.html',
|
templateUrl: './footer.component.html',
|
||||||
styleUrl: './footer.component.scss',
|
styleUrl: './footer.component.scss',
|
||||||
})
|
})
|
||||||
export class FooterComponent {}
|
export class FooterComponent {
|
||||||
|
readonly config = inject(StationConfigService).config;
|
||||||
|
readonly currentYear = computed(() => new Date().getFullYear());
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<section class="hero">
|
<section class="hero">
|
||||||
<!-- Mountain background layer -->
|
<!-- Mountain background layer -->
|
||||||
<div class="hero-mountains" aria-hidden="true">
|
<div class="hero-mountains" aria-hidden="true">
|
||||||
<img src="svg/mountain-landscape.svg" alt="">
|
<img [src]="config().hero_background_url" alt="">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Gradient overlay -->
|
<!-- Gradient overlay -->
|
||||||
@@ -11,18 +11,18 @@
|
|||||||
<div class="hero-content container">
|
<div class="hero-content container">
|
||||||
<div class="hero-inner">
|
<div class="hero-inner">
|
||||||
<div class="hero-icon">
|
<div class="hero-icon">
|
||||||
<img src="svg/orange-flower.svg" alt="" class="hero-flower">
|
<img [src]="config().hero_icon_url" alt="" class="hero-flower">
|
||||||
</div>
|
</div>
|
||||||
<h1 class="hero-title">
|
<h1 class="hero-title">
|
||||||
<span class="hero-line1">KMountain</span>
|
<span class="hero-line1">{{ config().name_primary }}</span>
|
||||||
<span class="hero-line2 text-accent">Flower Radio</span>
|
<span class="hero-line2 text-accent">{{ config().name_secondary }}</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="hero-subtitle">
|
<p class="hero-subtitle">
|
||||||
Your community-powered, nonprofit radio station — bringing music,
|
Your community-powered, nonprofit radio station — bringing music,
|
||||||
stories, and connection to the mountain towns and beyond since 1987.
|
stories, and connection to the mountain towns and beyond since {{ config().founded_year }}.
|
||||||
</p>
|
</p>
|
||||||
<p class="hero-subtitle hero-tagline">
|
<p class="hero-subtitle hero-tagline">
|
||||||
Free to stream. Funded by you. Always on-air.
|
{{ config().tagline }}
|
||||||
</p>
|
</p>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<a routerLink="/donate" class="btn btn-hero-primary">Support the Station</a>
|
<a routerLink="/donate" class="btn btn-hero-primary">Support the Station</a>
|
||||||
@@ -37,6 +37,6 @@
|
|||||||
|
|
||||||
<!-- Mountain divider at bottom -->
|
<!-- Mountain divider at bottom -->
|
||||||
<div class="hero-divider" aria-hidden="true">
|
<div class="hero-divider" aria-hidden="true">
|
||||||
<img src="svg/mountain-divider.svg" alt="">
|
<img [src]="config().hero_divider_url" alt="">
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
|
||||||
|
import { StationConfigService } from '../services/station-config.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-hero',
|
selector: 'app-hero',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [],
|
imports: [RouterLink],
|
||||||
templateUrl: './hero.component.html',
|
templateUrl: './hero.component.html',
|
||||||
styleUrl: './hero.component.scss',
|
styleUrl: './hero.component.scss',
|
||||||
})
|
})
|
||||||
export class HeroComponent {}
|
export class HeroComponent {
|
||||||
|
readonly config = inject(StationConfigService).config;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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">
|
<section class="login-section">
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<div class="login-header">
|
<div class="login-header">
|
||||||
<img src="svg/small-flower.svg" alt="" class="login-flower" aria-hidden="true">
|
<img [src]="config().logo_url" alt="" class="login-flower" aria-hidden="true">
|
||||||
<h1>Sign In</h1>
|
<h1>Sign In</h1>
|
||||||
<p>Admin access for KMountain Flower Radio</p>
|
<p>Admin access for {{ config().name_primary }} {{ config().name_secondary }} Radio</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form (ngSubmit)="onSubmit()" class="login-form">
|
<form (ngSubmit)="onSubmit()" class="login-form">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Router } from '@angular/router';
|
|||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
import { AuthService } from '../services/auth.service';
|
import { AuthService } from '../services/auth.service';
|
||||||
|
import { StationConfigService } from '../services/station-config.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-login',
|
selector: 'app-login',
|
||||||
@@ -17,6 +18,7 @@ export class LoginComponent {
|
|||||||
private auth = inject(AuthService);
|
private auth = inject(AuthService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private cdr = inject(ChangeDetectorRef);
|
private cdr = inject(ChangeDetectorRef);
|
||||||
|
readonly config = inject(StationConfigService).config;
|
||||||
|
|
||||||
username = '';
|
username = '';
|
||||||
password = '';
|
password = '';
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<nav class="navbar" role="navigation" aria-label="Main navigation">
|
<nav class="navbar" role="navigation" aria-label="Main navigation">
|
||||||
<div class="container navbar-inner">
|
<div class="container navbar-inner">
|
||||||
<a routerLink="/" class="navbar-brand" routerLinkActive="active">
|
<a routerLink="/" class="navbar-brand" routerLinkActive="active">
|
||||||
<img src="svg/small-flower.svg" alt="" class="brand-flower" aria-hidden="true">
|
<img [src]="config().logo_url" alt="" class="brand-flower" aria-hidden="true">
|
||||||
<span class="brand-name">
|
<span class="brand-name">
|
||||||
<span class="brand-mountain">K</span>MOUNTAIN
|
<span class="brand-mountain">{{ config().name_primary | slice:0:1 }}</span>{{ config().name_primary | uppercase }}
|
||||||
<span class="brand-accent">FLOWER</span>
|
<span class="brand-accent">{{ config().name_secondary | uppercase }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="brand-tagline">Community Radio — Est. 1987</span>
|
<span class="brand-tagline">{{ config().tagline }}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<button class="navbar-toggle" aria-label="Toggle menu" (click)="toggleMenu()">
|
<button class="navbar-toggle" aria-label="Toggle menu" (click)="toggleMenu()">
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { Component, DestroyRef, inject, OnInit } from '@angular/core';
|
import { Component, DestroyRef, inject, computed, OnInit } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule, SlicePipe, UpperCasePipe } from '@angular/common';
|
||||||
import { RouterLink, RouterLinkActive, Router } from '@angular/router';
|
import { RouterLink, RouterLinkActive, Router } from '@angular/router';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
|
||||||
import { AuthService } from '../services/auth.service';
|
import { AuthService } from '../services/auth.service';
|
||||||
|
import { StationConfigService } from '../services/station-config.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-navbar',
|
selector: 'app-navbar',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterLink, RouterLinkActive],
|
imports: [CommonModule, RouterLink, RouterLinkActive, SlicePipe, UpperCasePipe],
|
||||||
templateUrl: './navbar.component.html',
|
templateUrl: './navbar.component.html',
|
||||||
styleUrl: './navbar.component.scss',
|
styleUrl: './navbar.component.scss',
|
||||||
})
|
})
|
||||||
@@ -16,6 +17,9 @@ export class NavbarComponent implements OnInit {
|
|||||||
private auth = inject(AuthService);
|
private auth = inject(AuthService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private destroyRef = inject(DestroyRef);
|
private destroyRef = inject(DestroyRef);
|
||||||
|
private stationConfigService = inject(StationConfigService);
|
||||||
|
|
||||||
|
readonly config = computed(() => this.stationConfigService.config());
|
||||||
|
|
||||||
menuOpen = false;
|
menuOpen = false;
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<section class="schedule">
|
<section class="schedule">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="schedule-header">
|
<div class="schedule-header">
|
||||||
<img src="svg/small-flower.svg" alt="" class="schedule-flower" aria-hidden="true">
|
<img [src]="config().logo_url" alt="" class="schedule-flower" aria-hidden="true">
|
||||||
<h2>Program <span class="text-accent">Schedule</span></h2>
|
<h2>Program <span class="text-accent">Schedule</span></h2>
|
||||||
<p class="section-intro">
|
<p class="section-intro">
|
||||||
All times are local (Mountain Time). Every show is free to listen to
|
All times are local (Mountain Time). Every show is free to listen to
|
||||||
live at 98.7 FM or via our online stream.
|
live at {{ config().frequency }} or via our online stream.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { map, Observable, startWith } from 'rxjs';
|
|||||||
|
|
||||||
import { ProgramService } from '../services/program.service';
|
import { ProgramService } from '../services/program.service';
|
||||||
import { Program } from '../interfaces/program';
|
import { Program } from '../interfaces/program';
|
||||||
|
import { StationConfigService } from '../services/station-config.service';
|
||||||
|
|
||||||
interface DayGroup {
|
interface DayGroup {
|
||||||
day_label: string;
|
day_label: string;
|
||||||
@@ -36,12 +37,13 @@ function groupByDay(programs: Program[]): DayGroup[] {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-schedule',
|
selector: 'app-schedule',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [AsyncPipe, NgFor],
|
imports: [AsyncPipe],
|
||||||
templateUrl: './schedule.component.html',
|
templateUrl: './schedule.component.html',
|
||||||
styleUrl: './schedule.component.scss',
|
styleUrl: './schedule.component.scss',
|
||||||
})
|
})
|
||||||
export class ScheduleComponent {
|
export class ScheduleComponent {
|
||||||
private programService = inject(ProgramService);
|
private programService = inject(ProgramService);
|
||||||
|
readonly config = inject(StationConfigService).config;
|
||||||
|
|
||||||
/** Async pipe drives the template — guarantees change detection on every emission. */
|
/** Async pipe drives the template — guarantees change detection on every emission. */
|
||||||
readonly schedule$: Observable<ScheduleState> =
|
readonly schedule$: Observable<ScheduleState> =
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
import { StationConfig } from '../interfaces/station-config';
|
||||||
|
import { getAppConfig } from './app-config.service';
|
||||||
|
|
||||||
|
/** Hardcoded defaults — used when the API is unavailable (dev fallback). */
|
||||||
|
const DEFAULT_CONFIG: StationConfig = {
|
||||||
|
id: 0,
|
||||||
|
key: 'default',
|
||||||
|
callsign: 'KMTN',
|
||||||
|
name_primary: 'KMountain',
|
||||||
|
name_secondary: 'Flower Radio',
|
||||||
|
tagline: 'Community Radio — Est. 1987',
|
||||||
|
frequency: '98.7 FM',
|
||||||
|
founded_year: 1987,
|
||||||
|
nonprofit_type: '501(c)(3)',
|
||||||
|
ein: '84-XXXXXXX',
|
||||||
|
logo_url: 'svg/small-flower.svg',
|
||||||
|
hero_background_url: 'svg/mountain-landscape.svg',
|
||||||
|
hero_icon_url: 'svg/orange-flower.svg',
|
||||||
|
hero_divider_url: 'svg/mountain-divider.svg',
|
||||||
|
address: '42 Pine Street\nMountain View, CO 80424',
|
||||||
|
phone: '(970) 555-0198',
|
||||||
|
email: 'hello@kmountainflower.org',
|
||||||
|
website: 'kmountainflower.org',
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class StationConfigService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
private baseUrl = `${getAppConfig().apiBaseUrl}/api/station-config`;
|
||||||
|
|
||||||
|
readonly config = signal<StationConfig>(DEFAULT_CONFIG);
|
||||||
|
|
||||||
|
/** Load station config from the API. Falls back to defaults on error. */
|
||||||
|
async load(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const data = await firstValueFrom(this.http.get<StationConfig>(this.baseUrl));
|
||||||
|
this.config.set(data);
|
||||||
|
} catch {
|
||||||
|
// Keep the default config if the API is unavailable.
|
||||||
|
console.warn('StationConfigService: Failed to load config, using defaults.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update station config (admin only). */
|
||||||
|
async updateConfig(payload: Partial<StationConfig>): Promise<void> {
|
||||||
|
const data = await firstValueFrom(
|
||||||
|
this.http.put<StationConfig>(this.baseUrl, payload),
|
||||||
|
);
|
||||||
|
this.config.set(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upload an image file for station branding (admin only). Returns the relative URL. */
|
||||||
|
async uploadImage(file: File): Promise<string> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.http.post<{ url: string }>(`${this.baseUrl}/upload`, formData),
|
||||||
|
);
|
||||||
|
return response.url;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user