diff --git a/backend/__pycache__/seed.cpython-312.pyc b/backend/__pycache__/seed.cpython-312.pyc index a59b338..e62c571 100644 Binary files a/backend/__pycache__/seed.cpython-312.pyc and b/backend/__pycache__/seed.cpython-312.pyc differ diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc index 070a407..985cda8 100644 Binary files a/backend/app/__pycache__/main.cpython-312.pyc 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 index 49ca2e0..cc77d0e 100644 Binary files a/backend/app/__pycache__/models.cpython-312.pyc 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 index 085474c..91a76e8 100644 Binary files a/backend/app/__pycache__/schemas.cpython-312.pyc and b/backend/app/__pycache__/schemas.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/admin.cpython-312.pyc b/backend/app/api/__pycache__/admin.cpython-312.pyc index 1c12518..0339240 100644 Binary files a/backend/app/api/__pycache__/admin.cpython-312.pyc and b/backend/app/api/__pycache__/admin.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/shows.cpython-312.pyc b/backend/app/api/__pycache__/shows.cpython-312.pyc new file mode 100644 index 0000000..6d07bfe Binary files /dev/null and b/backend/app/api/__pycache__/shows.cpython-312.pyc differ diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 7ed693c..20df06e 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -2,7 +2,7 @@ from fastapi import APIRouter from app.config import settings from app.database import get_session -from app.models import DonationTier, Event, Program, StationConfig +from app.models import DonationTier, Event, Program, StationConfig, Show, ShowSchedule router = APIRouter() @@ -17,6 +17,8 @@ async def reset_database(): from seed import seed as run_seed async with async_session() as session: + await session.execute(ShowSchedule.__table__.delete()) + await session.execute(Show.__table__.delete()) await session.execute(Program.__table__.delete()) await session.execute(Event.__table__.delete()) await session.execute(DonationTier.__table__.delete()) diff --git a/backend/app/api/shows.py b/backend/app/api/shows.py new file mode 100644 index 0000000..0115bb8 --- /dev/null +++ b/backend/app/api/shows.py @@ -0,0 +1,118 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.auth import get_current_admin_user +from app.database import get_session +from app.models import Show, ShowSchedule +from app.schemas import ShowCreate, ShowUpdate, ShowResponse +from app.user_models import User + +router = APIRouter() + + +@router.get("/", response_model=list[ShowResponse]) +async def list_shows(session: AsyncSession = Depends(get_session)): + query = ( + select(Show) + .where(Show.active == True) + .options(selectinload(Show.schedules)) + .order_by(Show.display_order, Show.title) + ) + result = await session.execute(query) + return list(result.scalars().all()) + + +@router.get("/{show_id}", response_model=ShowResponse) +async def get_show( + show_id: int, + session: AsyncSession = Depends(get_session), +): + result = await session.execute( + select(Show) + .options(selectinload(Show.schedules)) + .where(Show.id == show_id) + ) + show = result.scalar_one_or_none() + if not show: + raise HTTPException(status_code=404, detail="Show not found") + return show + + +@router.post("/", response_model=ShowResponse, status_code=201) +async def create_show( + payload: ShowCreate, + session: AsyncSession = Depends(get_session), + _: User = Depends(get_current_admin_user), +): + data = payload.model_dump(exclude={"schedules"}) + show = Show(**data) + + for slot in payload.schedules: + show.schedules.append(ShowSchedule(**slot.model_dump())) + + session.add(show) + await session.commit() + await session.refresh(show) + await session.refresh(show) + # Re-query with eager load to return schedules + result = await session.execute( + select(Show) + .options(selectinload(Show.schedules)) + .where(Show.id == show.id) + ) + return result.scalar_one() + + +@router.put("/{show_id}", response_model=ShowResponse) +async def update_show( + show_id: int, + payload: ShowUpdate, + session: AsyncSession = Depends(get_session), + _: User = Depends(get_current_admin_user), +): + result = await session.execute( + select(Show) + .options(selectinload(Show.schedules)) + .where(Show.id == show_id) + ) + show = result.scalar_one_or_none() + if not show: + raise HTTPException(status_code=404, detail="Show not found") + + update_data = payload.model_dump(exclude_unset=True, exclude={"schedules"}) + for key, value in update_data.items(): + setattr(show, key, value) + + if payload.schedules is not None: + # Replace all schedule slots + for sched in show.schedules: + await session.delete(sched) + for slot in payload.schedules: + session.add(ShowSchedule(show_id=show.id, **slot.model_dump())) + + await session.commit() + await session.refresh(show) + + # Re-query with eager load + result = await session.execute( + select(Show) + .options(selectinload(Show.schedules)) + .where(Show.id == show.id) + ) + return result.scalar_one() + + +@router.delete("/{show_id}", status_code=204) +async def delete_show( + show_id: int, + session: AsyncSession = Depends(get_session), + _: User = Depends(get_current_admin_user), +): + result = await session.execute(select(Show).where(Show.id == show_id)) + show = result.scalar_one_or_none() + if not show: + raise HTTPException(status_code=404, detail="Show not found") + await session.delete(show) + await session.commit() diff --git a/backend/app/main.py b/backend/app/main.py index fabf42b..390a9d2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,9 +8,9 @@ from sqlalchemy import func, select from app.config import settings from app.database import async_session, engine -from app.models import Base, Program, StationConfig, HistoryEntry, TeamMember, CommunityHighlight +from app.models import Base, Program, Show, StationConfig, HistoryEntry, TeamMember, CommunityHighlight from app.user_models import User -from app.api import programs, events, tiers, auth, station_config, history, team, community +from app.api import programs, events, tiers, auth, station_config, history, team, community, shows async def _ensure_station_config(session): @@ -36,7 +36,7 @@ async def lifespan(app: FastAPI): # Auto-seed if database is empty async with async_session() as session: - result = await session.execute(select(func.count(Program.id))) + result = await session.execute(select(func.count(Show.id))) count = result.scalar() if count == 0: print(" → Database is empty — running seed...") @@ -99,6 +99,7 @@ app.include_router(station_config.router, prefix="/api/station-config", tags=["s app.include_router(history.router, prefix="/api/history", tags=["history"]) app.include_router(team.router, prefix="/api/team", tags=["team"]) app.include_router(community.router, prefix="/api/community", tags=["community"]) +app.include_router(shows.router, prefix="/api/shows", tags=["shows"]) # Serve uploaded files (dev only — Nginx handles this in production) if settings.ENV != "production": diff --git a/backend/app/models.py b/backend/app/models.py index 00199b4..92130ae 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -7,8 +7,10 @@ from sqlalchemy import ( Boolean, Date, JSON, + ForeignKey, + UniqueConstraint, ) -from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.orm import DeclarativeBase, relationship class Base(DeclarativeBase): @@ -27,6 +29,35 @@ class Program(Base): genre = Column(String(80), nullable=False) +class Show(Base): + __tablename__ = "shows" + + id = Column(Integer, primary_key=True, autoincrement=True) + title = Column(String(200), nullable=False) + description = Column(Text, nullable=False) + host = Column(String(100), nullable=False) + genre = Column(String(80), nullable=False) + show_art_url = Column(String(500), nullable=True) + hero_image_url = Column(String(500), nullable=True) + display_order = Column(Integer, nullable=False, default=0) + active = Column(Boolean, default=True) + schedules = relationship("ShowSchedule", back_populates="show", cascade="all, delete-orphan") + + +class ShowSchedule(Base): + __tablename__ = "show_schedules" + + id = Column(Integer, primary_key=True, autoincrement=True) + show_id = Column(Integer, ForeignKey("shows.id", ondelete="CASCADE"), nullable=False) + day_of_week = Column(Integer, nullable=False) + time = Column(String(10), nullable=False) + show = relationship("Show", back_populates="schedules") + + __table_args__ = ( + UniqueConstraint("show_id", "day_of_week", "time", name="uq_show_sched"), + ) + + class Event(Base): __tablename__ = "events" diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 41e107d..ef25776 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -243,3 +243,64 @@ class CommunityHighlightResponse(BaseModel): active: bool model_config = {"from_attributes": True} + + +# ── ShowSchedule ────────────────────────────────────────── + +class ShowScheduleResponse(BaseModel): + id: int + show_id: int + day_of_week: int + time: str + + @computed_field + @property + def day_label(self) -> str: + return DAY_NAMES.get(self.day_of_week, "Unknown") + + model_config = {"from_attributes": True} + + +class ShowScheduleCreate(BaseModel): + day_of_week: int + time: str + + +# ── Show ──────────────────────────────────────────────────── + +class ShowResponse(BaseModel): + id: int + title: str + description: str + host: str + genre: str + show_art_url: Optional[str] + hero_image_url: Optional[str] + display_order: int + active: bool + schedules: list[ShowScheduleResponse] + + model_config = {"from_attributes": True} + + +class ShowCreate(BaseModel): + title: str + description: str + host: str + genre: str + show_art_url: Optional[str] = None + hero_image_url: Optional[str] = None + display_order: int = 0 + schedules: list[ShowScheduleCreate] + + +class ShowUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + host: Optional[str] = None + genre: Optional[str] = None + show_art_url: Optional[str] = None + hero_image_url: Optional[str] = None + display_order: Optional[int] = None + active: Optional[bool] = None + schedules: Optional[list[ShowScheduleCreate]] = None diff --git a/backend/seed.py b/backend/seed.py index 6ea4fcb..305d5ab 100644 --- a/backend/seed.py +++ b/backend/seed.py @@ -14,7 +14,7 @@ from datetime import date from sqlalchemy import select from app.database import async_session -from app.models import Program, Event, DonationTier, StationConfig +from app.models import Program, Show, ShowSchedule, Event, DonationTier, StationConfig from app.models import HistoryEntry, TeamMember, CommunityHighlight # ── Seed data ────────────────────────────────────────────── @@ -32,6 +32,149 @@ PROGRAMS: list[dict] = [ {"day_of_week": 1, "day_label": "Monday", "time": "12:00 AM", "title": "Late Night Jazz", "host": "Mike Darrow", "genre": "Jazz"}, ] +SHOWS: list[dict] = [ + { + "title": "Morning Mountain Mist", + "description": "Begin your week with ambient soundscapes blended with real mountain field recordings. Birdsong, wind, and gentle melodies to ease you into the day.", + "host": "Diana Walsh", + "genre": "Ambient / Nature", + "show_art_url": None, + "hero_image_url": None, + "display_order": 1, + "schedules": [ + {"day_of_week": 1, "time": "6:00 AM"}, + {"day_of_week": 3, "time": "6:00 AM"}, + {"day_of_week": 5, "time": "6:00 AM"}, + ], + }, + { + "title": "Community Roundup", + "description": "Local news, town hall highlights, and conversations with the people who make this mountain community tick. Your weekly civic check-in.", + "host": "Tom Breen", + "genre": "News / Talk", + "show_art_url": None, + "hero_image_url": None, + "display_order": 2, + "schedules": [ + {"day_of_week": 1, "time": "8:00 AM"}, + {"day_of_week": 4, "time": "8:00 AM"}, + ], + }, + { + "title": "Bluegrass Trails", + "description": "Flatpickin' banjo, booming bass, and foot-stomping energy. From traditional Appalachian tunes to modern bluegrass fusion.", + "host": "Sarah Lynn", + "genre": "Bluegrass", + "show_art_url": None, + "hero_image_url": None, + "display_order": 3, + "schedules": [ + {"day_of_week": 1, "time": "10:00 AM"}, + {"day_of_week": 2, "time": "10:00 AM"}, + {"day_of_week": 5, "time": "7:00 PM"}, + ], + }, + { + "title": "Lunchtime Jazz", + "description": "Smooth standards, bebop bursts, and everything in between — the perfect soundtrack for your midday break.", + "host": "Mike Darrow", + "genre": "Jazz", + "show_art_url": None, + "hero_image_url": None, + "display_order": 4, + "schedules": [ + {"day_of_week": 1, "time": "12:00 PM"}, + {"day_of_week": 1, "time": "12:00 AM"}, + {"day_of_week": 3, "time": "12:00 PM"}, + {"day_of_week": 5, "time": "12:00 PM"}, + ], + }, + { + "title": "Folk Roots Hour", + "description": "Acoustic storytelling from the heart of the mountains. Singer-songwriters, traditional ballads, and the voices that keep our heritage alive.", + "host": "Nadia Cole", + "genre": "Folk", + "show_art_url": None, + "hero_image_url": None, + "display_order": 5, + "schedules": [ + {"day_of_week": 2, "time": "2:00 PM"}, + {"day_of_week": 4, "time": "2:00 PM"}, + ], + }, + { + "title": "Afternoon Acoustics", + "description": "Stripped-down arrangements and raw vocals. Indie folk, solo artists, and the beauty of unamplified sound.", + "host": "Jen Reeves", + "genre": "Acoustic", + "show_art_url": None, + "hero_image_url": None, + "display_order": 6, + "schedules": [ + {"day_of_week": 1, "time": "2:00 PM"}, + {"day_of_week": 3, "time": "2:00 PM"}, + {"day_of_week": 6, "time": "1:00 PM"}, + ], + }, + { + "title": "Evening Echoes", + "description": "Indie, alternative, and underground tracks from local bands and global artists. Where the next big sound finds its first home.", + "host": "Carlos Mendez", + "genre": "Indie / Alternative", + "show_art_url": None, + "hero_image_url": None, + "display_order": 7, + "schedules": [ + {"day_of_week": 1, "time": "6:00 PM"}, + {"day_of_week": 2, "time": "6:00 PM"}, + {"day_of_week": 4, "time": "6:00 PM"}, + {"day_of_week": 6, "time": "7:00 PM"}, + ], + }, + { + "title": "Classical Mountains", + "description": "Symphonies, chamber music, and solo piano performed by the world's greatest musicians. Elevate your evening.", + "host": "Elena Cross", + "genre": "Classical", + "show_art_url": None, + "hero_image_url": None, + "display_order": 8, + "schedules": [ + {"day_of_week": 1, "time": "8:00 PM"}, + {"day_of_week": 3, "time": "8:00 PM"}, + {"day_of_week": 5, "time": "8:00 PM"}, + ], + }, + { + "title": "Night Owl Session", + "description": "Electronic beats, synth waves, and deep house to carry you through the late hours. The mountains never sleep.", + "host": "DJ Kofi", + "genre": "Electronic", + "show_art_url": None, + "hero_image_url": None, + "display_order": 9, + "schedules": [ + {"day_of_week": 1, "time": "10:00 PM"}, + {"day_of_week": 5, "time": "10:00 PM"}, + {"day_of_week": 6, "time": "10:00 PM"}, + {"day_of_week": 7, "time": "9:00 PM"}, + ], + }, + { + "title": "Weekend Sunrise", + "description": "A gentle mix of acoustic covers, nature sounds, and listener requests to start your weekend on the right note.", + "host": "Diana Walsh", + "genre": "Acoustic / Ambient", + "show_art_url": None, + "hero_image_url": None, + "display_order": 10, + "schedules": [ + {"day_of_week": 6, "time": "7:00 AM"}, + {"day_of_week": 7, "time": "7:00 AM"}, + ], + }, +] + EVENTS: list[dict] = [ { "date": date(2026, 7, 12), @@ -289,6 +432,29 @@ async def _upsert_team_member(session, data: dict) -> None: session.add(TeamMember(**data, active=True)) +async def _upsert_show(session, data: dict) -> None: + """Get-or-create a show matched on title, with nested schedule slots.""" + schedules = data.pop("schedules", []) + existing = await session.execute( + select(Show).where(Show.title == data["title"]) + ) + show = existing.scalar_one_or_none() + if show: + for key, value in data.items(): + setattr(show, key, value) + # Replace schedule slots + for sched in show.schedules: + session.delete(sched) + for slot in schedules: + session.add(ShowSchedule(show_id=show.id, **slot)) + else: + show_data = {**data, "active": True} + show = Show(**show_data) + for slot in schedules: + show.schedules.append(ShowSchedule(**slot)) + session.add(show) + + async def _upsert_community_highlight(session, data: dict) -> None: """Get-or-create a community highlight matched on (icon, title).""" existing = await session.execute( @@ -344,10 +510,20 @@ async def seed() -> None: await session.commit() print(f" ✓ Seeded {len(COMMUNITY_HIGHLIGHTS)} community highlights") + # Make a copy since _upsert_show mutates the dict (pops 'schedules') + import copy + for data in copy.deepcopy(SHOWS): + await _upsert_show(session, data) + await session.commit() + print(f" ✓ Seeded {len(SHOWS)} shows") + async def truncate_all() -> None: """Remove all seed data from the database.""" async with async_session() as session: + # Delete child tables first to respect foreign keys + await session.execute(ShowSchedule.__table__.delete()) + await session.execute(Show.__table__.delete()) await session.execute(Program.__table__.delete()) await session.execute(Event.__table__.delete()) await session.execute(DonationTier.__table__.delete()) diff --git a/src/app/admin/admin-show-form.component.html b/src/app/admin/admin-show-form.component.html new file mode 100644 index 0000000..2da8825 --- /dev/null +++ b/src/app/admin/admin-show-form.component.html @@ -0,0 +1,79 @@ +
+
+
+

