Admin mode. Data driven content sections. Bugfixes.

This commit is contained in:
2026-06-19 17:36:35 +00:00
parent bd65455945
commit 266c2451f1
30 changed files with 1733 additions and 13 deletions
@@ -0,0 +1,60 @@
<div class="form-overlay" (click)="onClose()">
<div class="form-dialog" (click)="$event.stopPropagation()">
<div class="form-header">
<h2>{{ id ? 'Edit Event' : 'Add Event' }}</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">
<div class="form-row">
<div class="form-group">
<label for="date">Date</label>
<input id="date" type="date" [(ngModel)]="date" name="date" required [class.is-invalid]="submitted && !date">
</div>
<div class="form-group">
<label for="icon">Icon</label>
<input id="icon" type="text" [(ngModel)]="icon" name="icon" placeholder="🎵" maxlength="10">
</div>
</div>
<div class="form-group">
<label for="title">Title</label>
<input id="title" type="text" [(ngModel)]="title" name="title" placeholder="Event 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="Event description" required [class.is-invalid]="submitted && !description"></textarea>
</div>
<div class="form-group">
<label for="location">Location</label>
<input id="location" type="text" [(ngModel)]="location" name="location" placeholder="Location" required [class.is-invalid]="submitted && !location">
</div>
<div class="form-group">
<label for="rsvp_url">RSVP URL (optional)</label>
<input id="rsvp_url" type="url" [(ngModel)]="rsvp_url" name="rsvp_url" placeholder="https://...">
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" [(ngModel)]="active" name="active">
Active
</label>
</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,146 @@
@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: 560px;
box-shadow: $shadow-xl;
max-height: 90vh;
overflow-y: auto;
}
.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);
}
}
}
.checkbox-group {
label {
display: flex;
align-items: center;
gap: $spacing-sm;
font-weight: 500;
cursor: pointer;
input[type="checkbox"] {
width: auto;
margin: 0;
}
}
}
}
.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;
}
}
@@ -0,0 +1,54 @@
<div class="form-overlay" (click)="onClose()">
<div class="form-dialog" (click)="$event.stopPropagation()">
<div class="form-header">
<h2>{{ id ? 'Edit Program' : 'Add Program' }}</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-row">
<div class="form-group">
<label for="day">Day</label>
<select id="day" [(ngModel)]="day_of_week" name="day" (change)="onDayChange()" required>
@for (day of days; track day.value) {
<option [value]="day.value">{{ day.label }}</option>
}
</select>
</div>
<div class="form-group">
<label for="time">Time</label>
<input id="time" type="text" [(ngModel)]="time" name="time" placeholder="6:00 AM" required [class.is-invalid]="submitted && !time">
</div>
</div>
<div class="form-group">
<label for="title">Title</label>
<input id="title" type="text" [(ngModel)]="title" name="title" placeholder="Program title" required [class.is-invalid]="submitted && !title">
</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-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,129 @@
@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: 560px;
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);
}
}
}
}
.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;
}
}
@@ -0,0 +1,50 @@
<div class="form-overlay" (click)="onClose()">
<div class="form-dialog" (click)="$event.stopPropagation()">
<div class="form-header">
<h2>{{ id ? 'Edit Tier' : 'Add Tier' }}</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">
<div class="form-row">
<div class="form-group">
<label for="name">Name</label>
<input id="name" type="text" [(ngModel)]="name" name="name" placeholder="Tier name" required [class.is-invalid]="submitted && !name">
</div>
<div class="form-group">
<label for="amount">Amount ($)</label>
<input id="amount" type="number" step="0.01" min="0" [(ngModel)]="amount" name="amount" required [class.is-invalid]="submitted && amount <= 0">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="icon">Icon</label>
<input id="icon" type="text" [(ngModel)]="icon" name="icon" placeholder="⭐" maxlength="10">
</div>
<div class="form-group">
<label for="display_order">Display Order</label>
<input id="display_order" type="number" min="1" [(ngModel)]="display_order" name="display_order" required>
</div>
</div>
<div class="form-group">
<label for="benefits">Benefits (comma-separated)</label>
<textarea id="benefits" [(ngModel)]="benefits_text" name="benefits" rows="3" placeholder="Benefit 1, Benefit 2, Benefit 3"></textarea>
</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,129 @@
@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: 560px;
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);
}
}
}
}
.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;
}
}
+160
View File
@@ -0,0 +1,160 @@
<section class="admin-page">
<div class="container">
<div class="admin-header">
<h1>Admin Dashboard</h1>
<p>Manage programs, events, and donation tiers</p>
</div>
<!-- Tabs -->
<div class="admin-tabs">
<button
class="tab-btn"
[class.active]="activeTab() === 'programs'"
(click)="activeTab.set('programs')"
>Programs</button>
<button
class="tab-btn"
[class.active]="activeTab() === 'events'"
(click)="activeTab.set('events')"
>Events</button>
<button
class="tab-btn"
[class.active]="activeTab() === 'tiers'"
(click)="activeTab.set('tiers')"
>Tiers</button>
</div>
<!-- Programs Tab -->
@if (activeTab() === 'programs') {
@if (programs$ | async; as programs) {
<div class="tab-content">
<div class="tab-toolbar">
<h2>Programs ({{ programs.length }})</h2>
<button class="btn btn-add" (click)="openProgramForm()">+ Add Program</button>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Day</th>
<th>Time</th>
<th>Title</th>
<th>Host</th>
<th>Genre</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (program of programs; track program.id) {
<tr>
<td>{{ program.day_label }}</td>
<td>{{ program.time }}</td>
<td>{{ program.title }}</td>
<td>{{ program.host }}</td>
<td>{{ program.genre }}</td>
<td class="actions">
<button class="btn-icon btn-edit" (click)="editProgram(program)">Edit</button>
<button class="btn-icon btn-delete" (click)="deleteProgram(program.id)">Delete</button>
</td>
</tr>
} @empty {
<tr><td colspan="6" class="empty-row">No programs yet. Click "Add Program" to create one.</td></tr>
}
</tbody>
</table>
</div>
}
}
<!-- Events Tab -->
@if (activeTab() === 'events') {
@if (events$ | async; as events) {
<div class="tab-content">
<div class="tab-toolbar">
<h2>Events ({{ events.length }})</h2>
<button class="btn btn-add" (click)="openEventForm()">+ Add Event</button>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Date</th>
<th>Title</th>
<th>Location</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (event of events; track event.id) {
<tr>
<td>{{ event.date }}</td>
<td>{{ event.title }}</td>
<td>{{ event.location }}</td>
<td>{{ event.active ? 'Yes' : 'No' }}</td>
<td class="actions">
<button class="btn-icon btn-edit" (click)="editEvent(event)">Edit</button>
<button class="btn-icon btn-delete" (click)="deleteEvent(event.id)">Delete</button>
</td>
</tr>
} @empty {
<tr><td colspan="5" class="empty-row">No events yet. Click "Add Event" to create one.</td></tr>
}
</tbody>
</table>
</div>
}
}
<!-- Tiers Tab -->
@if (activeTab() === 'tiers') {
@if (tiers$ | async; as tiers) {
<div class="tab-content">
<div class="tab-toolbar">
<h2>Donation Tiers ({{ tiers.length }})</h2>
<button class="btn btn-add" (click)="openTierForm()">+ Add Tier</button>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Order</th>
<th>Name</th>
<th>Amount</th>
<th>Benefits</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (tier of tiers; track tier.id) {
<tr>
<td>{{ tier.display_order }}</td>
<td>{{ tier.icon }} {{ tier.name }}</td>
<td>${{ tier.amount.toFixed(2) }}</td>
<td class="benefits-cell">{{ tier.benefits.join(', ') }}</td>
<td class="actions">
<button class="btn-icon btn-edit" (click)="editTier(tier)">Edit</button>
<button class="btn-icon btn-delete" (click)="deleteTier(tier.id)">Delete</button>
</td>
</tr>
} @empty {
<tr><td colspan="5" class="empty-row">No tiers yet. Click "Add Tier" to create one.</td></tr>
}
</tbody>
</table>
</div>
}
}
</div>
<!-- Form modals -->
@if (showProgramForm) {
<app-admin-program-form [editItem]="editingProgram" (saved)="onProgramSaved()" (closed)="onProgramClosed()" />
}
@if (showEventForm) {
<app-admin-event-form [editItem]="editingEvent" (saved)="onEventSaved()" (closed)="onEventClosed()" />
}
@if (showTierForm) {
<app-admin-tier-form [editItem]="editingTier" (saved)="onTierSaved()" (closed)="onTierClosed()" />
}
</section>
+177
View File
@@ -0,0 +1,177 @@
@use '../../styles/variables' as *;
@use '../../styles/mixins' as *;
.admin-page {
min-height: calc(100vh - 80px);
background: $neutral-cream;
padding: $spacing-xl 0;
}
.admin-header {
text-align: center;
margin-bottom: $spacing-xl;
h1 {
font-family: $font-heading;
color: $primary-blue;
font-size: 2rem;
margin: 0 0 $spacing-xs;
}
p {
color: $neutral-medium;
margin: 0;
}
}
// ── Tabs ─────────────────────────────────────────────────
.admin-tabs {
display: flex;
justify-content: center;
gap: $spacing-sm;
margin-bottom: $spacing-xl;
}
.tab-btn {
background: $neutral-white;
border: 1px solid $neutral-light;
border-radius: $radius-md;
padding: $spacing-sm $spacing-xl;
font-size: 0.95rem;
font-weight: 600;
color: $neutral-dark;
cursor: pointer;
transition: all $transition-fast;
&:hover {
border-color: $primary-blue-muted;
color: $primary-blue;
}
&.active {
background: $primary-blue;
border-color: $primary-blue;
color: $neutral-white;
}
}
// ── Tab content ──────────────────────────────────────────
.tab-content {
@include card-style;
padding: $spacing-lg;
}
.tab-toolbar {
@include flex-between;
margin-bottom: $spacing-lg;
h2 {
font-family: $font-heading;
color: $primary-blue;
font-size: 1.3rem;
margin: 0;
}
}
.btn-add {
@include button-style($success-green, $neutral-white, darken($success-green, 8%));
}
// ── Table ────────────────────────────────────────────────
.admin-table {
width: 100%;
border-collapse: collapse;
thead th {
text-align: left;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
color: $neutral-medium;
padding: $spacing-sm $spacing-md;
border-bottom: 2px solid $neutral-light;
}
tbody td {
padding: $spacing-sm $spacing-md;
border-bottom: 1px solid $neutral-light;
font-size: 0.95rem;
color: $neutral-dark;
vertical-align: middle;
}
.benefits-cell {
max-width: 300px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.empty-row {
text-align: center;
color: $neutral-medium;
padding: $spacing-xl !important;
}
.actions {
display: flex;
gap: $spacing-xs;
white-space: nowrap;
}
}
.btn-icon {
border: none;
padding: $spacing-xs $spacing-sm;
border-radius: $radius-sm;
font-size: 0.85rem;
cursor: pointer;
transition: background $transition-fast;
}
.btn-edit {
background: rgba($info-blue, 0.1);
color: $info-blue;
&:hover {
background: rgba($info-blue, 0.2);
}
}
.btn-delete {
background: rgba($danger-red, 0.1);
color: $danger-red;
&:hover {
background: rgba($danger-red, 0.2);
}
}
.loading-message {
text-align: center;
padding: $spacing-3xl;
color: $neutral-medium;
font-size: 1.1rem;
}
// ── Responsive ───────────────────────────────────────────
@include responsive(md) {
.admin-table {
font-size: 0.85rem;
thead th,
tbody td {
padding: $spacing-xs $spacing-sm;
}
}
.tab-toolbar {
flex-direction: column;
align-items: flex-start;
gap: $spacing-sm;
}
}
+166
View File
@@ -0,0 +1,166 @@
import { Component, inject, signal, OnInit } from '@angular/core';
import { CommonModule, AsyncPipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { Program } from '../interfaces/program';
import { Event } from '../interfaces/event';
import { Tier } from '../interfaces/tier';
import { ProgramService } from '../services/program.service';
import { EventService } from '../services/event.service';
import { TierService } from '../services/tier.service';
import { AdminProgramFormComponent } from './admin-program-form.component';
import { AdminEventFormComponent } from './admin-event-form.component';
import { AdminTierFormComponent } from './admin-tier-form.component';
type TabKey = 'programs' | 'events' | 'tiers';
@Component({
selector: 'app-admin',
standalone: true,
imports: [
CommonModule,
AsyncPipe,
FormsModule,
AdminProgramFormComponent,
AdminEventFormComponent,
AdminTierFormComponent,
],
templateUrl: './admin.component.html',
styleUrl: './admin.component.scss',
})
export class AdminComponent implements OnInit {
private programService = inject(ProgramService);
private eventService = inject(EventService);
private tierService = inject(TierService);
activeTab = signal<TabKey>('programs');
/** Item currently being edited (set by editXxx, cleared on save/close). */
editingProgram: Program | null = null;
editingEvent: Event | null = null;
editingTier: Tier | null = null;
/** BehaviorSubjects guarantee change detection via the async pipe — start empty, populated on load. */
readonly programs$ = new BehaviorSubject<Program[]>([]);
readonly events$ = new BehaviorSubject<Event[]>([]);
readonly tiers$ = new BehaviorSubject<Tier[]>([]);
showProgramForm = false;
showEventForm = false;
showTierForm = false;
ngOnInit(): void {
this.loadAll();
}
async reloadPrograms(): Promise<void> {
const data = await firstValueFrom(this.programService.getPrograms());
this.programs$.next(data);
}
async reloadEvents(): Promise<void> {
const data = await firstValueFrom(this.eventService.getEvents());
this.events$.next(data);
}
async reloadTiers(): Promise<void> {
const data = await firstValueFrom(this.tierService.getTiers());
this.tiers$.next(data);
}
async loadAll(): Promise<void> {
await Promise.all([
this.reloadPrograms(),
this.reloadEvents(),
this.reloadTiers(),
]);
}
// ── Program CRUD ────────────────────────────────────────
openProgramForm(): void {
this.editingProgram = null;
this.showProgramForm = true;
}
editProgram(program: Program): void {
this.editingProgram = program;
this.showProgramForm = true;
}
onProgramSaved(): void {
this.reloadPrograms();
this.showProgramForm = false;
this.editingProgram = null;
}
onProgramClosed(): void {
this.showProgramForm = false;
this.editingProgram = null;
}
async deleteProgram(id: number): Promise<void> {
if (!confirm('Delete this program?')) return;
await firstValueFrom(this.programService.deleteProgram(id));
await this.reloadPrograms();
}
// ── Event CRUD ──────────────────────────────────────────
openEventForm(): void {
this.editingEvent = null;
this.showEventForm = true;
}
editEvent(event: Event): void {
this.editingEvent = event;
this.showEventForm = true;
}
onEventSaved(): void {
this.reloadEvents();
this.showEventForm = false;
this.editingEvent = null;
}
onEventClosed(): void {
this.showEventForm = false;
this.editingEvent = null;
}
async deleteEvent(id: number): Promise<void> {
if (!confirm('Delete this event?')) return;
await firstValueFrom(this.eventService.deleteEvent(id));
await this.reloadEvents();
}
// ── Tier CRUD ───────────────────────────────────────────
openTierForm(): void {
this.editingTier = null;
this.showTierForm = true;
}
editTier(tier: Tier): void {
this.editingTier = tier;
this.showTierForm = true;
}
onTierSaved(): void {
this.reloadTiers();
this.showTierForm = false;
this.editingTier = null;
}
onTierClosed(): void {
this.showTierForm = false;
this.editingTier = null;
}
async deleteTier(id: number): Promise<void> {
if (!confirm('Delete this tier?')) return;
await firstValueFrom(this.tierService.deleteTier(id));
await this.reloadTiers();
}
}
+3 -2
View File
@@ -1,13 +1,14 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { authInterceptor } from './interceptors/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(),
provideHttpClient(withInterceptors([authInterceptor])),
],
};
+13
View File
@@ -1,5 +1,7 @@
import { Routes } from '@angular/router';
import { AdminGuard } from './guards/admin.guard';
export const routes: Routes = [
{
path: '',
@@ -31,5 +33,16 @@ export const routes: Routes = [
loadComponent: () =>
import('./contact/contact.component').then((m) => m.ContactComponent),
},
{
path: 'login',
loadComponent: () =>
import('./login/login.component').then((m) => m.LoginComponent),
},
{
path: 'admin',
canActivate: [AdminGuard],
loadComponent: () =>
import('./admin/admin.component').then((m) => m.AdminComponent),
},
{ path: '**', redirectTo: '' },
];
+21
View File
@@ -0,0 +1,21 @@
import { Injectable, inject } from '@angular/core';
import { CanActivate, Router } from '@angular/router';
import { AuthService } from '../services/auth.service';
@Injectable({ providedIn: 'root' })
export class AdminGuard implements CanActivate {
private auth = inject(AuthService);
private router = inject(Router);
async canActivate(): Promise<boolean> {
// Ensure user info is loaded (handles page-reload race where token
// exists but /me hasn't been called yet).
const user = await this.auth.getCurrentUser();
if (user?.is_admin) {
return true;
}
this.router.navigate(['/login']);
return false;
}
}
+16
View File
@@ -0,0 +1,16 @@
import { HttpInterceptorFn, HttpRequest, HttpHandlerFn } from '@angular/common/http';
export const authInterceptor: HttpInterceptorFn = (req: HttpRequest<unknown>, next: HttpHandlerFn) => {
const token = localStorage.getItem('kmtn_access_token');
if (token) {
const cloned = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`,
},
});
return next(cloned);
}
return next(req);
};
+13
View File
@@ -0,0 +1,13 @@
export interface AppUser {
id: number;
email: string;
display_name: string;
avatar_url: string | null;
auth_provider: string;
is_admin: boolean;
}
export interface AuthTokenResponse {
access_token: string;
token_type: string;
}
+45
View File
@@ -0,0 +1,45 @@
<section class="login-section">
<div class="login-card">
<div class="login-header">
<img src="svg/small-flower.svg" alt="" class="login-flower" aria-hidden="true">
<h1>Sign In</h1>
<p>Admin access for KMountain Flower Radio</p>
</div>
<form (ngSubmit)="onSubmit()" class="login-form">
<div class="form-group">
<label for="username">Username</label>
<input
id="username"
type="text"
[(ngModel)]="username"
name="username"
autocomplete="username"
placeholder="Enter username"
required
>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
id="password"
type="password"
[(ngModel)]="password"
name="password"
autocomplete="current-password"
placeholder="Enter password"
required
>
</div>
@if (error) {
<div class="login-error" role="alert">{{ error }}</div>
}
<button type="submit" class="btn btn-login" [disabled]="loading">
{{ loading ? 'Signing in…' : 'Sign In' }}
</button>
</form>
</div>
</section>
+93
View File
@@ -0,0 +1,93 @@
@use '../styles/variables' as *;
@use '../styles/mixins' as *;
.login-section {
@include flex-center;
min-height: calc(100vh - 80px);
background: $sky-gradient;
padding: $spacing-xl;
}
.login-card {
@include card-style;
width: 100%;
max-width: 400px;
padding: $spacing-2xl;
text-align: center;
}
.login-header {
margin-bottom: $spacing-lg;
h1 {
font-family: $font-heading;
color: $primary-blue;
margin: $spacing-sm 0 $spacing-xs;
font-size: 1.75rem;
}
p {
color: $neutral-medium;
font-size: 0.9rem;
}
}
.login-flower {
width: 48px;
height: 48px;
margin-bottom: $spacing-sm;
}
.login-form {
text-align: left;
}
.form-group {
margin-bottom: $spacing-md;
label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: $neutral-dark;
margin-bottom: $spacing-xs;
}
input {
width: 100%;
padding: $spacing-sm $spacing-md;
border: 1px solid $neutral-light;
border-radius: $radius-md;
font-size: 1rem;
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);
}
}
}
.login-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;
}
.btn-login {
@include button-style($primary-blue, $neutral-white, $primary-blue-light);
width: 100%;
padding: $spacing-sm $spacing-lg;
margin-top: $spacing-sm;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
+47
View File
@@ -0,0 +1,47 @@
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { AuthService } from '../services/auth.service';
@Component({
selector: 'app-login',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './login.component.html',
styleUrl: './login.component.scss',
})
export class LoginComponent {
private auth = inject(AuthService);
private router = inject(Router);
username = '';
password = '';
error = '';
loading = false;
async onSubmit(): Promise<void> {
if (!this.username || !this.password) return;
this.loading = true;
this.error = '';
try {
const resp = await firstValueFrom(this.auth.login(this.username, this.password));
// Token stored — fetch user info
const user = await this.auth.getCurrentUser();
if (user?.is_admin) {
this.router.navigate(['/admin']);
} else {
this.router.navigate(['/']);
}
} catch (err: any) {
const body = err.error?.detail;
this.error = typeof body === 'string' ? body : 'Login failed. Please try again.';
} finally {
this.loading = false;
}
}
}
+95
View File
@@ -0,0 +1,95 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, firstValueFrom, map, Observable, tap } from 'rxjs';
import { environment } from '../../environments/environment';
import { AppUser, AuthTokenResponse } from '../interfaces/auth';
const TOKEN_KEY = 'kmtn_access_token';
@Injectable({
providedIn: 'root',
})
export class AuthService {
private http = inject(HttpClient);
private baseUrl = `${environment.apiBaseUrl}/api/auth`;
private authStateSubject = new BehaviorSubject<AppUser | null>(null);
/** Observable that emits the current auth state. */
readonly authState$: Observable<AppUser | null> = this.authStateSubject.asObservable();
constructor() {
// Check for existing token on init
this._initAuth();
}
/** Login with username/password (bootstrap admin). */
login(username: string, password: string): Observable<AuthTokenResponse> {
return this.http.post<AuthTokenResponse>(`${this.baseUrl}/login`, {
username,
password,
}).pipe(
tap((resp) => localStorage.setItem(TOKEN_KEY, resp.access_token)),
);
}
/** Login with Google ID token. */
loginWithGoogle(idToken: string): Observable<AuthTokenResponse> {
return this.http.post<AuthTokenResponse>(`${this.baseUrl}/google`, {
id_token: idToken,
}).pipe(
tap((resp) => localStorage.setItem(TOKEN_KEY, resp.access_token)),
);
}
/** Clear stored token and reset auth state. */
logout(): void {
localStorage.removeItem(TOKEN_KEY);
this.authStateSubject.next(null);
}
/** Fetch current user info from the API. */
getCurrentUser(): Promise<AppUser | null> {
const token = this._getToken();
if (!token) return Promise.resolve(null);
return firstValueFrom(
this.http.get<AppUser>(`${this.baseUrl}/me`).pipe(
tap((user) => this.authStateSubject.next(user)),
map((user) => user),
)
).catch(() => {
this.logout();
return null;
});
}
/** Check if there is a stored token. */
isLoggedIn(): boolean {
return !!this._getToken();
}
/** Check if the current user is an admin (synchronous, based on cached state). */
isAdmin(): boolean {
return this.authStateSubject.value?.is_admin ?? false;
}
/** Get the current user synchronously (from cached state). */
getUser(): AppUser | null {
return this.authStateSubject.value;
}
// ── Private ──────────────────────────────────────────────
private _getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
private async _initAuth(): Promise<void> {
const token = this._getToken();
if (token) {
await this.getCurrentUser();
}
}
}
+1
View File
@@ -1,3 +1,4 @@
import 'zone.js';
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';