working 3 tier approach

This commit is contained in:
kfj001 committed 2026-06-11 22:34:55 -07:00
1 parent 4482a30793
commit e5fef8b181
47 files changed
+1288 -200

No files matched your search

+13 -8
View File
@@ -1,20 +1,25 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"name": "Backend (FastAPI)",
"type": "python",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
"module": "uvicorn",
"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",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
"url": "http://localhost:4200",
"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
}
}
+51 -31
View File
@@ -1,42 +1,62 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
"label": "Start Backend",
"type": "shell",
"command": "cd ${workspaceFolder}/backend && uvicorn app.main:app --reload --host 0.0.0.0 --port 8000",
"group": "build",
"presentation": {
"reveal": "always",
"panel": "dedicated"
},
"problemMatcher": [],
"runOptions": {
"instanceLimit": 1
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
"label": "Start Frontend",
"type": "shell",
"command": "ng serve",
"portAction": "open",
"portPattern": {
"startBody": 4200
},
"group": "build",
"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
Whitespace-only changes.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
Whitespace-only changes.
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 { provideHttpClient } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)],
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(),
],
};
+21 -15
View File
@@ -17,18 +17,24 @@
<!-- Donation tiers -->
<div class="donate-tiers">
<h3>Choose Your Level of Support</h3>
<div class="tiers-grid">
<div class="tier-card" *ngFor="let tier of tiers">
<div class="tier-icon">{{ tier.icon }}</div>
<div class="tier-amount">${{ tier.amount }}</div>
<div class="tier-period">per month</div>
<h4>{{ tier.name }}</h4>
<ul class="tier-benefits">
<li *ngFor="let benefit of tier.benefits">{{ benefit }}</li>
</ul>
<a href="#" class="btn btn-tier">Select {{ tier.name }}</a>
@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="tier-card" *ngFor="let tier of tiers">
<div class="tier-icon">{{ tier.icon }}</div>
<div class="tier-amount">${{ tier.amount }}</div>
<div class="tier-period">per month</div>
<h4>{{ tier.name }}</h4>
<ul class="tier-benefits">
<li *ngFor="let benefit of tier.benefits">{{ benefit }}</li>
</ul>
<a href="#" class="btn btn-tier">Select {{ tier.name }}</a>
</div>
</div>
</div>
}
</div>
<!-- One-time donation -->
@@ -48,22 +54,22 @@
<div class="donate-why">
<div class="why-grid">
<div class="why-item">
<span class="why-icon">📻</span>
<span class="why-icon">&#x1F4FB;</span>
<strong>98.7 FM Signal</strong>
<p>Covering 3 mountain counties and the valleys between</p>
</div>
<div class="why-item">
<span class="why-icon">🎙️</span>
<span class="why-icon">&#x1F399;</span>
<strong>24/7 On-Air</strong>
<p>Over 15,000 hours of programming per year</p>
</div>
<div class="why-item">
<span class="why-icon">🌍</span>
<span class="why-icon">&#x1F30D;</span>
<strong>Worldwide Stream</strong>
<p>Accessible from every corner of the globe, free forever</p>
</div>
<div class="why-item">
<span class="why-icon">🎓</span>
<span class="why-icon">&#x1F393;</span>
<strong>Youth Programs</strong>
<p>Free radio training for 200+ local students each year</p>
</div>
+21 -53
View File
@@ -1,12 +1,8 @@
import { Component } from '@angular/core';
import { Component, inject, OnInit } from '@angular/core';
import { NgFor } from '@angular/common';
interface Tier {
name: string;
amount: number;
icon: string;
benefits: string[];
}
import { TierService } from '../services/tier.service';
import { Tier } from '../interfaces/tier';
@Component({
selector: 'app-donate',
@@ -15,50 +11,22 @@ interface Tier {
templateUrl: './donate.component.html',
styleUrl: './donate.component.scss',
})
export class DonateComponent {
readonly tiers: Tier[] = [
{
name: 'Seed',
amount: 5,
icon: '🌱',
benefits: [
'Digital thank-you card',
'Name listed on our website',
'Quarterly newsletter',
],
},
{
name: 'Stream',
amount: 15,
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',
],
},
];
export class DonateComponent implements OnInit {
private tierService = inject(TierService);
tiers: Tier[] = [];
loading = true;
ngOnInit(): void {
this.tierService.getTiers().subscribe({
next: (data) => {
this.tiers = data;
this.loading = false;
},
error: () => {
this.loading = false;
this.tiers = [];
},
});
}
}
+27 -17
View File
@@ -10,25 +10,35 @@
</p>
</div>
<div class="events-list">
<div class="event-card" *ngFor="let event of upcomingEvents">
<div class="event-date">
<span class="event-month">Jul</span>
<span class="event-day">12</span>
<span class="event-year">2026</span>
</div>
<div class="event-content">
<h3>{{ event.title }}</h3>
<p>{{ event.description }}</p>
<div class="event-meta">
<span class="event-location">
<span aria-hidden="true">📍</span> {{ event.location }}
</span>
<a href="#" class="event-rsvp">Learn More →</a>
@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="event-card" *ngFor="let event of upcomingEvents">
<div class="event-date">
<span class="event-month">{{ event.date | date:'MMM' }}</span>
<span class="event-day">{{ event.date | date:'d' }}</span>
<span class="event-year">{{ event.date | date:'yyyy' }}</span>
</div>
<div class="event-content">
<h3>{{ event.title }}</h3>
<p>{{ event.description }}</p>
<div class="event-meta">
<span class="event-location">
<span aria-hidden="true">&#x1F4CD;</span> {{ event.location }}
</span>
@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 class="event-icon" aria-hidden="true">{{ event.icon }}</div>
</div>
<div class="event-icon" aria-hidden="true">{{ event.icon }}</div>
</div>
</div>
}
</div>
</section>
+23 -41
View File
@@ -1,50 +1,32 @@
import { Component } from '@angular/core';
import { NgFor } from '@angular/common';
import { Component, inject, OnInit } from '@angular/core';
import { DatePipe, NgFor } from '@angular/common';
interface Event {
date: string;
title: string;
description: string;
location: string;
icon: string;
}
import { EventService } from '../services/event.service';
import { Event } from '../interfaces/event';
@Component({
selector: 'app-events',
standalone: true,
imports: [NgFor],
imports: [DatePipe, NgFor],
templateUrl: './events.component.html',
styleUrl: './events.component.scss',
})
export class EventsComponent {
readonly upcomingEvents: Event[] = [
{
date: 'July 12, 2026',
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: 'August 5, 2026',
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: '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: '🌙',
},
];
export class EventsComponent implements OnInit {
private eventService = inject(EventService);
upcomingEvents: Event[] = [];
loading = true;
ngOnInit(): void {
this.eventService.getEvents(true).subscribe({
next: (data) => {
this.upcomingEvents = data;
this.loading = false;
},
error: () => {
this.loading = false;
this.upcomingEvents = [];
},
});
}
}
+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;
}
+20 -14
View File
@@ -9,24 +9,30 @@
</p>
</div>
<div class="schedule-list">
<div class="schedule-day-label">Monday – Friday</div>
<div class="schedule-table">
<div *ngFor="let program of programs" class="schedule-row">
<div class="schedule-time">{{ program.time }}</div>
<div class="schedule-divider"></div>
<div class="schedule-details">
<h4 class="schedule-title">{{ program.title }}</h4>
<div class="schedule-meta">
<span class="schedule-host">
<span aria-hidden="true">🎙️</span> {{ program.host }}
</span>
<span class="schedule-genre">{{ program.genre }}</span>
@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-day-label">Monday – Friday</div>
<div class="schedule-table">
<div *ngFor="let program of programs" class="schedule-row">
<div class="schedule-time">{{ program.time }}</div>
<div class="schedule-divider"></div>
<div class="schedule-details">
<h4 class="schedule-title">{{ program.title }}</h4>
<div class="schedule-meta">
<span class="schedule-host">
<span aria-hidden="true">&#x1F399;</span> {{ program.host }}
</span>
<span class="schedule-genre">{{ program.genre }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
}
<div class="schedule-weekend">
<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';
interface Program {
time: string;
title: string;
host: string;
genre: string;
}
import { ProgramService } from '../services/program.service';
import { Program } from '../interfaces/program';
@Component({
selector: 'app-schedule',
@@ -15,17 +11,22 @@ interface Program {
templateUrl: './schedule.component.html',
styleUrl: './schedule.component.scss',
})
export class ScheduleComponent {
readonly programs: Program[] = [
{ 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' },
{ time: '10:00 AM', title: 'Bluegrass Trails', host: 'Sarah Lynn', genre: 'Bluegrass' },
{ 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' },
{ time: '4:00 PM', title: 'Afternoon Acoustics', host: 'Jen Reeves', genre: 'Acoustic' },
{ time: '6:00 PM', title: 'Evening Echoes', host: 'Carlos Mendez', genre: 'Indie / Alternative' },
{ time: '8:00 PM', title: 'Classical Mountains', host: 'Elena Cross', genre: 'Classical' },
{ time: '10:00 PM', title: 'Night Owl Session', host: 'DJ Kofi', genre: 'Electronic' },
{ time: '12:00 AM', title: 'Late Night Jazz', host: 'Mike Darrow', genre: 'Jazz' },
];
export class ScheduleComponent implements OnInit {
private programService = inject(ProgramService);
programs: Program[] = [];
loading = true;
ngOnInit(): void {
this.programService.getPrograms().subscribe({
next: (data) => {
this.programs = data;
this.loading = false;
},
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": {}
}