{{ id ? 'Edit Show' : 'Add Show' }}

+ +
+ + @if (error) { + + } + +
+
+ + +
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ + +
+ +
+ @for (slot of schedules; track slot.day_of_week; let i = $index) { +
+ + + +
+ } +
+ +
+ +
+ + +
+
+
+
diff --git a/src/app/admin/admin-show-form.component.scss b/src/app/admin/admin-show-form.component.scss new file mode 100644 index 0000000..c234e53 --- /dev/null +++ b/src/app/admin/admin-show-form.component.scss @@ -0,0 +1,216 @@ +@use '../../styles/variables' as *; +@use '../../styles/mixins' as *; + +.form-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + @include flex-center; + z-index: $z-modal; + @include fade-in; +} + +.form-dialog { + background: $neutral-white; + border-radius: $radius-lg; + padding: $spacing-xl; + width: 90%; + max-width: 600px; + max-height: 90vh; + overflow-y: auto; + box-shadow: $shadow-xl; +} + +.form-header { + @include flex-between; + margin-bottom: $spacing-lg; + + h2 { + font-family: $font-heading; + color: $primary-blue; + margin: 0; + font-size: 1.4rem; + } +} + +.btn-close { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: $neutral-medium; + line-height: 1; + padding: $spacing-xs; + + &:hover { + color: $neutral-dark; + } +} + +.form-error { + background: rgba($danger-red, 0.1); + color: $danger-red; + padding: $spacing-sm $spacing-md; + border-radius: $radius-md; + font-size: 0.875rem; + margin-bottom: $spacing-md; + text-align: center; +} + +.admin-form { + .form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: $spacing-md; + } + + .form-group { + margin-bottom: $spacing-md; + + label { + display: block; + font-size: 0.875rem; + font-weight: 600; + color: $neutral-dark; + margin-bottom: $spacing-xs; + } + + input, select, textarea { + width: 100%; + padding: $spacing-sm $spacing-md; + border: 1px solid $neutral-light; + border-radius: $radius-md; + font-size: 0.95rem; + transition: border-color $transition-fast; + box-sizing: border-box; + + &:focus { + outline: none; + border-color: $primary-blue; + box-shadow: 0 0 0 3px rgba($primary-blue, 0.15); + } + + &.is-invalid { + border-color: $danger-red; + box-shadow: 0 0 0 3px rgba($danger-red, 0.15); + } + } + } +} + +// ── Schedule slots ───────────────────────────────────────── + +.schedule-slots { + display: flex; + flex-direction: column; + gap: $spacing-sm; + margin-bottom: $spacing-sm; +} + +.slot-row { + display: flex; + gap: $spacing-sm; + align-items: center; + + select { + flex: 1; + padding: $spacing-sm $spacing-md; + border: 1px solid $neutral-light; + border-radius: $radius-md; + font-size: 0.9rem; + + &:focus { + outline: none; + border-color: $primary-blue; + } + } + + input { + flex: 1; + padding: $spacing-sm $spacing-md; + border: 1px solid $neutral-light; + border-radius: $radius-md; + font-size: 0.9rem; + + &:focus { + outline: none; + border-color: $primary-blue; + } + } +} + +.btn-remove-slot { + background: none; + border: 1px solid $neutral-light; + border-radius: 50%; + width: 28px; + height: 28px; + font-size: 1.1rem; + cursor: pointer; + color: $neutral-medium; + display: flex; + align-items: center; + justify-content: center; + transition: background $transition-fast, color $transition-fast; + flex-shrink: 0; + + &:hover:not(:disabled) { + background: $danger-red; + color: $neutral-white; + border-color: $danger-red; + } + + &:disabled { + opacity: 0.3; + cursor: not-allowed; + } +} + +.btn-add-slot { + background: none; + border: 1px dashed $neutral-light; + border-radius: $radius-md; + padding: $spacing-xs $spacing-md; + font-size: 0.85rem; + cursor: pointer; + color: $primary-blue; + transition: border-color $transition-fast, background $transition-fast; + width: 100%; + + &:hover { + border-color: $primary-blue; + background: rgba($primary-blue, 0.05); + } +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: $spacing-sm; + margin-top: $spacing-md; +} + +.btn-save { + @include button-style($primary-blue, $neutral-white, $primary-blue-light); + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} + +.btn-cancel { + background: $neutral-light; + color: $neutral-dark; + border: none; + border-radius: $radius-md; + padding: $spacing-sm $spacing-lg; + font-size: 0.95rem; + cursor: pointer; + transition: background $transition-fast; + + &:hover { + background: $neutral-medium; + color: $neutral-white; + } +} \ No newline at end of file diff --git a/src/app/admin/admin-show-form.component.ts b/src/app/admin/admin-show-form.component.ts new file mode 100644 index 0000000..5187e12 --- /dev/null +++ b/src/app/admin/admin-show-form.component.ts @@ -0,0 +1,153 @@ +import { Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { firstValueFrom } from 'rxjs'; + +import { Show } from '../interfaces/show'; +import { ShowService, ShowScheduleCreate, ShowCreatePayload, ShowUpdatePayload } from '../services/show.service'; + +@Component({ + selector: 'app-admin-show-form', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './admin-show-form.component.html', + styleUrl: './admin-show-form.component.scss', +}) +export class AdminShowFormComponent implements OnChanges { + private showService = inject(ShowService); + + @Input() editItem: Show | null = null; + @Output() saved = new EventEmitter(); + @Output() closed = new EventEmitter(); + + readonly days = [ + { value: 1, label: 'Monday' }, + { value: 2, label: 'Tuesday' }, + { value: 3, label: 'Wednesday' }, + { value: 4, label: 'Thursday' }, + { value: 5, label: 'Friday' }, + { value: 6, label: 'Saturday' }, + { value: 7, label: 'Sunday' }, + ]; + + id: number | null = null; + title = ''; + description = ''; + host = ''; + genre = ''; + show_art_url = ''; + hero_image_url = ''; + display_order = 0; + schedules: ShowScheduleCreate[] = [ + { day_of_week: 1, time: '' }, + ]; + + loading = false; + error = ''; + submitted = false; + + ngOnChanges(changes: SimpleChanges): void { + if (changes['editItem'] && this.editItem) { + this.edit(this.editItem); + } + } + + edit(show: Show): void { + this.id = show.id; + this.title = show.title; + this.description = show.description; + this.host = show.host; + this.genre = show.genre; + this.show_art_url = show.show_art_url || ''; + this.hero_image_url = show.hero_image_url || ''; + this.display_order = show.display_order; + this.schedules = show.schedules.map((s) => ({ + day_of_week: s.day_of_week, + time: s.time, + })); + } + + formatError(err: any): string { + if (err?.error?.detail) return err.error.detail; + if (err?.error?.message) return err.error.message; + if (err?.message) return err.message; + return 'Failed to save. Please try again.'; + } + + reset(): void { + this.id = null; + this.title = ''; + this.description = ''; + this.host = ''; + this.genre = ''; + this.show_art_url = ''; + this.hero_image_url = ''; + this.display_order = 0; + this.schedules = [{ day_of_week: 1, time: '' }]; + this.error = ''; + this.submitted = false; + } + + onClose(): void { + this.closed.emit(); + this.reset(); + } + + /** Add a new empty schedule slot row. */ + addScheduleSlot(): void { + this.schedules.push({ day_of_week: 1, time: '' }); + } + + /** Remove a schedule slot row by index. */ + removeScheduleSlot(index: number): void { + this.schedules.splice(index, 1); + } + + async onSubmit(): Promise { + this.submitted = true; + + // Validate required fields + if (!this.title || !this.host || !this.genre) { + this.error = 'Please fill in all required fields.'; + return; + } + + // Validate schedules + const validSchedules = this.schedules.filter( + (s) => s.day_of_week && s.time, + ); + if (validSchedules.length === 0) { + this.error = 'Add at least one schedule slot with a day and time.'; + return; + } + + this.loading = true; + this.error = ''; + + const payload = { + title: this.title, + description: this.description, + host: this.host, + genre: this.genre, + show_art_url: this.show_art_url || null, + hero_image_url: this.hero_image_url || null, + display_order: this.display_order, + schedules: validSchedules, + }; + + try { + if (this.id) { + const updatePayload: ShowUpdatePayload = { ...payload }; + await firstValueFrom(this.showService.updateShow(this.id, updatePayload)); + } else { + await firstValueFrom(this.showService.createShow(payload as ShowCreatePayload)); + } + this.saved.emit(); + this.reset(); + } catch (err: any) { + this.error = this.formatError(err); + } finally { + this.loading = false; + } + } +} \ No newline at end of file diff --git a/src/app/admin/admin.component.html b/src/app/admin/admin.component.html index 7072da6..2e33127 100644 --- a/src/app/admin/admin.component.html +++ b/src/app/admin/admin.component.html @@ -2,11 +2,16 @@

