working 3 tier approach
This commit is contained in:
Vendored
+13
-8
@@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+17
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+48
-28
@@ -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
@@ -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;"]
|
||||||
Vendored
+18
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+13
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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"])
|
||||||
@@ -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)
|
||||||
@@ -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}
|
||||||
@@ -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
@@ -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())
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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…</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">📻</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">🎙</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">🌍</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">🎓</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>
|
||||||
|
|||||||
@@ -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',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,25 +10,35 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<div class="schedule-loading">Loading events…</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">📍</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 →</a>
|
||||||
|
} @else {
|
||||||
|
<a href="#" class="event-rsvp">Learn More →</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>
|
||||||
|
|||||||
@@ -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: '🌙',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export interface Program {
|
||||||
|
id: number;
|
||||||
|
day_of_week: number;
|
||||||
|
day_label: string;
|
||||||
|
time: string;
|
||||||
|
title: string;
|
||||||
|
host: string;
|
||||||
|
genre: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export interface Tier {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
amount: number;
|
||||||
|
icon: string;
|
||||||
|
benefits: string[];
|
||||||
|
display_order: number;
|
||||||
|
}
|
||||||
@@ -9,6 +9,11 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<div class="schedule-loading">Loading schedule…</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">🎙</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">
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export const environment = {
|
||||||
|
production: true,
|
||||||
|
// In production the API is proxied through nginx at /api — no host needed.
|
||||||
|
apiBaseUrl: '',
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export const environment = {
|
||||||
|
production: false,
|
||||||
|
apiBaseUrl: 'http://localhost:8000',
|
||||||
|
};
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{ "path": "." },
|
||||||
|
{ "path": "backend" }
|
||||||
|
],
|
||||||
|
"settings": {}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user