working 3 tier approach

This commit is contained in:
2026-06-11 22:34:55 -07:00
parent 4482a30793
commit e5fef8b181
47 changed files with 1288 additions and 200 deletions
+13 -8
View File
@@ -1,20 +1,25 @@
{ {
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "ng serve", "name": "Backend (FastAPI)",
"type": "chrome", "type": "python",
"request": "launch", "request": "launch",
"preLaunchTask": "npm: start", "module": "uvicorn",
"url": "http://localhost:4200/" "args": ["app.main:app", "--reload", "--host", "0.0.0.0", "--port", "8000"],
"cwd": "${workspaceFolder}/backend",
"env": {
"DATABASE_URL": "postgresql+asyncpg://postgres:postgres@localhost:5432/kmountain"
},
"console": "integratedTerminal"
}, },
{ {
"name": "ng test", "name": "Frontend (ng serve)",
"type": "chrome", "type": "chrome",
"request": "launch", "request": "launch",
"preLaunchTask": "npm: test", "url": "http://localhost:4200",
"url": "http://localhost:9876/debug.html" "webRoot": "${workspaceFolder}/src",
"preLaunchTask": "Start Frontend"
} }
] ]
} }
+17
View File
@@ -0,0 +1,17 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"python.defaultInterpreterPath": "${workspaceFolder}/backend/.venv/bin/python",
"python.terminal.activateEnvironment": true,
"editor.formatOnSave": true,
"editor.tabSize": 2,
"[html]": {
"editor.defaultFormatter": "vscode.html-language-features"
},
"[typescript]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
},
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter",
"editor.tabSize": 4
}
}
+48 -28
View File
@@ -1,42 +1,62 @@
{ {
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0", "version": "2.0.0",
"tasks": [ "tasks": [
{ {
"type": "npm", "label": "Start Backend",
"script": "start", "type": "shell",
"isBackground": true, "command": "cd ${workspaceFolder}/backend && uvicorn app.main:app --reload --host 0.0.0.0 --port 8000",
"problemMatcher": { "group": "build",
"owner": "typescript", "presentation": {
"pattern": "$tsc", "reveal": "always",
"background": { "panel": "dedicated"
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
}, },
"endsPattern": { "problemMatcher": [],
"regexp": "bundle generation (complete|failed)" "runOptions": {
} "instanceLimit": 1
}
} }
}, },
{ {
"type": "npm", "label": "Start Frontend",
"script": "test", "type": "shell",
"isBackground": true, "command": "ng serve",
"problemMatcher": { "portAction": "open",
"owner": "typescript", "portPattern": {
"pattern": "$tsc", "startBody": 4200
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
}, },
"endsPattern": { "group": "build",
"regexp": "bundle generation (complete|failed)" "presentation": {
} "reveal": "always",
"panel": "dedicated"
},
"problemMatcher": ["$ng-cli-warn"],
"runOptions": {
"instanceLimit": 1
} }
},
{
"label": "Docker Up",
"type": "shell",
"command": "docker compose up --build -d",
"group": "build",
"presentation": {
"reveal": "always",
"panel": "dedicated"
},
"problemMatcher": [],
"runOptions": {
"instanceLimit": 1
} }
},
{
"label": "Docker Down",
"type": "shell",
"command": "docker compose down",
"group": "build",
"presentation": {
"reveal": "always",
"panel": "dedicated"
},
"problemMatcher": []
} }
] ]
} }
+14
View File
@@ -0,0 +1,14 @@
# ── Build stage ───────────────────────────────────────────────────────
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx ng build --configuration production
# ── Serve stage ────────────────────────────────────────────────────────
FROM docker.io/library/nginx:1.30-perl
COPY --from=build /app/dist/radio-station/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+18
View File
@@ -0,0 +1,18 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "FastAPI (uvicorn)",
"type": "python",
"request": "launch",
"module": "uvicorn",
"args": ["app.main:app", "--reload", "--host", "0.0.0.0", "--port", "8000"],
"cwd": "${workspaceFolder}",
"env": {
"DATABASE_URL": "postgresql+asyncpg://postgres:postgres@localhost:5432/kmountain"
},
"django": false,
"console": "integratedTerminal"
}
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
"python.linting.enabled": true,
"python.linting.flake8Enabled": true,
"python.linting.pylanceEnabled": true,
"python.formatting.provider": "black",
"editor.formatOnSave": true,
"editor.tabSize": 4,
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter",
"editor.tabSize": 4
}
}
+12
View File
@@ -0,0 +1,12 @@
FROM docker.io/library/python:3.12-slim AS base
WORKDIR /app
RUN pip install --no-cache-dir gunicorn uvicorn psycopg2-binary
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
Binary file not shown.
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+70
View File
@@ -0,0 +1,70 @@
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_session
from app.models import Event
from app.schemas import EventCreate, EventUpdate, EventResponse
router = APIRouter()
@router.get("/", response_model=list[EventResponse])
async def list_events(
active: Optional[bool] = Query(None, description="Filter by active status"),
session: AsyncSession = Depends(get_session),
):
query = select(Event).order_by(Event.date)
if active is not None:
query = query.where(Event.active == active)
result = await session.execute(query)
return list(result.scalars().all())
@router.get("/{event_id}", response_model=EventResponse)
async def get_event(event_id: int, session: AsyncSession = Depends(get_session)):
result = await session.execute(select(Event).where(Event.id == event_id))
event = result.scalar_one_or_none()
if not event:
return {"error": "Event not found"}
return event
@router.post("/", response_model=EventResponse, status_code=201)
async def create_event(
payload: EventCreate, session: AsyncSession = Depends(get_session)
):
event = Event(**payload.model_dump(), active=True)
session.add(event)
await session.commit()
await session.refresh(event)
return event
@router.put("/{event_id}", response_model=EventResponse)
async def update_event(
event_id: int,
payload: EventUpdate,
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(Event).where(Event.id == event_id))
event = result.scalar_one_or_none()
if not event:
return {"error": "Event not found"}
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(event, key, value)
await session.commit()
await session.refresh(event)
return event
@router.delete("/{event_id}", status_code=204)
async def delete_event(event_id: int, session: AsyncSession = Depends(get_session)):
result = await session.execute(select(Event).where(Event.id == event_id))
event = result.scalar_one_or_none()
if not event:
return {"error": "Event not found"}
await session.delete(event)
await session.commit()
+75
View File
@@ -0,0 +1,75 @@
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_session
from app.models import Program
from app.schemas import ProgramCreate, ProgramUpdate, ProgramResponse
router = APIRouter()
def _day_filter(day: int):
"""Return the SQLAlchemy filter for a given day_of_week."""
return Program.day_of_week == day
@router.get("/", response_model=list[ProgramResponse])
async def list_programs(
day: Optional[int] = Query(None, description="Filter by day_of_week (1=Mon … 7=Sun)"),
session: AsyncSession = Depends(get_session),
):
query = select(Program).order_by(Program.day_of_week, Program.time)
if day is not None:
query = query.filter(_day_filter(day))
result = await session.execute(query)
return list(result.scalars().all())
@router.get("/{program_id}", response_model=ProgramResponse)
async def get_program(program_id: int, session: AsyncSession = Depends(get_session)):
result = await session.execute(select(Program).where(Program.id == program_id))
program = result.scalar_one_or_none()
if not program:
return {"error": "Program not found"}
return program
@router.post("/", response_model=ProgramResponse, status_code=201)
async def create_program(
payload: ProgramCreate, session: AsyncSession = Depends(get_session)
):
program = Program(**payload.model_dump())
session.add(program)
await session.commit()
await session.refresh(program)
return program
@router.put("/{program_id}", response_model=ProgramResponse)
async def update_program(
program_id: int,
payload: ProgramUpdate,
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(Program).where(Program.id == program_id))
program = result.scalar_one_or_none()
if not program:
return {"error": "Program not found"}
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(program, key, value)
await session.commit()
await session.refresh(program)
return program
@router.delete("/{program_id}", status_code=204)
async def delete_program(program_id: int, session: AsyncSession = Depends(get_session)):
result = await session.execute(select(Program).where(Program.id == program_id))
program = result.scalar_one_or_none()
if not program:
return {"error": "Program not found"}
await session.delete(program)
await session.commit()
+66
View File
@@ -0,0 +1,66 @@
from typing import Optional
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_session
from app.models import DonationTier
from app.schemas import TierCreate, TierUpdate, TierResponse
router = APIRouter()
@router.get("/", response_model=list[TierResponse])
async def list_tiers(session: AsyncSession = Depends(get_session)):
result = await session.execute(
select(DonationTier).order_by(DonationTier.display_order)
)
return list(result.scalars().all())
@router.get("/{tier_id}", response_model=TierResponse)
async def get_tier(tier_id: int, session: AsyncSession = Depends(get_session)):
result = await session.execute(select(DonationTier).where(DonationTier.id == tier_id))
tier = result.scalar_one_or_none()
if not tier:
return {"error": "Tier not found"}
return tier
@router.post("/", response_model=TierResponse, status_code=201)
async def create_tier(
payload: TierCreate, session: AsyncSession = Depends(get_session)
):
tier = DonationTier(**payload.model_dump())
session.add(tier)
await session.commit()
await session.refresh(tier)
return tier
@router.put("/{tier_id}", response_model=TierResponse)
async def update_tier(
tier_id: int,
payload: TierUpdate,
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(DonationTier).where(DonationTier.id == tier_id))
tier = result.scalar_one_or_none()
if not tier:
return {"error": "Tier not found"}
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(tier, key, value)
await session.commit()
await session.refresh(tier)
return tier
@router.delete("/{tier_id}", status_code=204)
async def delete_tier(tier_id: int, session: AsyncSession = Depends(get_session)):
result = await session.execute(select(DonationTier).where(DonationTier.id == tier_id))
tier = result.scalar_one_or_none()
if not tier:
return {"error": "Tier not found"}
await session.delete(tier)
await session.commit()
+11
View File
@@ -0,0 +1,11 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/kmountain"
CORS_ORIGINS: list[str] = ["http://localhost:4200", "http://localhost:3000"]
model_config = {"env_prefix": "KMTN_"}
settings = Settings()
+11
View File
@@ -0,0 +1,11 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=False)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def get_session() -> AsyncSession:
async with async_session() as session:
yield session
+38
View File
@@ -0,0 +1,38 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.database import engine
from app.models import Base
from app.api import programs, events, tiers
@asynccontextmanager
async def lifespan(app: FastAPI):
# Create tables on startup (seed.py handles data seeding)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
app = FastAPI(
title="KMountain Flower Radio API",
description="Backend API for the KMountain Flower Radio Station website.",
version="1.0.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Register routers
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"])
+51
View File
@@ -0,0 +1,51 @@
from sqlalchemy import (
Column,
Integer,
String,
Text,
Numeric,
Boolean,
Date,
ARRAY,
)
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
class Program(Base):
__tablename__ = "programs"
id = Column(Integer, primary_key=True, autoincrement=True)
day_of_week = Column(Integer, nullable=False) # 1=Mon … 7=Sun
day_label = Column(String(10), nullable=False) # "Monday", …
time = Column(String(10), nullable=False) # "6:00 AM"
title = Column(String(200), nullable=False)
host = Column(String(100), nullable=False)
genre = Column(String(80), nullable=False)
class Event(Base):
__tablename__ = "events"
id = Column(Integer, primary_key=True, autoincrement=True)
date = Column(Date, nullable=False)
title = Column(String(200), nullable=False)
description = Column(Text, nullable=False)
location = Column(String(200), nullable=False)
icon = Column(String(10), nullable=False)
rsvp_url = Column(String(500), nullable=True)
active = Column(Boolean, default=True)
class DonationTier(Base):
__tablename__ = "donation_tiers"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(50), unique=True, nullable=False)
amount = Column(Numeric(10, 2), nullable=False)
icon = Column(String(10), nullable=False)
benefits = Column(ARRAY(Text), nullable=False)
display_order = Column(Integer, nullable=False)
+99
View File
@@ -0,0 +1,99 @@
from datetime import date
from typing import Optional
from pydantic import BaseModel, field_validator
# ── Program ──────────────────────────────────────────────
class ProgramCreate(BaseModel):
day_of_week: int
day_label: str
time: str
title: str
host: str
genre: str
class ProgramUpdate(BaseModel):
day_of_week: Optional[int] = None
day_label: Optional[str] = None
time: Optional[str] = None
title: Optional[str] = None
host: Optional[str] = None
genre: Optional[str] = None
class ProgramResponse(BaseModel):
id: int
day_of_week: int
day_label: str
time: str
title: str
host: str
genre: str
model_config = {"from_attributes": True}
# ── Event ────────────────────────────────────────────────
class EventCreate(BaseModel):
date: date
title: str
description: str
location: str
icon: str
rsvp_url: Optional[str] = None
class EventUpdate(BaseModel):
date: Optional[date] = None
title: Optional[str] = None
description: Optional[str] = None
location: Optional[str] = None
icon: Optional[str] = None
rsvp_url: Optional[str] = None
active: Optional[bool] = None
class EventResponse(BaseModel):
id: int
date: date
title: str
description: str
location: str
icon: str
rsvp_url: Optional[str]
active: bool
model_config = {"from_attributes": True}
# ── DonationTier ─────────────────────────────────────────
class TierCreate(BaseModel):
name: str
amount: float
icon: str
benefits: list[str]
display_order: int
class TierUpdate(BaseModel):
name: Optional[str] = None
amount: Optional[float] = None
icon: Optional[str] = None
benefits: Optional[list[str]] = None
display_order: Optional[int] = None
class TierResponse(BaseModel):
id: int
name: str
amount: float
icon: str
benefits: list[str]
display_order: int
model_config = {"from_attributes": True}
+9
View File
@@ -0,0 +1,9 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
psycopg2-binary==2.9.10
asyncpg==0.30.0
sqlalchemy[asyncio]==2.0.36
alembic==1.14.1
python-multipart==0.0.20
pydantic==2.10.4
pydantic-settings==2.7.1
+118
View File
@@ -0,0 +1,118 @@
"""
Seed script: populates the database with station data.
Usage:
DATABASE_URL=postgresql://... python -m app.seed
(or from the repo root: cd backend && python seed.py)
"""
import asyncio
from datetime import date
from app.database import async_session
from app.models import Program, Event, DonationTier
# ── Seed data ──────────────────────────────────────────────
PROGRAMS: list[dict] = [
{"day_of_week": 1, "day_label": "Monday", "time": "6:00 AM", "title": "Morning Mountain Mist", "host": "Diana Walsh", "genre": "Ambient / Nature"},
{"day_of_week": 1, "day_label": "Monday", "time": "8:00 AM", "title": "Community Roundup", "host": "Tom Breen", "genre": "News / Talk"},
{"day_of_week": 1, "day_label": "Monday", "time": "10:00 AM", "title": "Bluegrass Trails", "host": "Sarah Lynn", "genre": "Bluegrass"},
{"day_of_week": 1, "day_label": "Monday", "time": "12:00 PM", "title": "Lunchtime Jazz", "host": "Mike Darrow", "genre": "Jazz"},
{"day_of_week": 1, "day_label": "Monday", "time": "2:00 PM", "title": "Folk Roots Hour", "host": "Nadia Cole", "genre": "Folk"},
{"day_of_week": 1, "day_label": "Monday", "time": "4:00 PM", "title": "Afternoon Acoustics", "host": "Jen Reeves", "genre": "Acoustic"},
{"day_of_week": 1, "day_label": "Monday", "time": "6:00 PM", "title": "Evening Echoes", "host": "Carlos Mendez", "genre": "Indie / Alternative"},
{"day_of_week": 1, "day_label": "Monday", "time": "8:00 PM", "title": "Classical Mountains", "host": "Elena Cross", "genre": "Classical"},
{"day_of_week": 1, "day_label": "Monday", "time": "10:00 PM", "title": "Night Owl Session", "host": "DJ Kofi", "genre": "Electronic"},
{"day_of_week": 1, "day_label": "Monday", "time": "12:00 AM", "title": "Late Night Jazz", "host": "Mike Darrow", "genre": "Jazz"},
]
EVENTS: list[dict] = [
{
"date": date(2026, 7, 12),
"title": "Summer Sounds Festival",
"description": "A full day of live local music, community booths, and station meet-and-greet at the foothills park. Free admission — donations welcome.",
"location": "Foothills Community Park",
"icon": "🎵",
},
{
"date": date(2026, 8, 5),
"title": "Annual Fundraiser Gala",
"description": "Our biggest fundraiser of the year! Enjoy dinner, silent auction, and performances from past guest artists.",
"location": "Mountain View Ballroom",
"icon": "🎙️",
},
{
"date": date(2026, 9, 18),
"title": "Volunteer Open House",
"description": "Tour the studio, meet the team, and learn how you can help keep our free stream alive. New volunteers always welcome!",
"location": "KMTN Studio — 42 Pine St",
"icon": "🤝",
},
{
"date": date(2026, 10, 30),
"title": "Harvest Moon Broadcast",
"description": "Special live broadcast from the harvest moon gathering. Featuring folk, bluegrass, and indigenous music.",
"location": "Ridgefield Amphitheater",
"icon": "🌙",
},
]
TIERS: list[dict] = [
{
"name": "Seed",
"amount": 5.00,
"icon": "🌱",
"benefits": ["Digital thank-you card", "Name listed on our website", "Quarterly newsletter"],
"display_order": 1,
},
{
"name": "Stream",
"amount": 15.00,
"icon": "🎵",
"benefits": ["All Seed benefits", "KMountain Flower pin", "Priority show-request line", "Annual station update call"],
"display_order": 2,
},
{
"name": "Ridge",
"amount": 30.00,
"icon": "🏔️",
"benefits": ["All Stream benefits", "Official member patch", "Exclusive show invitations", "Annual station tour"],
"display_order": 3,
},
{
"name": "Summit",
"amount": 60.00,
"icon": "",
"benefits": ["All Ridge benefits", "Featured on the Summit Wall", "Name on-air during anniversary special", "Lifetime patron recognition"],
"display_order": 4,
},
]
# ── Main ──────────────────────────────────────────────────
async def seed():
async with async_session() as session:
# Programs
for data in PROGRAMS:
session.add(Program(**data))
await session.commit()
print(f" ✓ Seeded {len(PROGRAMS)} programs")
# Events
for data in EVENTS:
event = Event(**data, active=True)
session.add(event)
await session.commit()
print(f" ✓ Seeded {len(EVENTS)} events")
# Tiers
for data in TIERS:
session.add(DonationTier(**data))
await session.commit()
print(f" ✓ Seeded {len(TIERS)} donation tiers")
if __name__ == "__main__":
asyncio.run(seed())
+42
View File
@@ -0,0 +1,42 @@
services:
db:
image: docker.io/library/postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: kmountain
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
api:
build:
context: ./backend
dockerfile: Dockerfile
restart: unless-stopped
environment:
KMTN_DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/kmountain
KMTN_CORS_ORIGINS: '["http://localhost:4200"]'
ports:
- "8000:8000"
depends_on:
db:
condition: service_healthy
frontend:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "4200:80"
depends_on:
- api
volumes:
pgdata:
+26
View File
@@ -0,0 +1,26 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA fallback — Angular router handles its own paths
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API calls to the backend service (only when running inside Docker)
location /api/ {
# When the env var is set, nginx resolves "api" to the backend container.
# In dev (HOST_MODE=dev) we proxy to localhost:8000 instead.
if ($host = localhost) {
# Only proxy when the port matches our dev setup
# See: https://serverfault.com/questions/777768/conditional-proxy-pass-in-nginx
}
proxy_pass http://api:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+6 -1
View File
@@ -1,8 +1,13 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { routes } from './app.routes'; import { routes } from './app.routes';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)], providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(),
],
}; };
+10 -4
View File
@@ -17,6 +17,11 @@
<!-- Donation tiers --> <!-- Donation tiers -->
<div class="donate-tiers"> <div class="donate-tiers">
<h3>Choose Your Level of Support</h3> <h3>Choose Your Level of Support</h3>
@if (loading) {
<div class="schedule-loading">Loading tiers&hellip;</div>
} @else if (tiers.length === 0) {
<div class="schedule-empty">No donation tiers available. Check back soon!</div>
} @else {
<div class="tiers-grid"> <div class="tiers-grid">
<div class="tier-card" *ngFor="let tier of tiers"> <div class="tier-card" *ngFor="let tier of tiers">
<div class="tier-icon">{{ tier.icon }}</div> <div class="tier-icon">{{ tier.icon }}</div>
@@ -29,6 +34,7 @@
<a href="#" class="btn btn-tier">Select {{ tier.name }}</a> <a href="#" class="btn btn-tier">Select {{ tier.name }}</a>
</div> </div>
</div> </div>
}
</div> </div>
<!-- One-time donation --> <!-- One-time donation -->
@@ -48,22 +54,22 @@
<div class="donate-why"> <div class="donate-why">
<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">&#x1F4FB;</span>
<strong>98.7 FM Signal</strong> <strong>98.7 FM 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">
<span class="why-icon">🎙️</span> <span class="why-icon">&#x1F399;</span>
<strong>24/7 On-Air</strong> <strong>24/7 On-Air</strong>
<p>Over 15,000 hours of programming per year</p> <p>Over 15,000 hours of programming per year</p>
</div> </div>
<div class="why-item"> <div class="why-item">
<span class="why-icon">🌍</span> <span class="why-icon">&#x1F30D;</span>
<strong>Worldwide Stream</strong> <strong>Worldwide Stream</strong>
<p>Accessible from every corner of the globe, free forever</p> <p>Accessible from every corner of the globe, free forever</p>
</div> </div>
<div class="why-item"> <div class="why-item">
<span class="why-icon">🎓</span> <span class="why-icon">&#x1F393;</span>
<strong>Youth Programs</strong> <strong>Youth Programs</strong>
<p>Free radio training for 200+ local students each year</p> <p>Free radio training for 200+ local students each year</p>
</div> </div>
+19 -51
View File
@@ -1,12 +1,8 @@
import { Component } from '@angular/core'; import { Component, inject, OnInit } from '@angular/core';
import { NgFor } from '@angular/common'; import { NgFor } from '@angular/common';
interface Tier { import { TierService } from '../services/tier.service';
name: string; import { Tier } from '../interfaces/tier';
amount: number;
icon: string;
benefits: string[];
}
@Component({ @Component({
selector: 'app-donate', selector: 'app-donate',
@@ -15,50 +11,22 @@ interface Tier {
templateUrl: './donate.component.html', templateUrl: './donate.component.html',
styleUrl: './donate.component.scss', styleUrl: './donate.component.scss',
}) })
export class DonateComponent { export class DonateComponent implements OnInit {
readonly tiers: Tier[] = [ private tierService = inject(TierService);
{
name: 'Seed', tiers: Tier[] = [];
amount: 5, loading = true;
icon: '🌱',
benefits: [ ngOnInit(): void {
'Digital thank-you card', this.tierService.getTiers().subscribe({
'Name listed on our website', next: (data) => {
'Quarterly newsletter', this.tiers = data;
], this.loading = false;
}, },
{ error: () => {
name: 'Stream', this.loading = false;
amount: 15, this.tiers = [];
icon: '🎵',
benefits: [
'All Seed benefits',
'KMountain Flower pin',
'Priority show-request line',
'Annual station update call',
],
}, },
{ });
name: 'Ridge', }
amount: 30,
icon: '🏔️',
benefits: [
'All Stream benefits',
'Official member patch',
'Exclusive show invitations',
'Annual station tour',
],
},
{
name: 'Summit',
amount: 60,
icon: '⭐',
benefits: [
'All Ridge benefits',
'Featured on the Summit Wall',
'Name on-air during anniversary special',
'Lifetime patron recognition',
],
},
];
} }
+15 -5
View File
@@ -10,25 +10,35 @@
</p> </p>
</div> </div>
@if (loading) {
<div class="schedule-loading">Loading events&hellip;</div>
} @else if (upcomingEvents.length === 0) {
<div class="schedule-empty">No upcoming events — check back later!</div>
} @else {
<div class="events-list"> <div class="events-list">
<div class="event-card" *ngFor="let event of upcomingEvents"> <div class="event-card" *ngFor="let event of upcomingEvents">
<div class="event-date"> <div class="event-date">
<span class="event-month">Jul</span> <span class="event-month">{{ event.date | date:'MMM' }}</span>
<span class="event-day">12</span> <span class="event-day">{{ event.date | date:'d' }}</span>
<span class="event-year">2026</span> <span class="event-year">{{ event.date | date:'yyyy' }}</span>
</div> </div>
<div class="event-content"> <div class="event-content">
<h3>{{ event.title }}</h3> <h3>{{ event.title }}</h3>
<p>{{ event.description }}</p> <p>{{ event.description }}</p>
<div class="event-meta"> <div class="event-meta">
<span class="event-location"> <span class="event-location">
<span aria-hidden="true">📍</span> {{ event.location }} <span aria-hidden="true">&#x1F4CD;</span> {{ event.location }}
</span> </span>
<a href="#" class="event-rsvp">Learn More →</a> @if (event.rsvp_url) {
<a [href]="event.rsvp_url" class="event-rsvp" target="_blank" rel="noopener">RSVP &rarr;</a>
} @else {
<a href="#" class="event-rsvp">Learn More &rarr;</a>
}
</div> </div>
</div> </div>
<div class="event-icon" aria-hidden="true">{{ event.icon }}</div> <div class="event-icon" aria-hidden="true">{{ event.icon }}</div>
</div> </div>
</div> </div>
}
</div> </div>
</section> </section>
+21 -39
View File
@@ -1,50 +1,32 @@
import { Component } from '@angular/core'; import { Component, inject, OnInit } from '@angular/core';
import { NgFor } from '@angular/common'; import { DatePipe, NgFor } from '@angular/common';
interface Event { import { EventService } from '../services/event.service';
date: string; import { Event } from '../interfaces/event';
title: string;
description: string;
location: string;
icon: string;
}
@Component({ @Component({
selector: 'app-events', selector: 'app-events',
standalone: true, standalone: true,
imports: [NgFor], imports: [DatePipe, NgFor],
templateUrl: './events.component.html', templateUrl: './events.component.html',
styleUrl: './events.component.scss', styleUrl: './events.component.scss',
}) })
export class EventsComponent { export class EventsComponent implements OnInit {
readonly upcomingEvents: Event[] = [ private eventService = inject(EventService);
{
date: 'July 12, 2026', upcomingEvents: Event[] = [];
title: 'Summer Sounds Festival', loading = true;
description: 'A full day of live local music, community booths, and station meet-and-greet at the foothills park. Free admission — donations welcome.',
location: 'Foothills Community Park', ngOnInit(): void {
icon: '🎵', this.eventService.getEvents(true).subscribe({
next: (data) => {
this.upcomingEvents = data;
this.loading = false;
}, },
{ error: () => {
date: 'August 5, 2026', this.loading = false;
title: 'Annual Fundraiser Gala', this.upcomingEvents = [];
description: 'Our biggest fundraiser of the year! Enjoy dinner, silent auction, and performances from past guest artists.',
location: 'Mountain View Ballroom',
icon: '🎙️',
}, },
{ });
date: 'September 18, 2026', }
title: 'Volunteer Open House',
description: 'Tour the studio, meet the team, and learn how you can help keep our free stream alive. New volunteers always welcome!',
location: 'KMTN Studio — 42 Pine St',
icon: '🤝',
},
{
date: 'October 30, 2026',
title: 'Harvest Moon Broadcast',
description: 'Special live broadcast from the harvest moon gathering. Featuring folk, bluegrass, and indigenous music.',
location: 'Ridgefield Amphitheater',
icon: '🌙',
},
];
} }
+10
View File
@@ -0,0 +1,10 @@
export interface Event {
id: number;
date: string; // YYYY-MM-DD from backend
title: string;
description: string;
location: string;
icon: string;
rsvp_url: string | null;
active: boolean;
}
+9
View File
@@ -0,0 +1,9 @@
export interface Program {
id: number;
day_of_week: number;
day_label: string;
time: string;
title: string;
host: string;
genre: string;
}
+8
View File
@@ -0,0 +1,8 @@
export interface Tier {
id: number;
name: string;
amount: number;
icon: string;
benefits: string[];
display_order: number;
}
+7 -1
View File
@@ -9,6 +9,11 @@
</p> </p>
</div> </div>
@if (loading) {
<div class="schedule-loading">Loading schedule&hellip;</div>
} @else if (programs.length === 0) {
<div class="schedule-empty">No programs scheduled. Check back soon!</div>
} @else {
<div class="schedule-list"> <div class="schedule-list">
<div class="schedule-day-label">Monday Friday</div> <div class="schedule-day-label">Monday Friday</div>
<div class="schedule-table"> <div class="schedule-table">
@@ -19,7 +24,7 @@
<h4 class="schedule-title">{{ program.title }}</h4> <h4 class="schedule-title">{{ program.title }}</h4>
<div class="schedule-meta"> <div class="schedule-meta">
<span class="schedule-host"> <span class="schedule-host">
<span aria-hidden="true">🎙️</span> {{ program.host }} <span aria-hidden="true">&#x1F399;</span> {{ program.host }}
</span> </span>
<span class="schedule-genre">{{ program.genre }}</span> <span class="schedule-genre">{{ program.genre }}</span>
</div> </div>
@@ -27,6 +32,7 @@
</div> </div>
</div> </div>
</div> </div>
}
<div class="schedule-weekend"> <div class="schedule-weekend">
<div class="weekend-heading"> <div class="weekend-heading">
+21 -20
View File
@@ -1,12 +1,8 @@
import { Component } from '@angular/core'; import { Component, inject, OnInit } from '@angular/core';
import { NgFor } from '@angular/common'; import { NgFor } from '@angular/common';
interface Program { import { ProgramService } from '../services/program.service';
time: string; import { Program } from '../interfaces/program';
title: string;
host: string;
genre: string;
}
@Component({ @Component({
selector: 'app-schedule', selector: 'app-schedule',
@@ -15,17 +11,22 @@ interface Program {
templateUrl: './schedule.component.html', templateUrl: './schedule.component.html',
styleUrl: './schedule.component.scss', styleUrl: './schedule.component.scss',
}) })
export class ScheduleComponent { export class ScheduleComponent implements OnInit {
readonly programs: Program[] = [ private programService = inject(ProgramService);
{ time: '6:00 AM', title: 'Morning Mountain Mist', host: 'Diana Walsh', genre: 'Ambient / Nature' },
{ time: '8:00 AM', title: 'Community Roundup', host: 'Tom Breen', genre: 'News / Talk' }, programs: Program[] = [];
{ time: '10:00 AM', title: 'Bluegrass Trails', host: 'Sarah Lynn', genre: 'Bluegrass' }, loading = true;
{ time: '12:00 PM', title: 'Lunchtime Jazz', host: 'Mike Darrow', genre: 'Jazz' },
{ time: '2:00 PM', title: 'Folk Roots Hour', host: 'Nadia Cole', genre: 'Folk' }, ngOnInit(): void {
{ time: '4:00 PM', title: 'Afternoon Acoustics', host: 'Jen Reeves', genre: 'Acoustic' }, this.programService.getPrograms().subscribe({
{ time: '6:00 PM', title: 'Evening Echoes', host: 'Carlos Mendez', genre: 'Indie / Alternative' }, next: (data) => {
{ time: '8:00 PM', title: 'Classical Mountains', host: 'Elena Cross', genre: 'Classical' }, this.programs = data;
{ time: '10:00 PM', title: 'Night Owl Session', host: 'DJ Kofi', genre: 'Electronic' }, this.loading = false;
{ time: '12:00 AM', title: 'Late Night Jazz', host: 'Mike Darrow', genre: 'Jazz' }, },
]; error: () => {
this.loading = false;
this.programs = [];
},
});
}
} }
+37
View File
@@ -0,0 +1,37 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
import { Event } from '../interfaces/event';
@Injectable({
providedIn: 'root',
})
export class EventService {
private http = inject(HttpClient);
private baseUrl = `${environment.apiBaseUrl}/api/events`;
/** Fetch events, optionally filtered by active status. */
getEvents(active?: boolean): Observable<Event[]> {
let params = new HttpParams();
if (active !== undefined) params = params.set('active', String(active));
return this.http.get<Event[]>(this.baseUrl, { params });
}
getEvent(id: number): Observable<Event> {
return this.http.get<Event>(`${this.baseUrl}/${id}`);
}
createEvent(payload: Omit<Event, 'id'>): Observable<Event> {
return this.http.post<Event>(this.baseUrl, payload);
}
updateEvent(id: number, payload: Partial<Event>): Observable<Event> {
return this.http.put<Event>(`${this.baseUrl}/${id}`, payload);
}
deleteEvent(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
}
+37
View File
@@ -0,0 +1,37 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
import { Program } from '../interfaces/program';
@Injectable({
providedIn: 'root',
})
export class ProgramService {
private http = inject(HttpClient);
private baseUrl = `${environment.apiBaseUrl}/api/programs`;
/** Fetch all programs, optionally filtered by day_of_week (1=Mon … 7=Sun). */
getPrograms(day?: number): Observable<Program[]> {
let params = new HttpParams();
if (day !== undefined) params = params.set('day', String(day));
return this.http.get<Program[]>(this.baseUrl, { params });
}
getProgram(id: number): Observable<Program> {
return this.http.get<Program>(`${this.baseUrl}/${id}`);
}
createProgram(payload: Omit<Program, 'id'>): Observable<Program> {
return this.http.post<Program>(this.baseUrl, payload);
}
updateProgram(id: number, payload: Partial<Program>): Observable<Program> {
return this.http.put<Program>(`${this.baseUrl}/${id}`, payload);
}
deleteProgram(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
}
+35
View File
@@ -0,0 +1,35 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
import { Tier } from '../interfaces/tier';
@Injectable({
providedIn: 'root',
})
export class TierService {
private http = inject(HttpClient);
private baseUrl = `${environment.apiBaseUrl}/api/tiers`;
/** Fetch all tiers ordered by display_order. */
getTiers(): Observable<Tier[]> {
return this.http.get<Tier[]>(this.baseUrl);
}
getTier(id: number): Observable<Tier> {
return this.http.get<Tier>(`${this.baseUrl}/${id}`);
}
createTier(payload: Omit<Tier, 'id'>): Observable<Tier> {
return this.http.post<Tier>(this.baseUrl, payload);
}
updateTier(id: number, payload: Partial<Tier>): Observable<Tier> {
return this.http.put<Tier>(`${this.baseUrl}/${id}`, payload);
}
deleteTier(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
}
+5
View File
@@ -0,0 +1,5 @@
export const environment = {
production: true,
// In production the API is proxied through nginx at /api — no host needed.
apiBaseUrl: '',
};
+4
View File
@@ -0,0 +1,4 @@
export const environment = {
production: false,
apiBaseUrl: 'http://localhost:8000',
};
+243
View File
@@ -0,0 +1,243 @@
# Backend Infrastructure for KMountain Flower Radio Station
## Context
The app currently has a polished frontend with zero backend — all dynamic content (schedule, events, donations) lives as hardcoded `readonly` arrays in components. The contact form just shows an alert. We need to add persistent data, a backend API, and Docker-based deployment while keeping things simple for a volunteer-run community radio station.
## VS Code Workspace Setup
Use a **multi-root workspace** so both projects get their own IDE context, linting, and debugging configs:
```
web_app/ ← repo root (existing)
├── .vscode/
│ ├── settings.json ← workspace settings (shared)
│ ├── tasks.json ← common tasks (start backend, start frontend)
│ └── launch.json ← debug configs for both projects
├── .vscode/workspace.code-workspace ← multi-root workspace file (open this in VS Code)
├── src/ ← existing Angular frontend (unchanged structure)
├── backend/ ← new FastAPI backend
│ ├── .vscode/
│ │ ├── settings.json ← Python/Flake8/Pylance settings
│ │ └── launch.json ← FastAPI debug config
├── docker-compose.yml
├── Dockerfile ← Angular build + nginx serve (at repo root)
└── nginx.conf ← nginx config (at repo root)
```
**Steps:**
1. Create `web_app/.vscode/` directory with shared workspace settings
2. Create `web_app/.vscode/workspace.code-workspace` listing both roots:
```json
{
"folders": [
{ "path": "." },
{ "path": "backend" }
],
"settings": {}
}
```
3. VS Code: File → Open Workspace from File → select `workspace.code-workspace`
4. Backend folder gets Python language server; frontend folder gets Angular TS language server — no conflict
## Decisions (confirmed)
- **Admin panel**: Skip for now. Initial data via a seed script.
- **Contact form**: Leave as-is (alert only, unimplemented for now).
- **Rollout**: Phase by phase.
- **Backend**: FastAPI (Python 3 already in devcontainer, auto-docs, async).
- **Database**: PostgreSQL in Docker (production-ready, any managed host compatible).
- **Frontend serve**: nginx in Docker (build Angular → serve static).
## Target Architecture
```
docker-compose.yml
├── db (postgres:16-alpine) -- data tier, persistent volume
├── api (FastAPI + uvicorn) -- middleware tier, CORS + all endpoints
└── frontend (nginx) -- frontend tier, Angular build + API proxy in prod
```
## Phase 1: Backend + Schedule Page + Docker Skeleton
### 1.0 VS Code workspace setup
1. Create `.vscode/` directory with `workspace.code-workspace`, `settings.json`, `tasks.json`, `launch.json`
2. Create `backend/.vscode/settings.json` (Python language settings)
3. Open `workspace.code-workspace` in VS Code — both roots visible, each with its own language server
4. `tasks.json` provides unified commands: "Start Backend" (uvicorn --reload), "Start Frontend" (ng serve), "Docker Up"
### 1.1 Backend skeleton
Create `backend/` directory:
```
backend/
├── requirements.txt -- fastapi, uvicorn, psycopg2-binary, alembic, python-multipart
├── Dockerfile -- python:3.12-slim, copy requirements, copy app, CMD uvicorn
├── seed.py -- seed 10 weekday programs
└── app/
├── __init__.py
├── main.py -- FastAPI app, lifespan (DB init), CORS middleware
├── config.py -- DATABASE_URL, CORS_ORIGINS from env vars
├── database.py -- async SessionLocal engine
├── models.py -- Program SQLAlchemy model
├── schemas.py -- Program Pydantic schemas (read + create + update)
└── api/
├── __init__.py
└── programs.py -- GET /api/programs (with ?day filter), POST, PUT, DELETE
```
**Program model**: `id, day_of_week (int), day_label (str), time (str), title (str), host (str), genre (str)`
**Seed data**: The 10 existing weekday programs from [schedule.component.ts:19-29](src/app/schedule/schedule.component.ts).
### 1.2 Angular service layer
Modify:
- [src/app/app.config.ts](src/app/app.config.ts) -- add `provideHttpClient()` to providers
- [src/environments/environment.ts](src/environments/environment.ts) -- **new**: `apiBaseUrl` for dev/prod
Create:
- [src/app/interfaces/program.ts](src/app/interfaces/program.ts) -- `Program` interface mirroring backend
- [src/app/services/program.service.ts](src/app/services/program.service.ts) -- `getPrograms()`, `getProgram()`, `createProgram()`, etc.
### 1.3 Convert schedule component
Modify [src/app/schedule/schedule.component.ts](src/app/schedule/schedule.component.ts):
- Remove `readonly programs: Program[]` array
- `inject(ProgramService)` in class body
- Add `programs: Program[] = []` and `loading = true`
- Load data in `ngOnInit()` via service
- Template: add loading/empty states, keep `*ngFor` structure
### 1.4 Docker skeleton
Create:
- [docker-compose.yml](docker-compose.yml) -- db + api services, healthcheck on db, depends_on ordering, named `pgdata` volume
- [backend/Dockerfile](backend/Dockerfile) -- already covered in 1.1
### Phase 1 verification
| Check | How |
|-------|-----|
| Backend runs locally | `cd backend && uvicorn app.main:app --reload` |
| OpenAPI docs at `/docs` | Browse `http://localhost:8000/docs` |
| Seed data present | `curl http://localhost:8000/api/programs` returns 10 items |
| Schedule page fetches from DB | DevTools Network tab shows GET to `/api/programs`, page renders |
| Docker stack | `docker compose up` -- db + api running, schedule renders on frontend |
## Phase 2: Donate + Events Pages
### 2.1 Backend additions
Modify `backend/app/`:
- [models.py](backend/app/models.py) -- add `Event` and `DonationTier` models
- Event: `id, date (DATE), title, description (TEXT), location, icon, rsvp_url (nullable), active (bool)`
- DonationTier: `id, name (UNIQUE), amount (NUMERIC), icon, benefits (TEXT[]), display_order (int)`
- [schemas.py](backend/app/schemas.py) -- add Pydantic schemas for both
- [api/events.py](backend/app/api/events.py) -- GET/POST/PUT/DELETE with `?active` filter
- [api/tiers.py](backend/app/api/tiers.py) -- GET/POST/PUT/DELETE ordered by `display_order`
- [seed.py](backend/seed.py) -- add events and tiers data (from [events.component.ts](src/app/events/events.component.ts) and [donate.component.ts](src/app/donate/donate.component.ts))
### 2.2 Angular additions
Create:
- [src/app/interfaces/event.ts](src/app/interfaces/event.ts)
- [src/app/interfaces/tier.ts](src/app/interfaces/tier.ts)
- [src/app/services/event.service.ts](src/app/services/event.service.ts) -- `getEvents()`, `createEvent()`, etc.
- [src/app/services/tier.service.ts](src/app/services/tier.service.ts) -- `getTiers()` ordered
Modify:
- [src/app/events/events.component.ts](src/app/events/events.component.ts) -- remove readonly array, inject EventService, load in ngOnInit
- [src/app/donate/donate.component.ts](src/app/donate/donate.component.ts) -- remove readonly array, inject TierService, load in ngOnInit
- Keep template structure identical, just swap the data source
### Phase 2 verification
| Check | How |
|-------|-----|
| Events API | `curl http://localhost:8000/api/events?active=true` returns 4 events |
| Tiers API | `curl http://localhost:8000/api/tiers` returns 4 tiers ordered |
| Events page renders from DB | Navigate to /events, verify from DB not hardcoded |
| Donate page renders from DB | Navigate to /donate, verify from DB not hardcoded |
## Phase 3: Frontend Docker + Full Stack
### 3.1 Dockerfile + nginx config
Create at repo root alongside `src/` and `backend/`:
- `Dockerfile` -- multi-stage: node:20-alpine build Angular → nginx:alpine serve the dist output
- `nginx.conf` -- serve Angular static files; in production mode, proxy `/api/*` to api service (avoids CORS in prod)
### 3.2 Update docker-compose.yml
Add frontend service:
```yaml
frontend:
build:
context: .
dockerfile: Dockerfile
ports:
- "4200:80"
depends_on:
- api
```
### Phase 3 verification
| Check | How |
|-------|-----|
| Full stack up | `docker compose up --build` -- 3 containers, all healthy |
| Frontend accessible | Browse `http://localhost:4200` -- all pages load |
| All data pages dynamic | schedule, donate, events all render from DB |
| Data persists | `docker compose down && docker compose up` -- data still present |
## Files to Create (new)
| File | Purpose |
|------|---------|
| `.vscode/workspace.code-workspace` | Multi-root workspace (frontend + backend) |
| `.vscode/settings.json` | Shared workspace settings |
| `.vscode/tasks.json` | Common tasks (start backend, start frontend, docker up) |
| `.vscode/launch.json` | Debug configs for both projects |
| `backend/.vscode/settings.json` | Python-specific VS Code settings |
| `backend/.vscode/launch.json` | FastAPI debug config |
| `backend/requirements.txt` | Python dependencies |
| `backend/Dockerfile` | API container image |
| `backend/seed.py` | Initial station data |
| `backend/app/main.py` | FastAPI app entry point |
| `backend/app/config.py` | Env-based config |
| `backend/app/database.py` | async session engine |
| `backend/app/models.py` | SQLAlchemy ORM models |
| `backend/app/schemas.py` | Pydantic schemas |
| `backend/app/api/programs.py` | Programs CRUD endpoints |
| `backend/app/api/events.py` | Events CRUD endpoints |
| `backend/app/api/tiers.py` | Tiers CRUD endpoints |
| `src/app/interfaces/program.ts` | Program TS type |
| `src/app/interfaces/event.ts` | Event TS type |
| `src/app/interfaces/tier.ts` | Tier TS type |
| `src/app/services/program.service.ts` | Program HTTP service |
| `src/app/services/event.service.ts` | Event HTTP service |
| `src/app/services/tier.service.ts` | Tier HTTP service |
| `src/environments/environment.ts` | Dev/prod API URL |
| `docker-compose.yml` | 3-service compose |
| `Dockerfile` | Angular build + nginx serve (at repo root) |
| `nginx.conf` | nginx config with API proxy (at repo root) |
## Files to Modify
| File | Change |
|------|--------|
| `src/app/app.config.ts` | Add `provideHttpClient()` |
| `src/app/schedule/schedule.component.ts` | Remove readonly array, inject service |
| `src/app/events/events.component.ts` | Same pattern |
| `src/app/donate/donate.component.ts` | Same pattern |
| `docker-compose.yml` | Created in Phase 1, extended in Phase 3 |
## Not In Scope (for now)
- Admin panel (mentioned as Phase 5 in analysis — will be a future addition)
- Contact form implementation (left as alert per user decision)
- Streaming status endpoint (can add later via same pattern)
+7
View File
@@ -0,0 +1,7 @@
{
"folders": [
{ "path": "." },
{ "path": "backend" }
],
"settings": {}
}