Admin Dashboard

-

Manage programs, events, tiers, history, team, and community content

+

Manage shows, programs, events, tiers, history, team, and community content

+
+ + @if (activeTab() === 'shows') { + @if (shows$ | async; as shows) { +
+
+

Shows ({{ shows.length }})

+ +
+ + + + + + + + + + + + + + @for (show of shows; track show.id) { + + + + + + + + + } @empty { + + } + +
OrderTitleHostGenreSlotsActions
{{ show.display_order }}{{ show.title }}{{ show.host }}{{ show.genre }} + @for (slot of show.schedules; track slot.id) { + {{ slot.day_label.charAt(0) }} {{ slot.time }} + } + + + +
No shows yet. Click "Add Show" to create one.
+
+ } + } + @if (activeTab() === 'programs') { @if (programs$ | async; as programs) { @@ -339,6 +390,9 @@
+ @if (showShowForm) { + + } @if (showProgramForm) { } @@ -360,4 +414,4 @@ @if (showCommunityForm) { } - + \ No newline at end of file diff --git a/src/app/admin/admin.component.scss b/src/app/admin/admin.component.scss index 77a45d2..3219673 100644 --- a/src/app/admin/admin.component.scss +++ b/src/app/admin/admin.component.scss @@ -110,6 +110,18 @@ text-overflow: ellipsis; } + .slot-badge { + display: inline-block; + background: rgba($primary-blue, 0.1); + color: $primary-blue; + font-size: 0.75rem; + font-family: var(--font-mono, monospace); + padding: 2px 8px; + border-radius: 10px; + margin: 2px; + white-space: nowrap; + } + .empty-row { text-align: center; color: $neutral-medium; diff --git a/src/app/admin/admin.component.ts b/src/app/admin/admin.component.ts index 0d2442b..d8a0878 100644 --- a/src/app/admin/admin.component.ts +++ b/src/app/admin/admin.component.ts @@ -4,6 +4,7 @@ import { FormsModule } from '@angular/forms'; import { BehaviorSubject, firstValueFrom } from 'rxjs'; import { Program } from '../interfaces/program'; +import { Show } from '../interfaces/show'; import { Event } from '../interfaces/event'; import { Tier } from '../interfaces/tier'; import { StationConfig } from '../interfaces/station-config'; @@ -11,6 +12,7 @@ import { HistoryEntry } from '../interfaces/history-entry'; import { TeamMember } from '../interfaces/team-member'; import { CommunityHighlight } from '../interfaces/community-highlight'; import { ProgramService } from '../services/program.service'; +import { ShowService } from '../services/show.service'; import { EventService } from '../services/event.service'; import { TierService } from '../services/tier.service'; import { StationConfigService } from '../services/station-config.service'; @@ -18,6 +20,7 @@ import { HistoryEntryService } from '../services/history-entry.service'; import { TeamMemberService } from '../services/team-member.service'; import { CommunityHighlightService } from '../services/community-highlight.service'; import { AdminProgramFormComponent } from './admin-program-form.component'; +import { AdminShowFormComponent } from './admin-show-form.component'; import { AdminEventFormComponent } from './admin-event-form.component'; import { AdminTierFormComponent } from './admin-tier-form.component'; import { AdminStationFormComponent } from './admin-station-form.component'; @@ -25,7 +28,7 @@ import { AdminHistoryFormComponent } from './admin-history-form.component'; import { AdminTeamFormComponent } from './admin-team-form.component'; import { AdminCommunityFormComponent } from './admin-community-form.component'; -type TabKey = 'programs' | 'events' | 'tiers' | 'station' | 'history' | 'team' | 'community'; +type TabKey = 'shows' | 'programs' | 'events' | 'tiers' | 'station' | 'history' | 'team' | 'community'; @Component({ selector: 'app-admin', @@ -34,6 +37,7 @@ type TabKey = 'programs' | 'events' | 'tiers' | 'station' | 'history' | 'team' | CommonModule, AsyncPipe, FormsModule, + AdminShowFormComponent, AdminProgramFormComponent, AdminEventFormComponent, AdminTierFormComponent, @@ -47,6 +51,7 @@ type TabKey = 'programs' | 'events' | 'tiers' | 'station' | 'history' | 'team' | }) export class AdminComponent implements OnInit { private programService = inject(ProgramService); + private showService = inject(ShowService); private eventService = inject(EventService); private tierService = inject(TierService); private stationConfigService = inject(StationConfigService); @@ -54,9 +59,10 @@ export class AdminComponent implements OnInit { private teamMemberService = inject(TeamMemberService); private communityHighlightService = inject(CommunityHighlightService); - activeTab = signal('programs'); + activeTab = signal('shows'); /** Item currently being edited (set by editXxx, cleared on save/close). */ + editingShow: Show | null = null; editingProgram: Program | null = null; editingEvent: Event | null = null; editingTier: Tier | null = null; @@ -65,6 +71,7 @@ export class AdminComponent implements OnInit { editingCommunity: CommunityHighlight | null = null; /** BehaviorSubjects guarantee change detection via the async pipe — start empty, populated on load. */ + readonly shows$ = new BehaviorSubject([]); readonly programs$ = new BehaviorSubject([]); readonly events$ = new BehaviorSubject([]); readonly tiers$ = new BehaviorSubject([]); @@ -73,6 +80,7 @@ export class AdminComponent implements OnInit { readonly teamMembers$ = new BehaviorSubject([]); readonly communityHighlights$ = new BehaviorSubject([]); + showShowForm = false; showProgramForm = false; showEventForm = false; showTierForm = false; @@ -85,6 +93,11 @@ export class AdminComponent implements OnInit { this.loadAll(); } + async reloadShows(): Promise { + const data = await firstValueFrom(this.showService.getShows()); + this.shows$.next(data); + } + async reloadPrograms(): Promise { const data = await firstValueFrom(this.programService.getPrograms()); this.programs$.next(data); @@ -122,6 +135,7 @@ export class AdminComponent implements OnInit { async loadAll(): Promise { await Promise.all([ + this.reloadShows(), this.reloadPrograms(), this.reloadEvents(), this.reloadTiers(), @@ -132,6 +146,35 @@ export class AdminComponent implements OnInit { ]); } + // ── Show CRUD ─────────────────────────────────────────── + + openShowForm(): void { + this.editingShow = null; + this.showShowForm = true; + } + + editShow(show: Show): void { + this.editingShow = show; + this.showShowForm = true; + } + + onShowSaved(): void { + this.reloadShows(); + this.showShowForm = false; + this.editingShow = null; + } + + onShowClosed(): void { + this.showShowForm = false; + this.editingShow = null; + } + + async deleteShow(id: number): Promise { + if (!confirm('Delete this show?')) return; + await firstValueFrom(this.showService.deleteShow(id)); + await this.reloadShows(); + } + // ── Program CRUD ──────────────────────────────────────── openProgramForm(): void { diff --git a/src/app/interfaces/show.ts b/src/app/interfaces/show.ts new file mode 100644 index 0000000..56ded76 --- /dev/null +++ b/src/app/interfaces/show.ts @@ -0,0 +1,20 @@ +export interface ShowSchedule { + id: number; + show_id: number; + day_of_week: number; + day_label: string; + time: string; +} + +export interface Show { + id: number; + title: string; + description: string; + host: string; + genre: string; + show_art_url: string | null; + hero_image_url: string | null; + display_order: number; + active: boolean; + schedules: ShowSchedule[]; +} diff --git a/src/app/pipes/sort-schedules.pipe.ts b/src/app/pipes/sort-schedules.pipe.ts new file mode 100644 index 0000000..b8e635e --- /dev/null +++ b/src/app/pipes/sort-schedules.pipe.ts @@ -0,0 +1,33 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +import { ShowSchedule } from '../interfaces/show'; + +const DAY_ORDER = { + Monday: 1, + Tuesday: 2, + Wednesday: 3, + Thursday: 4, + Friday: 5, + Saturday: 6, + Sunday: 7, +}; + +/** Sort schedule slots by day of week, then by time string. */ +@Pipe({ + name: 'sortSchedules', + standalone: true, + pure: true, +}) +export class SortSchedulesPipe implements PipeTransform { + transform(schedules: ShowSchedule[]): ShowSchedule[] { + if (!schedules?.length) { + return []; + } + return [...schedules].sort((a, b) => { + const dayA = DAY_ORDER[a.day_label as keyof typeof DAY_ORDER] ?? 9; + const dayB = DAY_ORDER[b.day_label as keyof typeof DAY_ORDER] ?? 9; + if (dayA !== dayB) return dayA - dayB; + return a.time.localeCompare(b.time); + }); + } +} diff --git a/src/app/schedule/schedule.component.html b/src/app/schedule/schedule.component.html index 0607af6..360b54d 100644 --- a/src/app/schedule/schedule.component.html +++ b/src/app/schedule/schedule.component.html @@ -12,31 +12,47 @@ @if (schedule$ | async; as state) { @if (state.loading) {
Loading schedule…
- } @else if (state.dayGroups.length === 0) { -
No programs scheduled. Check back soon!
+ } @else if (state.shows.length === 0) { +
No shows scheduled. Check back soon!
} @else { - @for (group of state.dayGroups; track group.day_label) { -
-
{{ group.day_label }}
-
- @for (program of group.programs; track program.id) { -
-
{{ program.time }}
-
-
-

{{ program.title }}

-
- - {{ program.host }} +
+ @for (show of state.shows; track show.id) { +
+ +
+ @if (show.show_art_url) { + + } @else { +
+ +
+ } +
+ + +
+
+
+

{{ show.title }}

+

{{ show.description }}

+
+ + {{ show.host }} + + {{ show.genre }} +
+
+ @for (slot of show.schedules | sortSchedules; track slot.id) { + + {{ slot.day_label }} {{ slot.time }} - {{ program.genre }} -
+ }
- } -
-
- } +
+ + } +
} }
diff --git a/src/app/schedule/schedule.component.scss b/src/app/schedule/schedule.component.scss index f65cb88..211ee51 100644 --- a/src/app/schedule/schedule.component.scss +++ b/src/app/schedule/schedule.component.scss @@ -26,89 +26,210 @@ font-size: 1.1rem; } -.schedule-day-block { - max-width: 800px; - margin: 0 auto $spacing-xl; -} +// ── Show list (vertical stack) ───────────────────────────── -.schedule-day-label { - text-align: center; - font-family: var(--font-heading); - font-size: 1.2rem; - color: var(--color-primary); - margin-bottom: $spacing-md; - font-weight: 600; -} - -.schedule-table { +.show-list { display: flex; flex-direction: column; - gap: 0; + gap: $spacing-lg; } -.schedule-row { +// ── Horizontal show card ─────────────────────────────────── + +.show-card { display: flex; + flex-direction: row; align-items: stretch; - padding: $spacing-md 0; - border-bottom: 1px solid var(--color-light); - transition: background $transition-fast; + border-radius: $radius-lg; + overflow: hidden; + box-shadow: $shadow-md; + transition: transform $transition-normal, box-shadow $transition-normal; + min-height: 180px; &:hover { - background: var(--color-cream); + transform: translateY(-2px); + box-shadow: $shadow-lg; } } -.schedule-time { - width: 100px; +// ── Left: Show art badge (square) ────────────────────────── + +.show-card-art { flex-shrink: 0; - font-family: var(--font-mono); - font-size: 0.95rem; - color: var(--color-accent); - font-weight: 600; - padding-right: $spacing-md; - text-align: right; - padding-top: 2px; -} - -.schedule-divider { - width: 2px; - background: var(--color-accent-muted); - margin: 0 $spacing-sm; - border-radius: 1px; - opacity: 0.4; -} - -.schedule-details { - flex: 1; - padding: $spacing-xs 0; -} - -.schedule-title { - font-size: 1.05rem; - color: var(--color-primary); - margin-bottom: $spacing-xs; -} - -.schedule-meta { + width: 140px; display: flex; + align-items: center; + justify-content: center; + background: var(--color-primary-dark); + padding: $spacing-md; + + .show-art-img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: $radius-md; + display: block; + } + + .show-art-placeholder { + width: 100%; + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.12); + border-radius: $radius-md; + font-size: 3rem; + color: var(--color-accent); + } +} + +// ── Right: Hero-backed info panel ────────────────────────── + +.show-card-right { + flex: 1; + min-width: 0; + position: relative; + display: flex; + flex-direction: column; + justify-content: center; + background-color: var(--color-light); + + &.has-hero { + background-size: cover; + background-position: center; + } +} + +.show-card-overlay { + position: absolute; + inset: 0; + background: linear-gradient( + to right, + rgba(26, 36, 50, 0.95) 0%, + rgba(26, 36, 50, 0.85) 60%, + rgba(26, 36, 50, 0.7) 100% + ); + z-index: 1; +} + +.show-card-info { + position: relative; + z-index: 2; + padding: $spacing-lg $spacing-xl; + min-width: 0; +} + +// ── Show title ───────────────────────────────────────────── + +.show-title { + font-family: var(--font-heading); + font-size: 1.4rem; + color: var(--color-white); + margin: 0 0 $spacing-sm; + line-height: 1.25; +} + +// ── Show description (line-clamped) ──────────────────────── + +.show-description { + font-size: 0.95rem; + color: rgba(255, 255, 255, 0.82); + margin: 0 0 $spacing-md; + line-height: 1.5; + + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +// ── Meta row (host + genre) ──────────────────────────────── + +.show-meta { + display: flex; + flex-wrap: wrap; gap: $spacing-md; font-size: 0.85rem; - color: var(--color-medium); + margin-bottom: $spacing-md; - .schedule-host { + .show-host { + color: var(--color-accent); display: flex; align-items: center; gap: 4px; + font-weight: 500; } - .schedule-genre { - background: var(--color-light); - padding: 2px 10px; + .show-genre { + background: rgba(255, 255, 255, 0.15); + padding: 3px 12px; border-radius: 10px; + color: rgba(255, 255, 255, 0.88); font-style: italic; } } -@media (max-width: #{$bp-md - 1px}) { - .schedule-time { width: 80px; font-size: 0.85rem; } +// ── Schedule slot tags ───────────────────────────────────── + +.show-schedule-slots { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.slot-tag { + display: inline-block; + background: rgba(255, 255, 255, 0.16); + color: var(--color-white); + font-size: 0.78rem; + font-family: var(--font-mono); + padding: 4px 12px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.12); + white-space: nowrap; + letter-spacing: 0.02em; +} + +// ── Responsive ───────────────────────────────────────────── + +@include responsive(md) { + .show-card-art { + width: 110px; + padding: $spacing-sm; + } + + .show-card-info { + padding: $spacing-md $spacing-lg; + } + + .show-title { + font-size: 1.2rem; + } + + .show-description { + -webkit-line-clamp: 2; + font-size: 0.88rem; + } +} + +@include responsive(sm) { + .show-card { + flex-direction: column; + min-height: auto; + } + + .show-card-art { + width: 100%; + aspect-ratio: 16 / 9; + padding: $spacing-md; + } + + .show-card-info { + padding: $spacing-md $spacing-lg $spacing-lg; + } + + .show-description { + -webkit-line-clamp: 2; + } } diff --git a/src/app/schedule/schedule.component.ts b/src/app/schedule/schedule.component.ts index 5f9b862..fad23b5 100644 --- a/src/app/schedule/schedule.component.ts +++ b/src/app/schedule/schedule.component.ts @@ -1,55 +1,32 @@ import { Component, inject } from '@angular/core'; -import { AsyncPipe, NgFor } from '@angular/common'; +import { AsyncPipe } from '@angular/common'; import { map, Observable, startWith } from 'rxjs'; -import { ProgramService } from '../services/program.service'; -import { Program } from '../interfaces/program'; +import { ShowService } from '../services/show.service'; +import { Show } from '../interfaces/show'; import { StationConfigService } from '../services/station-config.service'; - -interface DayGroup { - day_label: string; - day_of_week: number; - programs: Program[]; -} +import { SortSchedulesPipe } from '../pipes/sort-schedules.pipe'; interface ScheduleState { loading: boolean; - dayGroups: DayGroup[]; -} - -function groupByDay(programs: Program[]): DayGroup[] { - const map = new Map(); - - for (const p of programs) { - if (!map.has(p.day_label)) { - map.set(p.day_label, { - day_label: p.day_label, - day_of_week: p.day_of_week, - programs: [], - }); - } - map.get(p.day_label)!.programs.push(p); - } - - return Array.from(map.values()).sort((a, b) => a.day_of_week - b.day_of_week); + shows: Show[]; } @Component({ selector: 'app-schedule', standalone: true, - imports: [AsyncPipe], + imports: [AsyncPipe, SortSchedulesPipe], templateUrl: './schedule.component.html', styleUrl: './schedule.component.scss', }) export class ScheduleComponent { - private programService = inject(ProgramService); + private showService = inject(ShowService); readonly config = inject(StationConfigService).config; /** Async pipe drives the template — guarantees change detection on every emission. */ readonly schedule$: Observable = - this.programService.getPrograms().pipe( - map((programs) => groupByDay(programs)), - map((dayGroups) => ({ loading: false, dayGroups })), - startWith({ loading: true, dayGroups: [] }), + this.showService.getShows().pipe( + map((shows) => ({ loading: false, shows })), + startWith({ loading: true, shows: [] }), ); } diff --git a/src/app/services/show.service.ts b/src/app/services/show.service.ts new file mode 100644 index 0000000..4b84201 --- /dev/null +++ b/src/app/services/show.service.ts @@ -0,0 +1,62 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +import { Show } from '../interfaces/show'; +import { getAppConfig } from './app-config.service'; + +export interface ShowScheduleCreate { + day_of_week: number; + time: string; +} + +export interface ShowCreatePayload { + title: string; + description: string; + host: string; + genre: string; + show_art_url: string | null; + hero_image_url: string | null; + display_order: number; + schedules: ShowScheduleCreate[]; +} + +export interface ShowUpdatePayload { + title?: string; + description?: string; + host?: string; + genre?: string; + show_art_url?: string | null; + hero_image_url?: string | null; + display_order?: number; + active?: boolean; + schedules?: ShowScheduleCreate[]; +} + +@Injectable({ + providedIn: 'root', +}) +export class ShowService { + private http = inject(HttpClient); + private baseUrl = `${getAppConfig().apiBaseUrl}/api/shows`; + + getShows(): Observable { + return this.http.get(this.baseUrl); + } + + getShow(id: number): Observable { + return this.http.get(`${this.baseUrl}/${id}`); + } + + createShow(payload: ShowCreatePayload): Observable { + return this.http.post(this.baseUrl, payload); + } + + updateShow(id: number, payload: ShowUpdatePayload): Observable { + return this.http.put(`${this.baseUrl}/${id}`, payload); + } + + deleteShow(id: number): Observable { + return this.http.delete(`${this.baseUrl}/${id}`); + } +} \ No newline at end of file