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 @@
Choose Your Level of Support
- @if (loading) {
-
Loading tiers…
- } @else if (tiers.length === 0) {
-
No donation tiers available. Check back soon!
- } @else {
-
-
-
{{ tier.icon }}
-
${{ tier.amount }}
-
per month
-
{{ tier.name }}
-
-
Select {{ tier.name }}
+ @if (tiers$ | async; as state) {
+ @if (state.loading) {
+
Loading tiers…
+ } @else if (state.tiers.length === 0) {
+
No donation tiers available. Check back soon!
+ } @else {
+
-
+ }
}
diff --git a/src/app/donate/donate.component.ts b/src/app/donate/donate.component.ts
index 56751a0..b70f2ba 100644
--- a/src/app/donate/donate.component.ts
+++ b/src/app/donate/donate.component.ts
@@ -1,32 +1,29 @@
-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 { TierService } from '../services/tier.service';
import { Tier } from '../interfaces/tier';
+interface TiersState {
+ loading: boolean;
+ tiers: Tier[];
+}
+
@Component({
selector: 'app-donate',
standalone: true,
- imports: [NgFor],
+ imports: [AsyncPipe, NgFor],
templateUrl: './donate.component.html',
styleUrl: './donate.component.scss',
})
-export class DonateComponent implements OnInit {
+export class DonateComponent {
private tierService = inject(TierService);
- tiers: Tier[] = [];
- loading = true;
-
- ngOnInit(): void {
- this.tierService.getTiers().subscribe({
- next: (data) => {
- this.tiers = data;
- this.loading = false;
- },
- error: () => {
- this.loading = false;
- this.tiers = [];
- },
- });
- }
+ /** Async pipe drives the template — guarantees change detection on every emission. */
+ readonly tiers$: Observable
=
+ this.tierService.getTiers().pipe(
+ map((tiers) => ({ loading: false, tiers })),
+ startWith({ loading: true, tiers: [] }),
+ );
}
diff --git a/src/app/events/events.component.html b/src/app/events/events.component.html
index 038a3fe..5eb78bf 100644
--- a/src/app/events/events.component.html
+++ b/src/app/events/events.component.html
@@ -10,35 +10,37 @@
- @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 }}
-
+ }
}
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
+ }
+
+ @if (isLoggedIn) {
+
+ } @else {
+
Sign In
+ }
+
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 @@
- @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 @@
+
+