diff --git a/.vscode/launch.json b/.vscode/launch.json index 925af83..7c9e608 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,20 +1,25 @@ { - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { - "name": "ng serve", - "type": "chrome", + "name": "Backend (FastAPI)", + "type": "python", "request": "launch", - "preLaunchTask": "npm: start", - "url": "http://localhost:4200/" + "module": "uvicorn", + "args": ["app.main:app", "--reload", "--host", "0.0.0.0", "--port", "8000"], + "cwd": "${workspaceFolder}/backend", + "env": { + "DATABASE_URL": "postgresql+asyncpg://postgres:postgres@localhost:5432/kmountain" + }, + "console": "integratedTerminal" }, { - "name": "ng test", + "name": "Frontend (ng serve)", "type": "chrome", "request": "launch", - "preLaunchTask": "npm: test", - "url": "http://localhost:9876/debug.html" + "url": "http://localhost:4200", + "webRoot": "${workspaceFolder}/src", + "preLaunchTask": "Start Frontend" } ] } diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e240a56 --- /dev/null +++ b/.vscode/settings.json @@ -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 + } +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 244306f..4c60d2a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,42 +1,62 @@ { - // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 "version": "2.0.0", "tasks": [ { - "type": "npm", - "script": "start", - "isBackground": true, - "problemMatcher": { - "owner": "typescript", - "pattern": "$tsc", - "background": { - "activeOnStart": true, - "beginsPattern": { - "regexp": "Changes detected" - }, - "endsPattern": { - "regexp": "bundle generation (complete|failed)" - } - } + "label": "Start Backend", + "type": "shell", + "command": "cd ${workspaceFolder}/backend && uvicorn app.main:app --reload --host 0.0.0.0 --port 8000", + "group": "build", + "presentation": { + "reveal": "always", + "panel": "dedicated" + }, + "problemMatcher": [], + "runOptions": { + "instanceLimit": 1 } }, { - "type": "npm", - "script": "test", - "isBackground": true, - "problemMatcher": { - "owner": "typescript", - "pattern": "$tsc", - "background": { - "activeOnStart": true, - "beginsPattern": { - "regexp": "Changes detected" - }, - "endsPattern": { - "regexp": "bundle generation (complete|failed)" - } - } + "label": "Start Frontend", + "type": "shell", + "command": "ng serve", + "portAction": "open", + "portPattern": { + "startBody": 4200 + }, + "group": "build", + "presentation": { + "reveal": "always", + "panel": "dedicated" + }, + "problemMatcher": ["$ng-cli-warn"], + "runOptions": { + "instanceLimit": 1 } + }, + { + "label": "Docker Up", + "type": "shell", + "command": "docker compose up --build -d", + "group": "build", + "presentation": { + "reveal": "always", + "panel": "dedicated" + }, + "problemMatcher": [], + "runOptions": { + "instanceLimit": 1 + } + }, + { + "label": "Docker Down", + "type": "shell", + "command": "docker compose down", + "group": "build", + "presentation": { + "reveal": "always", + "panel": "dedicated" + }, + "problemMatcher": [] } ] } diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0ca92b3 --- /dev/null +++ b/Dockerfile @@ -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;"] diff --git a/backend/.vscode/launch.json b/backend/.vscode/launch.json new file mode 100644 index 0000000..dd87224 --- /dev/null +++ b/backend/.vscode/launch.json @@ -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" + } + ] +} diff --git a/backend/.vscode/settings.json b/backend/.vscode/settings.json new file mode 100644 index 0000000..8d5ebc6 --- /dev/null +++ b/backend/.vscode/settings.json @@ -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 + } +} diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..39c096e --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/__pycache__/seed.cpython-312.pyc b/backend/__pycache__/seed.cpython-312.pyc new file mode 100644 index 0000000..db7128d Binary files /dev/null and b/backend/__pycache__/seed.cpython-312.pyc differ diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/__pycache__/config.cpython-312.pyc b/backend/app/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000..4314697 Binary files /dev/null and b/backend/app/__pycache__/config.cpython-312.pyc differ diff --git a/backend/app/__pycache__/database.cpython-312.pyc b/backend/app/__pycache__/database.cpython-312.pyc new file mode 100644 index 0000000..2e4fedc Binary files /dev/null and b/backend/app/__pycache__/database.cpython-312.pyc differ diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000..a1c4972 Binary files /dev/null and b/backend/app/__pycache__/main.cpython-312.pyc differ diff --git a/backend/app/__pycache__/models.cpython-312.pyc b/backend/app/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000..8cdabdf Binary files /dev/null and b/backend/app/__pycache__/models.cpython-312.pyc differ diff --git a/backend/app/__pycache__/schemas.cpython-312.pyc b/backend/app/__pycache__/schemas.cpython-312.pyc new file mode 100644 index 0000000..4578cd3 Binary files /dev/null and b/backend/app/__pycache__/schemas.cpython-312.pyc differ diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__pycache__/events.cpython-312.pyc b/backend/app/api/__pycache__/events.cpython-312.pyc new file mode 100644 index 0000000..41c6ceb Binary files /dev/null and b/backend/app/api/__pycache__/events.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/programs.cpython-312.pyc b/backend/app/api/__pycache__/programs.cpython-312.pyc new file mode 100644 index 0000000..595095d Binary files /dev/null and b/backend/app/api/__pycache__/programs.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/tiers.cpython-312.pyc b/backend/app/api/__pycache__/tiers.cpython-312.pyc new file mode 100644 index 0000000..546c029 Binary files /dev/null and b/backend/app/api/__pycache__/tiers.cpython-312.pyc differ diff --git a/backend/app/api/events.py b/backend/app/api/events.py new file mode 100644 index 0000000..8e6ce0f --- /dev/null +++ b/backend/app/api/events.py @@ -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() diff --git a/backend/app/api/programs.py b/backend/app/api/programs.py new file mode 100644 index 0000000..20908d5 --- /dev/null +++ b/backend/app/api/programs.py @@ -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() diff --git a/backend/app/api/tiers.py b/backend/app/api/tiers.py new file mode 100644 index 0000000..a7702c7 --- /dev/null +++ b/backend/app/api/tiers.py @@ -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() diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..91bf5f5 --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..d843672 --- /dev/null +++ b/backend/app/database.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..9e0b8f9 --- /dev/null +++ b/backend/app/main.py @@ -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"]) diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..48af1e3 --- /dev/null +++ b/backend/app/models.py @@ -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) diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..1b2de88 --- /dev/null +++ b/backend/app/schemas.py @@ -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} diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..69a6773 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/seed.py b/backend/seed.py new file mode 100644 index 0000000..4204ec2 --- /dev/null +++ b/backend/seed.py @@ -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()) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cbdb3f5 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..6d20403 --- /dev/null +++ b/nginx.conf @@ -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; + } +} diff --git a/src/app/app.config.ts b/src/app/app.config.ts index e75614a..3660a5e 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,8 +1,13 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { provideHttpClient } from '@angular/common/http'; import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; export const appConfig: ApplicationConfig = { - providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)], + providers: [ + provideBrowserGlobalErrorListeners(), + provideRouter(routes), + provideHttpClient(), + ], }; diff --git a/src/app/donate/donate.component.html b/src/app/donate/donate.component.html index 669d483..6a1e874 100644 --- a/src/app/donate/donate.component.html +++ b/src/app/donate/donate.component.html @@ -17,18 +17,24 @@