diff --git a/.claude/projects/-workspaces-web-app/memory/MEMORY.md b/.claude/projects/-workspaces-web-app/memory/MEMORY.md new file mode 100644 index 0000000..11d542c --- /dev/null +++ b/.claude/projects/-workspaces-web-app/memory/MEMORY.md @@ -0,0 +1 @@ +- [Angular Zoneless Fix](angular-zoneless-fix.md) — Added zone.js to fix async HTTP change detection diff --git a/.claude/projects/-workspaces-web-app/memory/angular-zoneless-fix.md b/.claude/projects/-workspaces-web-app/memory/angular-zoneless-fix.md new file mode 100644 index 0000000..4c4f457 --- /dev/null +++ b/.claude/projects/-workspaces-web-app/memory/angular-zoneless-fix.md @@ -0,0 +1,12 @@ +--- +name: angular-zoneless-fix +description: Fixed Angular 21 zoneless default causing async HTTP callbacks to not trigger change detection +metadata: + type: project +--- + +Angular 21 defaults to zoneless execution. The project was missing zone.js entirely, so HTTP responses arrived but change detection never ran — components stayed stuck on loading states. + +**Fix:** Added `import 'zone.js'` to [src/main.ts](src/main.ts) and installed zone.js as a dependency. This is the simpler fix over configuring provideZoneChangeDetection() manually. + +Related: [[auto-seed-admin-bootstrap]] diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 8fbf7cf..1f3fc4f 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,6 +1,8 @@ FROM python:3.12-alpine -RUN apk update && apk add git nodejs npm +RUN apk update && apk add git nodejs npm bash # Install Angular.js via NPM -RUN npm install -g @angular/cli@21.2.14 \ No newline at end of file +RUN npm install -g @angular/cli@21.2.14 + +RUN /bin/bash \ No newline at end of file diff --git a/backend/__pycache__/seed.cpython-312.pyc b/backend/__pycache__/seed.cpython-312.pyc deleted file mode 100644 index af1e52f..0000000 Binary files a/backend/__pycache__/seed.cpython-312.pyc and /dev/null differ diff --git a/backend/app/__pycache__/auth.cpython-312.pyc b/backend/app/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000..d1adf3b Binary files /dev/null and b/backend/app/__pycache__/auth.cpython-312.pyc differ diff --git a/backend/app/__pycache__/config.cpython-312.pyc b/backend/app/__pycache__/config.cpython-312.pyc index 64826b3..7a00907 100644 Binary files a/backend/app/__pycache__/config.cpython-312.pyc 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 index 2e4fedc..7e7f255 100644 Binary files a/backend/app/__pycache__/database.cpython-312.pyc 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 index f4ac95d..dab4c2e 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__/schemas.cpython-312.pyc b/backend/app/__pycache__/schemas.cpython-312.pyc index 4578cd3..8272a8b 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/__pycache__/user_models.cpython-312.pyc b/backend/app/__pycache__/user_models.cpython-312.pyc new file mode 100644 index 0000000..7746fc5 Binary files /dev/null and b/backend/app/__pycache__/user_models.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/auth.cpython-312.pyc b/backend/app/api/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000..a8b7cce Binary files /dev/null and b/backend/app/api/__pycache__/auth.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/events.cpython-312.pyc b/backend/app/api/__pycache__/events.cpython-312.pyc index 41c6ceb..6ecf4ed 100644 Binary files a/backend/app/api/__pycache__/events.cpython-312.pyc 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 index 595095d..a1baa4a 100644 Binary files a/backend/app/api/__pycache__/programs.cpython-312.pyc 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 index 546c029..ee7913a 100644 Binary files a/backend/app/api/__pycache__/tiers.cpython-312.pyc and b/backend/app/api/__pycache__/tiers.cpython-312.pyc differ diff --git a/src/app/donate/donate.component.html b/src/app/donate/donate.component.html index 6a1e874..5248a92 100644 --- a/src/app/donate/donate.component.html +++ b/src/app/donate/donate.component.html @@ -17,23 +17,25 @@ - @if (loading) { -
Loading events…
- } @else if (upcomingEvents.length === 0) { -
No upcoming events — check back later!
- } @else { -
-
-
- {{ event.date | date:'MMM' }} - {{ event.date | date:'d' }} - {{ event.date | date:'yyyy' }} -
-
-

{{ event.title }}

-

{{ event.description }}

-
- - {{ event.location }} - - @if (event.rsvp_url) { - RSVP → - } @else { - Learn More → - } + @if (events$ | async; as state) { + @if (state.loading) { +
Loading events…
+ } @else if (state.upcomingEvents.length === 0) { +
No upcoming events — check back later!
+ } @else { +
+
+
+ {{ event.date | date:'MMM' }} + {{ event.date | date:'d' }} + {{ event.date | date:'yyyy' }}
+
+

{{ event.title }}

+

{{ event.description }}

+
+ + {{ event.location }} + + @if (event.rsvp_url) { + RSVP → + } @else { + Learn More → + } +
+
+
-
-
+ } }
diff --git a/src/app/events/events.component.ts b/src/app/events/events.component.ts index e7ea1fc..b5cf1c5 100644 --- a/src/app/events/events.component.ts +++ b/src/app/events/events.component.ts @@ -1,32 +1,29 @@ -import { Component, inject, OnInit } from '@angular/core'; -import { DatePipe, NgFor } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { AsyncPipe, DatePipe, NgFor } from '@angular/common'; +import { map, Observable, startWith } from 'rxjs'; import { EventService } from '../services/event.service'; import { Event } from '../interfaces/event'; +interface EventsState { + loading: boolean; + upcomingEvents: Event[]; +} + @Component({ selector: 'app-events', standalone: true, - imports: [DatePipe, NgFor], + imports: [AsyncPipe, DatePipe, NgFor], templateUrl: './events.component.html', styleUrl: './events.component.scss', }) -export class EventsComponent implements OnInit { +export class EventsComponent { private eventService = inject(EventService); - upcomingEvents: Event[] = []; - loading = true; - - ngOnInit(): void { - this.eventService.getEvents(true).subscribe({ - next: (data) => { - this.upcomingEvents = data; - this.loading = false; - }, - error: () => { - this.loading = false; - this.upcomingEvents = []; - }, - }); - } + /** Async pipe drives the template — guarantees change detection on every emission. */ + readonly events$: Observable = + this.eventService.getEvents(true).pipe( + map((upcomingEvents) => ({ loading: false, upcomingEvents })), + startWith({ loading: true, upcomingEvents: [] }), + ); } diff --git a/src/app/navbar/navbar.component.html b/src/app/navbar/navbar.component.html index 3b9f6c1..c2e8d7c 100644 --- a/src/app/navbar/navbar.component.html +++ b/src/app/navbar/navbar.component.html @@ -21,7 +21,20 @@
  • Schedule
  • Events
  • Contact
  • + @if (isAdmin) { +
  • Admin
  • + } + Donate Now
    diff --git a/src/app/navbar/navbar.component.scss b/src/app/navbar/navbar.component.scss index 59c9a4a..391e0e4 100644 --- a/src/app/navbar/navbar.component.scss +++ b/src/app/navbar/navbar.component.scss @@ -99,6 +99,62 @@ background: rgba(232, 122, 46, 0.1); } } + + .nav-admin { + color: var(--color-accent-light); + font-weight: 600; + } +} + +// Auth section in navbar +.nav-auth { + display: flex; + align-items: center; + gap: $spacing-sm; + margin-left: $spacing-sm; +} + +.user-menu { + display: flex; + align-items: center; + gap: $spacing-sm; + + .user-name { + color: var(--color-light); + font-size: 0.875rem; + font-weight: 500; + } +} + +.btn-logout { + background: none; + border: 1px solid rgba(255, 255, 255, 0.3); + color: var(--color-light); + padding: $spacing-xs $spacing-sm; + border-radius: $radius-sm; + font-size: 0.8rem; + cursor: pointer; + transition: border-color $transition-fast, color $transition-fast; + + &:hover { + border-color: var(--color-accent); + color: var(--color-accent-light); + } +} + +.btn-sign-in { + color: var(--color-accent-light); + font-size: 0.9rem; + font-weight: 500; + text-decoration: none; + padding: $spacing-xs $spacing-sm; + border-radius: $radius-sm; + transition: color $transition-fast, background $transition-fast; + + &:hover { + color: var(--color-accent); + background: rgba(232, 122, 46, 0.1); + } } .btn-donate-nav { diff --git a/src/app/navbar/navbar.component.ts b/src/app/navbar/navbar.component.ts index c9edad5..a405008 100644 --- a/src/app/navbar/navbar.component.ts +++ b/src/app/navbar/navbar.component.ts @@ -1,16 +1,40 @@ -import { Component } from '@angular/core'; -import { RouterLink, RouterLinkActive } from '@angular/router'; +import { Component, DestroyRef, inject, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterLink, RouterLinkActive, Router } from '@angular/router'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +import { AuthService } from '../services/auth.service'; @Component({ selector: 'app-navbar', standalone: true, - imports: [RouterLink, RouterLinkActive], + imports: [CommonModule, RouterLink, RouterLinkActive], templateUrl: './navbar.component.html', styleUrl: './navbar.component.scss', }) -export class NavbarComponent { +export class NavbarComponent implements OnInit { + private auth = inject(AuthService); + private router = inject(Router); + private destroyRef = inject(DestroyRef); + menuOpen = false; + isLoggedIn = false; + isAdmin = false; + displayName = ''; + + ngOnInit(): void { + // Subscribe to auth state — drives all three properties from a single source. + // isLoggedIn and isAdmin are derived from the user object, not from separate + // checks (localStorage vs BehaviorSubject), eliminating a race condition where + // the token exists but the /me call hasn't returned yet. + this.auth.authState$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((user) => { + this.isLoggedIn = !!user; + this.isAdmin = user?.is_admin ?? false; + this.displayName = user?.display_name ?? ''; + }); + } + toggleMenu(): void { this.menuOpen = !this.menuOpen; } @@ -18,4 +42,9 @@ export class NavbarComponent { closeMenu(): void { this.menuOpen = false; } + + onLogout(): void { + this.auth.logout(); + this.router.navigate(['/']); + } } diff --git a/src/app/schedule/schedule.component.html b/src/app/schedule/schedule.component.html index b0cdb8e..b1d80c2 100644 --- a/src/app/schedule/schedule.component.html +++ b/src/app/schedule/schedule.component.html @@ -2,79 +2,42 @@
    -

    Today's Program Schedule

    +

    Program Schedule

    All times are local (Mountain Time). Every show is free to listen to live at 98.7 FM or via our online stream.

    - @if (loading) { -
    Loading schedule…
    - } @else if (programs.length === 0) { -
    No programs scheduled. Check back soon!
    - } @else { -
    -
    Monday – Friday
    -
    -
    -
    {{ program.time }}
    -
    -
    -

    {{ program.title }}

    -
    - - {{ program.host }} - - {{ program.genre }} -
    + @if (schedule$ | async; as state) { + @if (state.loading) { +
    Loading schedule…
    + } @else if (state.dayGroups.length === 0) { +
    No programs 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 }} + + {{ program.genre }} +
    +
    +
    + }
    -
    -
    + } + } } - -
    -
    - -

    Weekend Programming

    -
    -
    -
    -
    Saturday
    -
    -
    - 9:00 AM — Country Roads Hour -
    -
    - 11:00 AM — Community Hour (call-ins welcome!) -
    -
    - 2:00 PM — Afternoon Bluegrass -
    -
    - 4:00 PM — Record Request Hour -
    -
    -
    -
    -
    Sunday
    -
    -
    - 10:00 AM — Sunday Morning Hymns & Folk -
    -
    - 12:00 PM — Gospel & Roots -
    -
    - 3:00 PM — The Long Wave (ambient / nature sounds) -
    -
    - 6:00 PM — Sunday Sunset Jazz -
    -
    -
    -
    -
    diff --git a/src/app/schedule/schedule.component.scss b/src/app/schedule/schedule.component.scss index 0fa4cce..f65cb88 100644 --- a/src/app/schedule/schedule.component.scss +++ b/src/app/schedule/schedule.component.scss @@ -18,9 +18,17 @@ } } -.schedule-list { +.schedule-loading, +.schedule-empty { + text-align: center; + padding: $spacing-3xl $spacing-md; + color: var(--color-medium); + font-size: 1.1rem; +} + +.schedule-day-block { max-width: 800px; - margin: 0 auto $spacing-3xl; + margin: 0 auto $spacing-xl; } .schedule-day-label { @@ -101,73 +109,6 @@ } } -// Weekend -.schedule-weekend { - margin-top: $spacing-xl; -} - -.weekend-heading { - text-align: center; - margin-bottom: $spacing-lg; - - .schedule-flower-sm { - width: 35px; - height: 35px; - margin: 0 auto $spacing-sm; - opacity: 0.5; - } - - h3 { - font-size: 1.6rem; - color: var(--color-primary); - } -} - -.weekend-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: $spacing-lg; -} - -.weekend-card { - @include card-style; - padding: $spacing-xl; - background: var(--color-white); - - .card-day { - font-family: var(--font-heading); - font-size: 1.4rem; - font-weight: 700; - color: var(--color-accent); - margin-bottom: $spacing-md; - padding-bottom: $spacing-sm; - border-bottom: 2px solid var(--color-light); - } - - .card-programs { - display: flex; - flex-direction: column; - gap: $spacing-sm; - } - - .card-item { - font-size: 0.95rem; - color: var(--color-dark); - padding: $spacing-sm; - border-radius: $radius-sm; - transition: background $transition-fast; - - strong { - color: var(--color-primary); - } - - &:hover { - background: var(--color-cream); - } - } -} - @media (max-width: #{$bp-md - 1px}) { .schedule-time { width: 80px; font-size: 0.85rem; } - .weekend-grid { grid-template-columns: 1fr; } } diff --git a/src/app/schedule/schedule.component.ts b/src/app/schedule/schedule.component.ts index 28837f9..de61105 100644 --- a/src/app/schedule/schedule.component.ts +++ b/src/app/schedule/schedule.component.ts @@ -1,32 +1,53 @@ -import { Component, inject, OnInit } from '@angular/core'; -import { NgFor } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { AsyncPipe, NgFor } from '@angular/common'; +import { map, Observable, startWith } from 'rxjs'; import { ProgramService } from '../services/program.service'; import { Program } from '../interfaces/program'; +interface DayGroup { + day_label: string; + day_of_week: number; + programs: Program[]; +} + +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); +} + @Component({ selector: 'app-schedule', standalone: true, - imports: [NgFor], + imports: [AsyncPipe, NgFor], templateUrl: './schedule.component.html', styleUrl: './schedule.component.scss', }) -export class ScheduleComponent implements OnInit { +export class ScheduleComponent { private programService = inject(ProgramService); - programs: Program[] = []; - loading = true; - - ngOnInit(): void { - this.programService.getPrograms().subscribe({ - next: (data) => { - this.programs = data; - this.loading = false; - }, - error: () => { - this.loading = false; - this.programs = []; - }, - }); - } + /** 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: [] }), + ); } diff --git a/studio.svg b/studio.svg new file mode 100644 index 0000000..4332377 --- /dev/null +++ b/studio.svg @@ -0,0 +1,434 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ON AIR + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +