From f52625b37bdfcfdd30de326610a41e83789f2993 Mon Sep 17 00:00:00 2001 From: kfj001 Date: Sun, 21 Jun 2026 02:56:49 +0000 Subject: [PATCH] working basic site admin --- .claude/plans/station-config-plan.md | 125 +++++++++++ backend/__pycache__/seed.cpython-312.pyc | Bin 0 -> 10644 bytes backend/app/__pycache__/main.cpython-312.pyc | Bin 4361 -> 6803 bytes .../app/__pycache__/models.cpython-312.pyc | Bin 2349 -> 4047 bytes .../app/__pycache__/schemas.cpython-312.pyc | Bin 4240 -> 5822 bytes .../app/api/__pycache__/admin.cpython-312.pyc | Bin 1855 -> 2051 bytes .../station_config.cpython-312.pyc | Bin 0 -> 5041 bytes backend/app/api/admin.py | 3 +- backend/app/api/station_config.py | 95 +++++++++ backend/app/main.py | 37 +++- backend/app/models.py | 31 +++ backend/app/schemas.py | 44 ++++ backend/seed.py | 40 +++- src/app/about/about.component.html | 12 +- src/app/about/about.component.ts | 11 +- .../admin/admin-station-form.component.html | 141 +++++++++++++ .../admin/admin-station-form.component.scss | 196 ++++++++++++++++++ src/app/admin/admin-station-form.component.ts | 161 ++++++++++++++ src/app/admin/admin.component.html | 58 ++++++ src/app/admin/admin.component.scss | 27 +++ src/app/admin/admin.component.ts | 30 ++- src/app/app.config.ts | 13 +- src/app/contact/contact.component.html | 8 +- src/app/contact/contact.component.ts | 6 +- src/app/donate/donate.component.html | 10 +- src/app/donate/donate.component.ts | 2 + src/app/footer/footer.component.html | 12 +- src/app/footer/footer.component.ts | 13 +- src/app/hero/hero.component.html | 14 +- src/app/hero/hero.component.ts | 11 +- src/app/interfaces/station-config.ts | 20 ++ src/app/login/login.component.html | 4 +- src/app/login/login.component.ts | 2 + src/app/navbar/navbar.component.html | 8 +- src/app/navbar/navbar.component.ts | 10 +- src/app/schedule/schedule.component.html | 4 +- src/app/schedule/schedule.component.ts | 4 +- src/app/services/station-config.service.ts | 67 ++++++ 38 files changed, 1163 insertions(+), 56 deletions(-) create mode 100644 .claude/plans/station-config-plan.md create mode 100644 backend/__pycache__/seed.cpython-312.pyc create mode 100644 backend/app/api/__pycache__/station_config.cpython-312.pyc create mode 100644 backend/app/api/station_config.py create mode 100644 src/app/admin/admin-station-form.component.html create mode 100644 src/app/admin/admin-station-form.component.scss create mode 100644 src/app/admin/admin-station-form.component.ts create mode 100644 src/app/interfaces/station-config.ts create mode 100644 src/app/services/station-config.service.ts diff --git a/.claude/plans/station-config-plan.md b/.claude/plans/station-config-plan.md new file mode 100644 index 0000000..9721662 --- /dev/null +++ b/.claude/plans/station-config-plan.md @@ -0,0 +1,125 @@ +# Plan: Data-Driven Station Configuration + +## Context + +The station's branding — callsign, name, founding year, nonprofit status, logo, hero banner images, contact info, and frequency — is hardcoded across 10+ component templates. This makes the app a single-use template rather than a customizable platform. We need an admin-editable `StationConfig` singleton stored in PostgreSQL, a reactive frontend service, and an admin form so any station operator can rebrand the site without touching code. + +## Architecture + +- **Backend**: New `StationConfig` SQLAlchemy model (singleton via `key='default'`), a `PUT /station-config` endpoint (admin-only), a `GET /station-config` public endpoint, and a `POST /station-config/upload` file-upload endpoint. +- **Frontend**: New `StationConfigService` with a `signal` loaded via `APP_INITIALIZER`. All components that currently hardcode station data read from this service. +- **Admin UI**: New "Station" tab in the existing admin dashboard with a modal form for editing all fields, including file upload inputs for images. + +--- + +## Step 1: Backend — Model + Schemas + +### `backend/app/models.py` +Add `StationConfig` class using the existing `Base`. Fields: +- `id`, `key` (unique, always `'default'`) +- **Identity**: `callsign`, `name_primary`, `name_secondary`, `tagline`, `frequency`, `founded_year` +- **Nonprofit**: `nonprofit_type`, `ein` +- **Images** (URL strings): `logo_url`, `hero_background_url`, `hero_icon_url`, `hero_divider_url` +- **Contact**: `address` (Text), `phone`, `email`, `website` + +All columns have defaults matching current hardcoded values. + +### `backend/app/schemas.py` +Add `StationConfigResponse` (all fields, `from_attributes: True`) and `StationConfigUpdate` (all fields `Optional`, default `None` for partial updates). + +--- + +## Step 2: Backend — API Router + +### `backend/app/api/station_config.py` (NEW) +Three endpoints: +1. `GET /station-config` — Returns the singleton row. Public. +2. `PUT /station-config` — Partial update via `model_dump(exclude_unset=True)`. Requires `get_current_admin_user`. +3. `POST /station-config/upload` — Accepts `UploadFile`, saves to `uploads/` directory with UUID filename, validates `image/*` MIME type, max 5MB. Returns `{"url": "uploads/."}`. Requires `get_current_admin_user`. + +### `backend/app/main.py` +- Import `StationConfig` from models. +- Register router: `app.include_router(station_config.router, prefix="/api/station-config", tags=["station-config"])`. +- Mount `StaticFiles` for `uploads/` directory at `/uploads` (dev only; Nginx handles prod). +- Create `uploads/` directory on startup. + +### `backend/seed.py` +- Add `STATION_CONFIG` dict with current defaults. +- Add `_upsert_station_config()` helper (get-or-create by `key='default'`). +- Call it in `seed()`. +- Add `StationConfig.__table__.delete()` to `truncate_all()`. + +### `backend/app/api/admin.py` +- Add `StationConfig` to the truncation list in `reset_database()`. + +--- + +## Step 3: Frontend — Interface + Service + +### `src/app/interfaces/station-config.ts` (NEW) +TypeScript interface mirroring the schema. + +### `src/app/services/station-config.service.ts` (NEW) +- `config = signal(DEFAULT_CONFIG)` — signal initialized with hardcoded defaults so components never see `null`. +- `load()` — fetches from API, updates signal. Errors silently caught (same pattern as `appConfigFactory`). +- `updateConfig(payload: Partial)` — PUT request. +- `uploadImage(file: File)` — POST FormData to upload endpoint. + +### `src/app/app.config.ts` +Add `APP_INITIALIZER` provider that calls `stationConfigService.load()` (parallel with existing `appConfigInitProvider`). + +--- + +## Step 4: Frontend — Admin Station Form + +### `src/app/admin/admin-station-form.component.*` (3 NEW files) +Modal form following the existing `admin-program-form` pattern: +- **Identity section**: callsign, name_primary, name_secondary, tagline, frequency, founded_year +- **Nonprofit section**: nonprofit_type, ein +- **Images section**: 4 file inputs (logo, hero background, hero icon, hero divider) with thumbnail previews of current values +- **Contact section**: address (textarea), phone, email, website +- On submit: calls `stationConfigService.updateConfig()` with all fields as a partial update + +### `src/app/admin/admin.component.ts` +- Add `'station'` to `TabKey` union. +- Import `StationConfigService` and `AdminStationFormComponent`. +- Add `editingStation`, `showStationForm` properties. +- Add `openStationForm()`, `onStationSaved()`, `onStationClosed()` methods. +- In `loadAll()`, load station config. + +### `src/app/admin/admin.component.html` +- Add "Station" tab button. +- Add station tab content: a read-only form display of current values with an "Edit" button. +- Add form modal: `@if (showStationForm) { }`. + +--- + +## Step 5: Frontend — Update All Components + +Each component injects `StationConfigService` and reads `configService.config()` (a signal). Since config loads via `APP_INITIALIZER` with defaults, it's never null. + +| Component | Changes | +|-----------|---------| +| **Navbar** | Logo src, brand name (callsign + name_primary + name_secondary), tagline with founded_year | +| **Hero** | Background/icon/divider image srcs, title, subtitle (tagline + founded_year) | +| **Footer** | Logo src, brand name, tagline, nonprofit_type, EIN, copyright year range | +| **About** | Hero icon src, station name, frequency, nonprofit_type | +| **Contact** | Logo src, address (split on `\n` for `
`), phone, email, frequency, website | +| **Donate** | Hero icon src, station name, EIN, frequency | +| **Schedule** | Logo src, frequency | +| **Login** | Logo src, station name | +| **App** | Set `document.title` from config in `ngOnInit` | + +--- + +## Step 6: Verification + +1. Start backend: `cd backend && KMTN_DATABASE_URL=sqlite+aiosqlite:///./kmountain.db uvicorn app.main:app --reload` +2. Verify Swagger: `GET /api/station-config` returns seeded defaults +3. Verify upload: `POST /api/station-config/upload` with an image file returns a URL +4. Start frontend: `ng serve` +5. Navigate to site — verify all pages render with seeded data +6. Log in as admin → navigate to `/admin` → click "Station" tab +7. Edit a field (e.g., change name_primary to "Summit") → save → verify navbar/hero/footer update +8. Upload a new logo image → verify it appears in navbar and footer +9. Run `curl -X POST http://localhost:8000/api/admin/reset` → verify config resets to seed defaults diff --git a/backend/__pycache__/seed.cpython-312.pyc b/backend/__pycache__/seed.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1c25c33c9beec594123a979deafce1cc639f28f1 GIT binary patch literal 10644 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 literal 0 HcmV?d00001 diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc index dab4c2ef20102e8bb9fe3b01ce96e2ef338a1398..3ee04a6384b1e39dfb706bbc020baa18a592439a 100644 GIT binary patch literal 6803 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 delta 2057 zcmah}O>7%Q6rS;}ckT7R6Fckt)XqyZcI|Z5 zCJjPzLA`(?wPghXQ3)ZqptKSa5;qP`sx~eX7cP-Sh!83g2q8m@2o(or#!b`GNL_2* ze)GNW&3m)+#@2zi+q~a+JQ5&}bFx1ZkuG`V;t3l73qS)J)PVt22r0JdHiJ_+hB=)# z?5dq%UKb2e6%B{#U~{|fG$d7GSkPUDTXh>A)x+kZ?lpX>&+w~$Hh1U&Bd7)$cItIT zy;^UC)MCg8t6`%-ZGZqlO+wr$o8~&tFVZT>qslgbIL)njG%u2`_!jwld;~=@2Wt31 zi|k0s3_Qrxu;$kS(~yoU!9{zGBN-!dX6R8dYRDY4nxWizCA7$GX}6Wu6Y0w^QhdBJ zlsWilDoxfRq^8G5hqsMx)S7D1#rtJSnc;2HG`~6s0PZAb+d{s5RE^TjZ@Z!BwuyFV z(TB^Ni_OL2t-sJzaagIdWWLvWfj`h_tME@IO;iylac&wX4dUddOoZdS^%WnpzIQa* zcGZxPl|CcepRz4P}~ zncZ9LCd$V)5n2C)yT!Go?zYamM_`ZjilPcL&&)_uS>#5K|o!+shjw#0zQ_nwb$$@au!-8i|BaCpEieyRzYte~Z z7A0~xktOd6m(<9zshnY?^KlUesg6(Ove}AgBGXLgvK0X%v!Lg3l=^DhA$zSzNzF)S z69v+0CapMDd+v7i%vvR}Gvv_Ir;s_5%;Fy6^^#$J3$69QMd#lh;E!G?-U(Wt1b-0! z>Hv1BI%X}^br&y?-#f*-)p}6h{!i6r4|_MN%^=XVQH>I2V_|e*^wQ|RW(R4^w;~fk z_z@iM6E1TEeY|IUm@B#A_<&II@DvAJgf2D1@gcs{5~R2XDnX&NkIGAZqsk6$X+Km# z!ct#9!OLAxDayj~E}l-hY4GwORGRqZC;AD#)(w?@;o2S=d~M&@PGy9<9)ilCa6LTM zNhUXjp)w@g7>N0Vmv@R3cQYv|Y=kQhF?)z9M}!HVc$0$@g8L>fQtT2Zg4Wle zH_2Ta4Nt*<^*1-OC(0&U<*TLR((6I($>!9~L&|4LN zEmXC&LFohWPe5L`xlQ7a2?#{*s?@paKepax(w(AENJFYA@1Us#b;|GcpCn*{;7d$b{xOc!7 zR25XpA7C_9gyjuYr3%%lMAa_5=)zK$U6jQv%BY)k=VhgI-#dd%ZY3jehV$LuIrlsF z3i#3S#|`p#tJO@P*Dp6iFTIvB*`xWse5dTw5=#Wr^@?CDpzE|l0SIE-mbd!JpMK?a znv0)D^j~Q;ABwL#9Yv<|jp03h; zudQ=ngsn~JlmUv$l&FalL+owDh0W;3i0*3%4@NkyCA=8nREeT;*!VU*7~)b8;70a( zG1b#Wfn9it{@XF>(T?95n*T8y&483>|a3XV?Mn>=_gK62h3m_lgp%Z&4sN5f;s#aBH(JpFgSJTVmQ54vzI5p4=1A!pO z`u$xVdC-Fl&8B6;A=nYvEP&kzf(|7(l`wx5rqKr>hQ6?`h(BGoOVPI`+mWFfJ1e>* z(|qZE(`+w$Dq7K3eNYdc`z7;ADf~|-VfU1il@0M_EqFF0**+;fY&v_7hpNt#yLJ89 zmy&b2w9<6<9WPd=PL}H*oDWLwwbE*nbd?`h!s4@9uyOCHL~fUsnyAr1Wk)R3B4@Lb zJyQA&8IcQ+QcB-fKtwCNovzgCFvG%qg~0}$}_7-!vLoyaG_IANmtM3zc!O`eUH@>v)&H#@P`GBN5;KEQ6s zW(-tVWIXvhdoeqR>8Hs*`4wLzqw!=%n7kf@F;CJWx%D};BQX~Wv004a_ B7=r)+ diff --git a/backend/app/__pycache__/schemas.cpython-312.pyc b/backend/app/__pycache__/schemas.cpython-312.pyc index 8272a8b14f57d11a5acc023ccc2acc6bf4bd5b14..c9e4a7537623454b048149fa06f4f76ad594e2d8 100644 GIT binary patch delta 1508 zcmaiz&u`mg7{~4AO_Mf>ljc_&Sehj%Z9No(0F506mkPB*Ybw?aMxbPF-Z!aPVh1~A z`_ygcFWDf6qnS{7`x!awX7+nZPt~<8U2CeXJH+hjnn{dTS)&_xNcwKLp|snk+S1&prgTWX zYp5N?=(`fHOw!Ob40WidwA!jh+=Zq=zBndYqwhwW`mu%yuJ?&zuob%2HT0(1tM~d{ zowxy_YHql#xAgk4(RNcu#L(-9O5H`qPW z+4vyoC~DgcJtBvu+9Spr>`v}h<&gcmR2aO)3S$lA6_9n1y%+L`8MDLWJAsBHqiwnn z9qmwQX|zj|83$!(df7qCG?p0Mrt##cdV!wz=B_yC3Qc7ll%?66gL0=UBGTyn5Cx?% z`~Isp0PzAn?GX_#-eA}`?8AXh>71O%=OQUdwUfkim`KU1OQbSR(y0P0KOJOl>vJ^Pz~F=63c zEe&_ZN0vG1IH*KR>%-gD=IqUB#X;+IZF5+)c4qt2T?cK_d~vuvzHc=q2M#LILV0*= z{M9Tl`PxC{3+o1#7v}QAt?`Ogp5z^rzcf&;JVnd1l^@EF*~tnmZwz;>8?&wHI}X~Q z>(`#5v~@Hyr)+cAE^XlD!tlNE4Qp${S_{-yY*FRA4^!tPxugV$wAl|9)&y+uo zi;u_+-|Xo4xJ}+Qnyg)f=k&z>JN~$IB80`t$xCUmGP`vmu*1n~39&Sj{}I@M5BL{V Cyn3_% delta 76 zcmdm|J3*1}G%qg~0}zN;nr1O_P2`hcG<@lLCEie-R;vrdh?>2GFh*@zyQeI4eRDL~{g7YN=AHn(s3iMduxdsty!(;Zpgf(T7 ztnDBlkVnWgGKP#=7r7B}{2_OPJOHeZSxa06BrC;7sDxKCWa}oM3>$uQhu+pTEoUY; zL$cQSTVez!-482BVEyISi~5TGN^7VSTk1qZO>L>Ex|*)bnTC+Dg-lcKF6CG9by;l) zsx7F$MG_h;727fq53C+(@QTeV&7flU#B4Ec^YK;~`+u6k>Aj{fiKLMXqVBcaoqMHX zsklTzIQd^IV%o&uKOt7rY_H34u zMi;n?6v|@0+Cg9g!CKVk`mliWmlCqIOot$By`dvZ|DV^;7fk%m{C&M(8t&u1CA*l! z=)-V?l&0TVxR*^Ehp+^sa0;Wv0l+pH_(=x0$>UsMqga7 ULr>cig2THU@$`1g7{bl}0CXd|TmS$7 delta 528 zcmZn`*w4p%nwOW00SKP-7-y+X*_E|#u@&W)mZTQlVkyWkE-3;9#!80IK$4+I97x>au*uC&Da}c> zD^i%ei(N+7_#%VxLr$S?=?1nBYz#~s?b40X9TArq#Xd6eGirQhW?&Krvx?+_x&fnK Bibntd diff --git a/backend/app/api/__pycache__/station_config.cpython-312.pyc b/backend/app/api/__pycache__/station_config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7a1a2c6e8d42a2b20afd0aa294803abaeeb26c89 GIT binary patch literal 5041 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 literal 0 HcmV?d00001 diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 1d2c659..7ed693c 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -2,7 +2,7 @@ from fastapi import APIRouter from app.config import settings from app.database import get_session -from app.models import DonationTier, Event, Program +from app.models import DonationTier, Event, Program, StationConfig router = APIRouter() @@ -20,6 +20,7 @@ async def reset_database(): await session.execute(Program.__table__.delete()) await session.execute(Event.__table__.delete()) await session.execute(DonationTier.__table__.delete()) + await session.execute(StationConfig.__table__.delete()) await session.commit() await run_seed() diff --git a/backend/app/api/station_config.py b/backend/app/api/station_config.py new file mode 100644 index 0000000..1771e65 --- /dev/null +++ b/backend/app/api/station_config.py @@ -0,0 +1,95 @@ +"""Station config router: read/update the singleton station branding config.""" + +import os +import uuid + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, status + +from app.auth import get_current_admin_user +from app.database import get_session +from app.models import StationConfig +from app.schemas import StationConfigResponse, StationConfigUpdate +from app.user_models import User +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +router = APIRouter() + +UPLOAD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads") +MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB + + +@router.get("", response_model=StationConfigResponse) +async def get_station_config(session: AsyncSession = Depends(get_session)): + """Return the singleton station config. Public endpoint.""" + result = await session.execute( + select(StationConfig).where(StationConfig.key == "default") + ) + config = result.scalar_one_or_none() + if config is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Station config not found. Run seed to initialize.", + ) + return config + + +@router.put("", response_model=StationConfigResponse) +async def update_station_config( + payload: StationConfigUpdate, + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_admin_user), +): + """Partial update of station config. Admin only.""" + result = await session.execute( + select(StationConfig).where(StationConfig.key == "default") + ) + config = result.scalar_one_or_none() + if config is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Station config not found") + + for key, value in payload.model_dump(exclude_unset=True).items(): + setattr(config, key, value) + + await session.commit() + await session.refresh(config) + return config + + +@router.post("/upload") +async def upload_image( + file: UploadFile, + current_user: User = Depends(get_current_admin_user), +): + """Upload an image file for station branding. Admin only. + + Saves the file to the uploads/ directory with a UUID filename. + Returns the relative URL path. + """ + # Validate MIME type + if not file.content_type or not file.content_type.startswith("image/"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Only image files are allowed", + ) + + # Read and validate size + content = await file.read() + if len(content) > MAX_FILE_SIZE: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="File size exceeds 5 MB limit", + ) + + # Ensure upload directory exists + os.makedirs(UPLOAD_DIR, exist_ok=True) + + # Generate unique filename preserving extension + ext = os.path.splitext(file.filename or "upload")[1] if file.filename else ".bin" + filename = f"{uuid.uuid4().hex}{ext}" + filepath = os.path.join(UPLOAD_DIR, filename) + + with open(filepath, "wb") as f: + f.write(content) + + return {"url": f"uploads/{filename}"} diff --git a/backend/app/main.py b/backend/app/main.py index aeb1eee..4dc0a10 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,14 +1,31 @@ +import os from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles from sqlalchemy import func, select from app.config import settings from app.database import async_session, engine -from app.models import Base, Program +from app.models import Base, Program, StationConfig from app.user_models import User -from app.api import programs, events, tiers, auth +from app.api import programs, events, tiers, auth, station_config + + +async def _ensure_station_config(session): + """Ensure the singleton station config row exists (idempotent).""" + result = await session.execute( + select(StationConfig).where(StationConfig.key == "default") + ) + if result.scalar_one_or_none() is None: + print(" → Station config missing — seeding defaults...") + from seed import seed as run_seed + # Only seed the station config, not the full dataset + from seed import STATION_CONFIG, _upsert_station_config + await _upsert_station_config(session, STATION_CONFIG) + await session.commit() + print(" ✓ Station config seeded") @asynccontextmanager @@ -25,6 +42,11 @@ async def lifespan(app: FastAPI): print(" → Database is empty — running seed...") from seed import seed as run_seed await run_seed() + return # Full seed already included station config + + # Ensure station config exists (runs even if DB already had data) + async with async_session() as session: + await _ensure_station_config(session) # Bootstrap admin user if configured if settings.ADMIN_USERNAME and settings.ADMIN_PASSWORD: @@ -46,6 +68,10 @@ async def lifespan(app: FastAPI): await session.commit() print(f" → Bootstrap admin created: {settings.ADMIN_USERNAME}") + # Ensure uploads directory exists + uploads_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "uploads") + os.makedirs(uploads_dir, exist_ok=True) + yield @@ -69,6 +95,13 @@ app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(programs.router, prefix="/api/programs", tags=["programs"]) app.include_router(events.router, prefix="/api/events", tags=["events"]) app.include_router(tiers.router, prefix="/api/tiers", tags=["tiers"]) +app.include_router(station_config.router, prefix="/api/station-config", tags=["station-config"]) + +# Serve uploaded files (dev only — Nginx handles this in production) +if settings.ENV != "production": + uploads_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "uploads") + if os.path.exists(uploads_dir): + app.mount("/uploads", StaticFiles(directory=uploads_dir), name="uploads") # Dev-only admin router (disabled in production) if settings.ENV != "production": diff --git a/backend/app/models.py b/backend/app/models.py index 6defaa3..7b93e09 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -49,3 +49,34 @@ class DonationTier(Base): icon = Column(String(10), nullable=False) benefits = Column(JSON, nullable=False) display_order = Column(Integer, nullable=False) + + +class StationConfig(Base): + __tablename__ = "station_config" + + id = Column(Integer, primary_key=True, autoincrement=True) + key = Column(String(20), unique=True, nullable=False, default="default") + + # Identity + callsign = Column(String(10), nullable=False, default="KMTN") + name_primary = Column(String(100), nullable=False, default="KMountain") + name_secondary = Column(String(100), nullable=False, default="Flower Radio") + tagline = Column(String(200), nullable=False, default="Community Radio — Est. 1987") + frequency = Column(String(10), nullable=False, default="98.7 FM") + founded_year = Column(Integer, nullable=False, default=1987) + + # Nonprofit + nonprofit_type = Column(String(10), nullable=False, default="501(c)(3)") + ein = Column(String(20), nullable=False, default="84-XXXXXXX") + + # Images (URL strings relative to app root) + logo_url = Column(String(500), nullable=False, default="svg/small-flower.svg") + hero_background_url = Column(String(500), nullable=False, default="svg/mountain-landscape.svg") + hero_icon_url = Column(String(500), nullable=False, default="svg/orange-flower.svg") + hero_divider_url = Column(String(500), nullable=False, default="svg/mountain-divider.svg") + + # Contact + address = Column(Text, nullable=False, default="42 Pine Street\nMountain View, CO 80424") + phone = Column(String(30), nullable=False, default="(970) 555-0198") + email = Column(String(100), nullable=False, default="hello@kmountainflower.org") + website = Column(String(200), nullable=False, default="kmountainflower.org") diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 4d2044e..68ea471 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -112,3 +112,47 @@ class TierResponse(BaseModel): display_order: int model_config = {"from_attributes": True} + + +# ── StationConfig ───────────────────────────────────────── + +class StationConfigResponse(BaseModel): + id: int + key: str + callsign: str + name_primary: str + name_secondary: str + tagline: str + frequency: str + founded_year: int + nonprofit_type: str + ein: str + logo_url: str + hero_background_url: str + hero_icon_url: str + hero_divider_url: str + address: str + phone: str + email: str + website: str + + model_config = {"from_attributes": True} + + +class StationConfigUpdate(BaseModel): + callsign: Optional[str] = None + name_primary: Optional[str] = None + name_secondary: Optional[str] = None + tagline: Optional[str] = None + frequency: Optional[str] = None + founded_year: Optional[int] = None + nonprofit_type: Optional[str] = None + ein: Optional[str] = None + logo_url: Optional[str] = None + hero_background_url: Optional[str] = None + hero_icon_url: Optional[str] = None + hero_divider_url: Optional[str] = None + address: Optional[str] = None + phone: Optional[str] = None + email: Optional[str] = None + website: Optional[str] = None diff --git a/backend/seed.py b/backend/seed.py index 9c094ce..ab2f416 100644 --- a/backend/seed.py +++ b/backend/seed.py @@ -14,7 +14,7 @@ from datetime import date from sqlalchemy import select from app.database import async_session -from app.models import Program, Event, DonationTier +from app.models import Program, Event, DonationTier, StationConfig # ── Seed data ────────────────────────────────────────────── @@ -93,6 +93,26 @@ TIERS: list[dict] = [ }, ] +STATION_CONFIG: dict = { + "key": "default", + "callsign": "KMTN", + "name_primary": "KMountain", + "name_secondary": "Flower Radio", + "tagline": "Community Radio — Est. 1987", + "frequency": "98.7 FM", + "founded_year": 1987, + "nonprofit_type": "501(c)(3)", + "ein": "84-XXXXXXX", + "logo_url": "svg/small-flower.svg", + "hero_background_url": "svg/mountain-landscape.svg", + "hero_icon_url": "svg/orange-flower.svg", + "hero_divider_url": "svg/mountain-divider.svg", + "address": "42 Pine Street\nMountain View, CO 80424", + "phone": "(970) 555-0198", + "email": "hello@kmountainflower.org", + "website": "kmountainflower.org", +} + # ── Helpers ──────────────────────────────────────────────── @@ -141,6 +161,19 @@ async def _upsert_tier(session, data: dict) -> None: session.add(DonationTier(**data)) +async def _upsert_station_config(session, data: dict) -> None: + """Get-or-create the singleton station config (key='default').""" + existing = await session.execute( + select(StationConfig).where(StationConfig.key == "default") + ) + config = existing.scalar_one_or_none() + if config: + for key, value in data.items(): + setattr(config, key, value) + else: + session.add(StationConfig(**data)) + + # ── Main ────────────────────────────────────────────────── async def seed() -> None: @@ -161,6 +194,10 @@ async def seed() -> None: await session.commit() print(f" ✓ Seeded {len(TIERS)} donation tiers") + await _upsert_station_config(session, STATION_CONFIG) + await session.commit() + print(" ✓ Seeded station config") + async def truncate_all() -> None: """Remove all seed data from the database.""" @@ -168,6 +205,7 @@ async def truncate_all() -> None: await session.execute(Program.__table__.delete()) await session.execute(Event.__table__.delete()) await session.execute(DonationTier.__table__.delete()) + await session.execute(StationConfig.__table__.delete()) await session.commit() print(" ✓ Truncated all tables") diff --git a/src/app/about/about.component.html b/src/app/about/about.component.html index 8bac987..eaa1add 100644 --- a/src/app/about/about.component.html +++ b/src/app/about/about.component.html @@ -1,10 +1,10 @@
- -

About KMountain Flower

+ +

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

- For nearly four decades, KMountain Flower has been the voice of our + For nearly four decades, {{ config().name_primary }} {{ config().name_secondary }} has been the voice of our community — a nonprofit, listener-supported radio station dedicated to music, local news, and the stories that bind mountain towns together.

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

Our Reach

- Over the air on 98.7 FM and streaming live worldwide. Whether you're + Over the air on {{ config().frequency }} and streaming live worldwide. Whether you're hiking the ridgeline or on the other side of the planet, our signal — and our welcome — goes everywhere.

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

Become a Member

- As a 501(c)(3) nonprofit, every donation keeps our airwaves free and our + As a {{ config().nonprofit_type }} nonprofit, every donation keeps our airwaves free and our signal strong. Members receive exclusive perks and the satisfaction of knowing they helped make this possible.

diff --git a/src/app/about/about.component.ts b/src/app/about/about.component.ts index 7c59ee5..bd74414 100644 --- a/src/app/about/about.component.ts +++ b/src/app/about/about.component.ts @@ -1,10 +1,15 @@ -import { Component } from '@angular/core'; +import { Component, inject } from '@angular/core'; +import { RouterLink } from '@angular/router'; + +import { StationConfigService } from '../services/station-config.service'; @Component({ selector: 'app-about', standalone: true, - imports: [], + imports: [RouterLink], templateUrl: './about.component.html', styleUrl: './about.component.scss', }) -export class AboutComponent {} +export class AboutComponent { + readonly config = inject(StationConfigService).config; +} diff --git a/src/app/admin/admin-station-form.component.html b/src/app/admin/admin-station-form.component.html new file mode 100644 index 0000000..ebc7c9f --- /dev/null +++ b/src/app/admin/admin-station-form.component.html @@ -0,0 +1,141 @@ +
+
+
+

