New programming screen

This commit is contained in:
2026-06-21 21:55:55 +00:00
parent 96d0fef792
commit e627fe3637
24 changed files with 1297 additions and 122 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3 -1
View File
@@ -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())
+118
View File
@@ -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()
+4 -3
View File
@@ -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":
+32 -1
View File
@@ -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"
+61
View File
@@ -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
+177 -1
View File
@@ -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())
@@ -0,0 +1,79 @@
<div class="form-overlay" (click)="onClose()">
<div class="form-dialog" (click)="$event.stopPropagation()">
<div class="form-header">
<h2>{{ id ? 'Edit Show' : 'Add Show' }}</h2>
<button type="button" class="btn-close" (click)="onClose()">&times;</button>
</div>
@if (error) {
<div class="form-error" role="alert">{{ error }}</div>
}
<form (ngSubmit)="onSubmit()" class="admin-form" #form="ngForm">
<div class="form-group">
<label for="title">Title</label>
<input id="title" type="text" [(ngModel)]="title" name="title" placeholder="Show title" required [class.is-invalid]="submitted && !title">
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" [(ngModel)]="description" name="description" rows="3" placeholder="What is this show about?"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="host">Host</label>
<input id="host" type="text" [(ngModel)]="host" name="host" placeholder="Host name" required [class.is-invalid]="submitted && !host">
</div>
<div class="form-group">
<label for="genre">Genre</label>
<input id="genre" type="text" [(ngModel)]="genre" name="genre" placeholder="Genre" required [class.is-invalid]="submitted && !genre">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="show_art_url">Art URL</label>
<input id="show_art_url" type="text" [(ngModel)]="show_art_url" name="show_art_url" placeholder="uploads/show-art.jpg">
</div>
<div class="form-group">
<label for="hero_image_url">Hero Image URL</label>
<input id="hero_image_url" type="text" [(ngModel)]="hero_image_url" name="hero_image_url" placeholder="uploads/hero-bg.jpg">
</div>
</div>
<div class="form-group">
<label for="display_order">Display Order</label>
<input id="display_order" type="number" [(ngModel)]="display_order" name="display_order" min="0">
</div>
<!-- Schedule slots -->
<div class="form-group">
<label>Schedule Slots</label>
<div class="schedule-slots">
@for (slot of schedules; track slot.day_of_week; let i = $index) {
<div class="slot-row">
<select [(ngModel)]="slot.day_of_week" [name]="'slot_day_' + i">
@for (day of days; track day.value) {
<option [value]="day.value">{{ day.label }}</option>
}
</select>
<input type="text" [(ngModel)]="slot.time" [name]="'slot_time_' + i" placeholder="6:00 AM">
<button type="button" class="btn-remove-slot" (click)="removeScheduleSlot(i)" [disabled]="schedules.length <= 1" title="Remove slot">&times;</button>
</div>
}
</div>
<button type="button" class="btn-add-slot" (click)="addScheduleSlot()">+ Add Slot</button>
</div>
<div class="form-actions">
<button type="button" class="btn btn-cancel" (click)="onClose()">Cancel</button>
<button type="submit" class="btn btn-save" [disabled]="loading">
{{ loading ? 'Saving...' : (id ? 'Update' : 'Create') }}
</button>
</div>
</form>
</div>
</div>
@@ -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;
}
}
+153
View File
@@ -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<void>();
@Output() closed = new EventEmitter<void>();
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<void> {
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;
}
}
}
+56 -2
View File
@@ -2,11 +2,16 @@
<div class="container">
<div class="admin-header">
<h1>Admin Dashboard</h1>
<p>Manage programs, events, tiers, history, team, and community content</p>
<p>Manage shows, programs, events, tiers, history, team, and community content</p>
</div>
<!-- Tabs -->
<div class="admin-tabs">
<button
class="tab-btn"
[class.active]="activeTab() === 'shows'"
(click)="activeTab.set('shows')"
>Shows</button>
<button
class="tab-btn"
[class.active]="activeTab() === 'programs'"
@@ -44,6 +49,52 @@
>Station</button>
</div>
<!-- Shows Tab -->
@if (activeTab() === 'shows') {
@if (shows$ | async; as shows) {
<div class="tab-content">
<div class="tab-toolbar">
<h2>Shows ({{ shows.length }})</h2>
<button class="btn btn-add" (click)="openShowForm()">+ Add Show</button>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Order</th>
<th>Title</th>
<th>Host</th>
<th>Genre</th>
<th>Slots</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (show of shows; track show.id) {
<tr>
<td>{{ show.display_order }}</td>
<td>{{ show.title }}</td>
<td>{{ show.host }}</td>
<td>{{ show.genre }}</td>
<td>
@for (slot of show.schedules; track slot.id) {
<span class="slot-badge">{{ slot.day_label.charAt(0) }} {{ slot.time }}</span>
}
</td>
<td class="actions">
<button class="btn-icon btn-edit" (click)="editShow(show)">Edit</button>
<button class="btn-icon btn-delete" (click)="deleteShow(show.id)">Delete</button>
</td>
</tr>
} @empty {
<tr><td colspan="6" class="empty-row">No shows yet. Click "Add Show" to create one.</td></tr>
}
</tbody>
</table>
</div>
}
}
<!-- Programs Tab -->
@if (activeTab() === 'programs') {
@if (programs$ | async; as programs) {
@@ -339,6 +390,9 @@
</div>
<!-- Form modals -->
@if (showShowForm) {
<app-admin-show-form [editItem]="editingShow" (saved)="onShowSaved()" (closed)="onShowClosed()" />
}
@if (showProgramForm) {
<app-admin-program-form [editItem]="editingProgram" (saved)="onProgramSaved()" (closed)="onProgramClosed()" />
}
@@ -360,4 +414,4 @@
@if (showCommunityForm) {
<app-admin-community-form [editItem]="editingCommunity" (saved)="onCommunitySaved()" (closed)="onCommunityClosed()" />
}
</section>
</section>
+12
View File
@@ -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;
+45 -2
View File
@@ -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<TabKey>('programs');
activeTab = signal<TabKey>('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<Show[]>([]);
readonly programs$ = new BehaviorSubject<Program[]>([]);
readonly events$ = new BehaviorSubject<Event[]>([]);
readonly tiers$ = new BehaviorSubject<Tier[]>([]);
@@ -73,6 +80,7 @@ export class AdminComponent implements OnInit {
readonly teamMembers$ = new BehaviorSubject<TeamMember[]>([]);
readonly communityHighlights$ = new BehaviorSubject<CommunityHighlight[]>([]);
showShowForm = false;
showProgramForm = false;
showEventForm = false;
showTierForm = false;
@@ -85,6 +93,11 @@ export class AdminComponent implements OnInit {
this.loadAll();
}
async reloadShows(): Promise<void> {
const data = await firstValueFrom(this.showService.getShows());
this.shows$.next(data);
}
async reloadPrograms(): Promise<void> {
const data = await firstValueFrom(this.programService.getPrograms());
this.programs$.next(data);
@@ -122,6 +135,7 @@ export class AdminComponent implements OnInit {
async loadAll(): Promise<void> {
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<void> {
if (!confirm('Delete this show?')) return;
await firstValueFrom(this.showService.deleteShow(id));
await this.reloadShows();
}
// ── Program CRUD ────────────────────────────────────────
openProgramForm(): void {
+20
View File
@@ -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[];
}
+33
View File
@@ -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);
});
}
}
+37 -21
View File
@@ -12,31 +12,47 @@
@if (schedule$ | async; as state) {
@if (state.loading) {
<div class="schedule-loading">Loading schedule&hellip;</div>
} @else if (state.dayGroups.length === 0) {
<div class="schedule-empty">No programs scheduled. Check back soon!</div>
} @else if (state.shows.length === 0) {
<div class="schedule-empty">No shows scheduled. Check back soon!</div>
} @else {
@for (group of state.dayGroups; track group.day_label) {
<div class="schedule-day-block">
<div class="schedule-day-label">{{ group.day_label }}</div>
<div class="schedule-table">
@for (program of group.programs; track program.id) {
<div class="schedule-row">
<div class="schedule-time">{{ program.time }}</div>
<div class="schedule-divider"></div>
<div class="schedule-details">
<h4 class="schedule-title">{{ program.title }}</h4>
<div class="schedule-meta">
<span class="schedule-host">
<span aria-hidden="true">&#x1F399;</span> {{ program.host }}
<div class="show-list">
@for (show of state.shows; track show.id) {
<article class="show-card">
<!-- Left: Show art badge -->
<div class="show-card-art">
@if (show.show_art_url) {
<img [src]="show.show_art_url" [alt]="show.title + ' art'" class="show-art-img" />
} @else {
<div class="show-art-placeholder">
<span aria-hidden="true">&#x1F3B5;</span>
</div>
}
</div>
<!-- Right: Hero-backed info area -->
<div class="show-card-right" [class.has-hero]="show.hero_image_url" [style.background-image]="show.hero_image_url ? 'url(' + show.hero_image_url + ')' : ''">
<div class="show-card-overlay"></div>
<div class="show-card-info">
<h3 class="show-title">{{ show.title }}</h3>
<p class="show-description">{{ show.description }}</p>
<div class="show-meta">
<span class="show-host">
<span aria-hidden="true">&#x1F399;</span> {{ show.host }}
</span>
<span class="show-genre">{{ show.genre }}</span>
</div>
<div class="show-schedule-slots">
@for (slot of show.schedules | sortSchedules; track slot.id) {
<span class="slot-tag">
{{ slot.day_label }} {{ slot.time }}
</span>
<span class="schedule-genre">{{ program.genre }}</span>
</div>
}
</div>
</div>
}
</div>
</div>
}
</div>
</article>
}
</div>
}
}
</div>
+179 -58
View File
@@ -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;
}
}
+10 -33
View File
@@ -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<string, DayGroup>();
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<ScheduleState> =
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: [] }),
);
}
+62
View File
@@ -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<Show[]> {
return this.http.get<Show[]>(this.baseUrl);
}
getShow(id: number): Observable<Show> {
return this.http.get<Show>(`${this.baseUrl}/${id}`);
}
createShow(payload: ShowCreatePayload): Observable<Show> {
return this.http.post<Show>(this.baseUrl, payload);
}
updateShow(id: number, payload: ShowUpdatePayload): Observable<Show> {
return this.http.put<Show>(`${this.baseUrl}/${id}`, payload);
}
deleteShow(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
}