working 3 tier approach
This commit is contained in:
1 parent
4482a30793
commit
e5fef8b181
47 files changed
+1245
-157
No files matched your search
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.
Whitespace-only changes.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Whitespace-only changes.
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