Edit Station Config

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

Identity

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

Images

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

Nonprofit

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

Contact

+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+ + +
+
+
+
diff --git a/src/app/admin/admin-station-form.component.scss b/src/app/admin/admin-station-form.component.scss new file mode 100644 index 0000000..fedc857 --- /dev/null +++ b/src/app/admin/admin-station-form.component.scss @@ -0,0 +1,196 @@ +@use '../../styles/variables' as *; +@use '../../styles/mixins' as *; + +.form-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + @include flex-center; + z-index: $z-modal; + @include fade-in; +} + +.form-dialog { + background: $neutral-white; + border-radius: $radius-lg; + padding: $spacing-xl; + width: 90%; + max-width: 560px; + box-shadow: $shadow-xl; + + &--wide { + max-width: 720px; + max-height: 85vh; + overflow-y: auto; + } +} + +.form-header { + @include flex-between; + margin-bottom: $spacing-lg; + + h2 { + font-family: $font-heading; + color: $primary-blue; + margin: 0; + font-size: 1.4rem; + } +} + +.btn-close { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: $neutral-medium; + line-height: 1; + padding: $spacing-xs; + + &:hover { + color: $neutral-dark; + } +} + +.form-error { + background: rgba($danger-red, 0.1); + color: $danger-red; + padding: $spacing-sm $spacing-md; + border-radius: $radius-md; + font-size: 0.875rem; + margin-bottom: $spacing-md; + text-align: center; +} + +.form-success { + background: rgba(#2e7d32, 0.1); + color: #2e7d32; + padding: $spacing-sm $spacing-md; + border-radius: $radius-md; + font-size: 0.875rem; + margin-bottom: $spacing-md; + text-align: center; +} + +.admin-form { + .form-section { + margin-bottom: $spacing-xl; + + h3 { + font-family: $font-heading; + color: $primary-blue; + font-size: 1.1rem; + margin: 0 0 $spacing-md 0; + padding-bottom: $spacing-xs; + border-bottom: 1px solid $neutral-light; + } + } + + .form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: $spacing-md; + } + + .form-group { + margin-bottom: $spacing-md; + + label:not(.btn-upload) { + display: block; + font-size: 0.875rem; + font-weight: 600; + color: $neutral-dark; + margin-bottom: $spacing-xs; + } + + input, select, textarea { + width: 100%; + padding: $spacing-sm $spacing-md; + border: 1px solid $neutral-light; + border-radius: $radius-md; + font-size: 0.95rem; + transition: border-color $transition-fast; + box-sizing: border-box; + + &:focus { + outline: none; + border-color: $primary-blue; + box-shadow: 0 0 0 3px rgba($primary-blue, 0.15); + } + } + } + + .url-with-upload { + display: flex; + gap: $spacing-sm; + align-items: center; + + input[type="text"] { + flex: 1; + } + } + + .btn-upload { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + background: $neutral-light; + color: $neutral-dark; + border: none; + border-radius: $radius-md; + padding: $spacing-sm $spacing-md; + font-size: 0.875rem; + cursor: pointer; + transition: background $transition-fast; + white-space: nowrap; + + &:hover { + background: $neutral-medium; + color: $neutral-white; + } + + input[type="file"] { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; + width: 100%; + + &:disabled { + cursor: not-allowed; + } + } + } +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: $spacing-sm; + margin-top: $spacing-md; +} + +.btn-save { + @include button-style($primary-blue, $neutral-white, $primary-blue-light); + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} + +.btn-cancel { + background: $neutral-light; + color: $neutral-dark; + border: none; + border-radius: $radius-md; + padding: $spacing-sm $spacing-lg; + font-size: 0.95rem; + cursor: pointer; + transition: background $transition-fast; + + &:hover { + background: $neutral-medium; + color: $neutral-white; + } +} diff --git a/src/app/admin/admin-station-form.component.ts b/src/app/admin/admin-station-form.component.ts new file mode 100644 index 0000000..e678a2f --- /dev/null +++ b/src/app/admin/admin-station-form.component.ts @@ -0,0 +1,161 @@ +import { Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { firstValueFrom } from 'rxjs'; + +import { StationConfig } from '../interfaces/station-config'; +import { StationConfigService } from '../services/station-config.service'; + +@Component({ + selector: 'app-admin-station-form', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './admin-station-form.component.html', + styleUrl: './admin-station-form.component.scss', +}) +export class AdminStationFormComponent implements OnChanges { + private stationConfigService = inject(StationConfigService); + + @Input() editItem: StationConfig | null = null; + @Output() saved = new EventEmitter(); + @Output() closed = new EventEmitter(); + + callsign = ''; + name_primary = ''; + name_secondary = ''; + tagline = ''; + frequency = ''; + founded_year = 0; + nonprofit_type = ''; + ein = ''; + logo_url = ''; + hero_background_url = ''; + hero_icon_url = ''; + hero_divider_url = ''; + address = ''; + phone = ''; + email = ''; + website = ''; + + loading = false; + saving = false; + error = ''; + success = ''; + + ngOnChanges(changes: SimpleChanges): void { + if (changes['editItem'] && this.editItem) { + this.populate(this.editItem); + } + } + + /** Populate form with existing station config. */ + populate(config: StationConfig): void { + this.callsign = config.callsign; + this.name_primary = config.name_primary; + this.name_secondary = config.name_secondary; + this.tagline = config.tagline; + this.frequency = config.frequency; + this.founded_year = config.founded_year; + this.nonprofit_type = config.nonprofit_type; + this.ein = config.ein; + this.logo_url = config.logo_url; + this.hero_background_url = config.hero_background_url; + this.hero_icon_url = config.hero_icon_url; + this.hero_divider_url = config.hero_divider_url; + this.address = config.address; + this.phone = config.phone; + this.email = config.email; + this.website = config.website; + } + + /** Reset form with current service values. */ + reset(): void { + const config = this.stationConfigService.config(); + this.populate(config); + this.error = ''; + this.success = ''; + this.loading = false; + } + + /** Close the dialog without saving. */ + onClose(): void { + this.closed.emit(); + this.reset(); + } + + formatError(err: any): string { + if (err?.error?.detail) return err.error.detail; + if (err?.error?.message) return err.error.message; + if (err?.message) return err.message; + return 'Failed to save. Please try again.'; + } + + /** Handle image file upload and set the URL field. */ + async onImageUpload(event: Event, field: string): Promise { + const input = event.target as HTMLInputElement; + if (!input?.files?.length) return; + + const file = input.files[0]; + if (!file.type.startsWith('image/')) { + this.error = 'Only image files are allowed.'; + return; + } + if (file.size > 5 * 1024 * 1024) { + this.error = 'File size exceeds 5 MB limit.'; + return; + } + + this.loading = true; + this.error = ''; + + try { + const url = await this.stationConfigService.uploadImage(file); + (this as any)[field] = url; + this.success = 'Image uploaded successfully.'; + } catch (err: any) { + this.error = this.formatError(err); + } finally { + this.loading = false; + } + } + + async onSubmit(): Promise { + if (!this.name_primary || !this.callsign) { + this.error = 'Callsign and primary name are required.'; + return; + } + + this.saving = true; + this.error = ''; + this.success = ''; + + const payload: Partial = { + callsign: this.callsign, + name_primary: this.name_primary, + name_secondary: this.name_secondary, + tagline: this.tagline, + frequency: this.frequency, + founded_year: this.founded_year, + nonprofit_type: this.nonprofit_type, + ein: this.ein, + logo_url: this.logo_url, + hero_background_url: this.hero_background_url, + hero_icon_url: this.hero_icon_url, + hero_divider_url: this.hero_divider_url, + address: this.address, + phone: this.phone, + email: this.email, + website: this.website, + }; + + try { + await this.stationConfigService.updateConfig(payload); + this.success = 'Station config updated successfully.'; + this.saved.emit(); + } catch (err: any) { + this.error = this.formatError(err); + } finally { + this.saving = false; + } + } +} diff --git a/src/app/admin/admin.component.html b/src/app/admin/admin.component.html index 464d63f..61dc63e 100644 --- a/src/app/admin/admin.component.html +++ b/src/app/admin/admin.component.html @@ -22,6 +22,11 @@ [class.active]="activeTab() === 'tiers'" (click)="activeTab.set('tiers')" >Tiers +
@@ -145,6 +150,56 @@
} } + + @if (activeTab() === 'station') { + @if (stationConfig$ | async; as config) { +
+
+

Station Configuration

+ +
+ +
+
+ Callsign + {{ config.callsign }} +
+
+ Name + {{ config.name_primary }} {{ config.name_secondary }} +
+
+ Tagline + {{ config.tagline }} +
+
+ Frequency + {{ config.frequency }} +
+
+ Founded + {{ config.founded_year }} +
+
+ Nonprofit + {{ config.nonprofit_type }} — EIN {{ config.ein }} +
+
+ Email + {{ config.email }} +
+
+ Phone + {{ config.phone }} +
+
+ Website + {{ config.website }} +
+
+
+ } + }
@@ -157,4 +212,7 @@ @if (showTierForm) { } + @if (showStationForm) { + + }
diff --git a/src/app/admin/admin.component.scss b/src/app/admin/admin.component.scss index 229c900..77a45d2 100644 --- a/src/app/admin/admin.component.scss +++ b/src/app/admin/admin.component.scss @@ -157,6 +157,33 @@ font-size: 1.1rem; } +// ── Station config view ────────────────────────────────── + +.station-config-view { + .config-row { + display: flex; + padding: $spacing-sm 0; + border-bottom: 1px solid $neutral-light; + + &:last-child { + border-bottom: none; + } + } + + .config-label { + font-weight: 600; + color: $neutral-medium; + width: 140px; + flex-shrink: 0; + font-size: 0.9rem; + } + + .config-value { + color: $neutral-dark; + font-size: 0.95rem; + } +} + // ── Responsive ─────────────────────────────────────────── @include responsive(md) { diff --git a/src/app/admin/admin.component.ts b/src/app/admin/admin.component.ts index 1225f8c..18efcd0 100644 --- a/src/app/admin/admin.component.ts +++ b/src/app/admin/admin.component.ts @@ -6,14 +6,17 @@ import { BehaviorSubject, firstValueFrom } from 'rxjs'; import { Program } from '../interfaces/program'; import { Event } from '../interfaces/event'; import { Tier } from '../interfaces/tier'; +import { StationConfig } from '../interfaces/station-config'; import { ProgramService } from '../services/program.service'; import { EventService } from '../services/event.service'; import { TierService } from '../services/tier.service'; +import { StationConfigService } from '../services/station-config.service'; import { AdminProgramFormComponent } from './admin-program-form.component'; import { AdminEventFormComponent } from './admin-event-form.component'; import { AdminTierFormComponent } from './admin-tier-form.component'; +import { AdminStationFormComponent } from './admin-station-form.component'; -type TabKey = 'programs' | 'events' | 'tiers'; +type TabKey = 'programs' | 'events' | 'tiers' | 'station'; @Component({ selector: 'app-admin', @@ -25,6 +28,7 @@ type TabKey = 'programs' | 'events' | 'tiers'; AdminProgramFormComponent, AdminEventFormComponent, AdminTierFormComponent, + AdminStationFormComponent, ], templateUrl: './admin.component.html', styleUrl: './admin.component.scss', @@ -33,6 +37,7 @@ export class AdminComponent implements OnInit { private programService = inject(ProgramService); private eventService = inject(EventService); private tierService = inject(TierService); + private stationConfigService = inject(StationConfigService); activeTab = signal('programs'); @@ -45,10 +50,12 @@ export class AdminComponent implements OnInit { readonly programs$ = new BehaviorSubject([]); readonly events$ = new BehaviorSubject([]); readonly tiers$ = new BehaviorSubject([]); + readonly stationConfig$ = new BehaviorSubject(null); showProgramForm = false; showEventForm = false; showTierForm = false; + showStationForm = false; ngOnInit(): void { this.loadAll(); @@ -69,11 +76,17 @@ export class AdminComponent implements OnInit { this.tiers$.next(data); } + async reloadStationConfig(): Promise { + await this.stationConfigService.load(); + this.stationConfig$.next(this.stationConfigService.config()); + } + async loadAll(): Promise { await Promise.all([ this.reloadPrograms(), this.reloadEvents(), this.reloadTiers(), + this.reloadStationConfig(), ]); } @@ -163,4 +176,19 @@ export class AdminComponent implements OnInit { await firstValueFrom(this.tierService.deleteTier(id)); await this.reloadTiers(); } + + // ── Station Config ────────────────────────────────────── + + openStationForm(): void { + this.showStationForm = true; + } + + onStationSaved(): void { + this.reloadStationConfig(); + this.showStationForm = false; + } + + onStationClosed(): void { + this.showStationForm = false; + } } diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 4f19d13..a59b476 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,4 +1,4 @@ -import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { APP_INITIALIZER, ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { provideRouter } from '@angular/router'; @@ -8,6 +8,16 @@ import { appConfigInitProvider, appConfigProvider, } from './services/app-config.service'; +import { StationConfigService } from './services/station-config.service'; + +export const stationConfigInitProvider = { + provide: APP_INITIALIZER, + useFactory: (stationConfigService: StationConfigService) => { + return () => stationConfigService.load(); + }, + deps: [StationConfigService], + multi: true, +}; export const appConfig: ApplicationConfig = { providers: [ @@ -16,5 +26,6 @@ export const appConfig: ApplicationConfig = { provideHttpClient(withInterceptors([authInterceptor])), appConfigProvider, appConfigInitProvider, + stationConfigInitProvider, ], }; diff --git a/src/app/contact/contact.component.html b/src/app/contact/contact.component.html index f2660e2..ae1a6ed 100644 --- a/src/app/contact/contact.component.html +++ b/src/app/contact/contact.component.html @@ -45,19 +45,19 @@

Studio Location

-

42 Pine Street
Mountain View, CO 80424

+

Phone

-

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

+

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

Frequencies

-

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

+

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

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