data driven about us screen

This commit is contained in:
2026-06-21 05:24:40 +00:00
parent f52625b37b
commit 1b537b3dfd
34 changed files with 2119 additions and 44 deletions
+63 -38
View File
@@ -1,5 +1,6 @@
<section class="about">
<div class="container">
<!-- Intro -->
<div class="about-intro">
<img [src]="config().hero_icon_url" alt="" class="about-flower-decor" aria-hidden="true">
<h2>About <span class="text-accent">{{ config().name_primary }} {{ config().name_secondary }}</span></h2>
@@ -10,45 +11,69 @@
</p>
</div>
<div class="about-grid">
<div class="about-card">
<div class="card-icon">🎵</div>
<h3>Our Music</h3>
<p>
From bluegrass and folk to jazz and classical, our playlists reflect
the rich cultural tapestry of the high country and its neighbors. We
feature local artists alongside the world's best voices.
</p>
</div>
<div class="about-card">
<div class="card-icon">🤝</div>
<h3>Our Community</h3>
<p>
Every show is staffed by volunteers who live and work in these
mountains. From high school DJs to retired teachers, our team is your
community — passionate about sharing what they love.
</p>
</div>
<div class="about-card">
<div class="card-icon">📡</div>
<h3>Our Reach</h3>
<p>
Over the air on {{ config().frequency }} and streaming live worldwide. Whether you're
hiking the ridgeline or on the other side of the planet, our signal
— and our welcome — goes everywhere.
</p>
</div>
<div class="about-card">
<div class="card-icon">🌱</div>
<h3>Our Mission</h3>
<p>
To keep public radio free, independent, and deeply rooted in place.
No corporate advertising, no spin — just music, conversation, and
community served with care.
</p>
</div>
</div>
<!-- Community Highlights Grid -->
@if (communityHighlights$ | async; as highlights) {
@if (highlights.length) {
<div class="about-grid">
@for (highlight of highlights; track highlight.id) {
<div class="about-card">
<div class="card-icon">{{ highlight.icon }}</div>
<h3>{{ highlight.title }}</h3>
<p>{{ highlight.description }}</p>
</div>
}
</div>
}
}
<!-- History Timeline -->
@if (historyEntries$ | async; as entries) {
@if (entries.length) {
<div class="timeline-section">
<h2 class="section-title">Our <span class="text-accent">History</span></h2>
<div class="timeline">
@for (entry of entries; track entry.id; let index = $index) {
<div class="timeline-item" [class.even]="index % 2 === 1">
<div class="timeline-card">
<span class="timeline-year">{{ entry.year }}</span>
<h3>{{ entry.title }}</h3>
<p>{{ entry.body }}</p>
</div>
</div>
}
</div>
</div>
}
}
<!-- Team Roster -->
@if (teamMembers$ | async; as members) {
@if (members.length) {
<div class="team-section">
<h2 class="section-title">Our <span class="text-accent">Team</span></h2>
<div class="team-grid">
@for (member of members; track member.id) {
<div class="team-card">
<div class="team-photo">
@if (member.photo_url) {
<img [src]="member.photo_url" [alt]="member.name">
} @else {
<span>{{ member.name.charAt(0) }}</span>
}
</div>
<h3>{{ member.name }}</h3>
<p class="team-title">{{ member.title }}</p>
@if (member.bio) {
<p class="team-bio">{{ member.bio }}</p>
}
</div>
}
</div>
</div>
}
}
<!-- Donation CTA -->
<div class="about-cta">
<div class="about-flower-divider" aria-hidden="true">
<img [src]="config().logo_url" alt="">
+187 -1
View File
@@ -50,6 +50,166 @@
}
}
.section-title {
text-align: center;
font-size: 2rem;
font-family: $font-heading;
color: var(--color-primary);
margin-bottom: $spacing-2xl;
}
// ── Timeline ──────────────────────────────────────────────
.timeline-section {
margin-bottom: $spacing-3xl;
}
.timeline {
position: relative;
padding: $spacing-lg 0;
// Central vertical line
&::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 3px;
background: var(--color-primary);
transform: translateX(-50%);
}
}
.timeline-item {
position: relative;
width: 50%;
padding: 0 $spacing-xl $spacing-2xl;
// Dot on the timeline
&::before {
content: '';
position: absolute;
top: $spacing-sm;
width: 16px;
height: 16px;
background: var(--color-accent);
border: 3px solid var(--color-white);
border-radius: 50%;
box-shadow: 0 0 0 2px var(--color-primary);
z-index: 1;
}
// Odd items — left side
&:not(.even) {
left: 0;
text-align: right;
&::before {
right: -8px;
}
}
// Even items — right side
&.even {
left: 50%;
text-align: left;
&::before {
left: -8px;
}
}
}
.timeline-card {
@include card-style;
padding: $spacing-lg;
background: var(--color-white);
.timeline-year {
display: inline-block;
background: var(--color-primary);
color: var(--color-white);
font-weight: 700;
font-size: 0.9rem;
padding: $spacing-xs $spacing-md;
border-radius: $radius-md;
margin-bottom: $spacing-sm;
}
h3 {
font-size: 1.2rem;
color: var(--color-primary);
margin-bottom: $spacing-sm;
}
p {
font-size: 0.95rem;
color: var(--color-dark);
line-height: 1.7;
}
}
// ── Team Grid ─────────────────────────────────────────────
.team-section {
margin-bottom: $spacing-3xl;
}
.team-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: $spacing-lg;
}
.team-card {
@include card-style;
padding: $spacing-xl;
text-align: center;
background: var(--color-white);
}
.team-photo {
@include flex-center;
width: 80px;
height: 80px;
margin: 0 auto $spacing-md;
border-radius: 50%;
background: var(--color-primary);
color: var(--color-white);
font-size: 1.8rem;
font-weight: 700;
overflow: hidden;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.team-card h3 {
font-size: 1.1rem;
color: var(--color-primary);
margin-bottom: $spacing-xs;
}
.team-title {
font-size: 0.9rem;
color: var(--color-accent);
font-weight: 600;
margin-bottom: $spacing-sm;
}
.team-bio {
font-size: 0.9rem;
color: var(--color-dark);
line-height: 1.6;
}
// ── Donation CTA ──────────────────────────────────────────
.about-cta {
text-align: center;
padding: $spacing-2xl $spacing-lg;
@@ -80,8 +240,34 @@
}
}
@media (max-width: #{$bp-md - 1px}) {
// ── Responsive ────────────────────────────────────────────
@include responsive(md) {
.about-grid {
grid-template-columns: 1fr;
}
// Collapse timeline to single-column
.timeline {
&::before {
left: $spacing-md;
}
}
.timeline-item {
width: 100%;
left: 0 !important;
text-align: left !important;
padding-left: $spacing-3xl;
padding-right: 0;
&::before {
left: #{$spacing-md - 0.5rem} !important;
right: auto !important;
}
}
.team-grid {
grid-template-columns: 1fr;
}
}
+36 -1
View File
@@ -1,15 +1,50 @@
import { Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { AsyncPipe } from '@angular/common';
import { BehaviorSubject } from 'rxjs';
import { StationConfigService } from '../services/station-config.service';
import { HistoryEntryService } from '../services/history-entry.service';
import { TeamMemberService } from '../services/team-member.service';
import { CommunityHighlightService } from '../services/community-highlight.service';
import { HistoryEntry } from '../interfaces/history-entry';
import { TeamMember } from '../interfaces/team-member';
import { CommunityHighlight } from '../interfaces/community-highlight';
@Component({
selector: 'app-about',
standalone: true,
imports: [RouterLink],
imports: [RouterLink, AsyncPipe],
templateUrl: './about.component.html',
styleUrl: './about.component.scss',
})
export class AboutComponent {
private historyEntryService = inject(HistoryEntryService);
private teamMemberService = inject(TeamMemberService);
private communityHighlightService = inject(CommunityHighlightService);
readonly config = inject(StationConfigService).config;
readonly historyEntries$ = new BehaviorSubject<HistoryEntry[]>([]);
readonly teamMembers$ = new BehaviorSubject<TeamMember[]>([]);
readonly communityHighlights$ = new BehaviorSubject<CommunityHighlight[]>([]);
constructor() {
this.load();
}
async load(): Promise<void> {
try {
const [history, team, highlights] = await Promise.all([
this.historyEntryService.getHistoryEntries(true).toPromise(),
this.teamMemberService.getTeamMembers(true).toPromise(),
this.communityHighlightService.getCommunityHighlights(true).toPromise(),
]);
this.historyEntries$.next(history ?? []);
this.teamMembers$.next(team ?? []);
this.communityHighlights$.next(highlights ?? []);
} catch {
// Silently fail — page still renders with empty state
}
}
}
@@ -0,0 +1,50 @@
<div class="form-overlay" (click)="onClose()">
<div class="form-dialog" (click)="$event.stopPropagation()">
<div class="form-header">
<h2>{{ id ? 'Edit Community Highlight' : 'Add Community Highlight' }}</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="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" [(ngModel)]="display_order" name="display_order">
</div>
</div>
<div class="form-group">
<label for="title">Title</label>
<input id="title" type="text" [(ngModel)]="title" name="title" placeholder="e.g., Our Music" required [class.is-invalid]="submitted && !title">
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" [(ngModel)]="description" name="description" rows="4" placeholder="Describe this highlight..." required [class.is-invalid]="submitted && !description"></textarea>
</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,104 @@
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 { CommunityHighlight } from '../interfaces/community-highlight';
import { CommunityHighlightService } from '../services/community-highlight.service';
@Component({
selector: 'app-admin-community-form',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './admin-community-form.component.html',
styleUrl: './admin-community-form.component.scss',
})
export class AdminCommunityFormComponent implements OnChanges {
private communityHighlightService = inject(CommunityHighlightService);
@Input() editItem: CommunityHighlight | null = null;
@Output() saved = new EventEmitter<void>();
@Output() closed = new EventEmitter<void>();
id: number | null = null;
icon = '🎵';
title = '';
description = '';
display_order = 0;
active = true;
loading = false;
error = '';
submitted = false;
ngOnChanges(changes: SimpleChanges): void {
if (changes['editItem'] && this.editItem) {
this.edit(this.editItem);
}
}
edit(highlight: CommunityHighlight): void {
this.id = highlight.id;
this.icon = highlight.icon;
this.title = highlight.title;
this.description = highlight.description;
this.display_order = highlight.display_order;
this.active = highlight.active;
}
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.icon = '🎵';
this.title = '';
this.description = '';
this.display_order = 0;
this.active = true;
this.error = '';
this.submitted = false;
}
onClose(): void {
this.closed.emit();
this.reset();
}
async onSubmit(): Promise<void> {
this.submitted = true;
if (!this.title || !this.description) {
this.error = 'Please fill in all required fields.';
return;
}
this.loading = true;
this.error = '';
const payload = {
icon: this.icon,
title: this.title,
description: this.description,
display_order: this.display_order,
active: this.active,
};
try {
if (this.id) {
await firstValueFrom(this.communityHighlightService.updateCommunityHighlight(this.id, payload));
} else {
await firstValueFrom(this.communityHighlightService.createCommunityHighlight(payload));
}
this.saved.emit();
this.reset();
} catch (err: any) {
this.error = this.formatError(err);
} finally {
this.loading = false;
}
}
}
@@ -0,0 +1,50 @@
<div class="form-overlay" (click)="onClose()">
<div class="form-dialog" (click)="$event.stopPropagation()">
<div class="form-header">
<h2>{{ id ? 'Edit History Entry' : 'Add History Entry' }}</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="year">Year</label>
<input id="year" type="number" [(ngModel)]="year" name="year" required [class.is-invalid]="submitted && !year">
</div>
<div class="form-group">
<label for="display_order">Display Order</label>
<input id="display_order" type="number" [(ngModel)]="display_order" name="display_order">
</div>
</div>
<div class="form-group">
<label for="title">Title</label>
<input id="title" type="text" [(ngModel)]="title" name="title" placeholder="e.g., First Broadcast" required [class.is-invalid]="submitted && !title">
</div>
<div class="form-group">
<label for="body">Body</label>
<textarea id="body" [(ngModel)]="body" name="body" rows="4" placeholder="Describe this milestone..." required [class.is-invalid]="submitted && !body"></textarea>
</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,104 @@
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 { HistoryEntry } from '../interfaces/history-entry';
import { HistoryEntryService } from '../services/history-entry.service';
@Component({
selector: 'app-admin-history-form',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './admin-history-form.component.html',
styleUrl: './admin-history-form.component.scss',
})
export class AdminHistoryFormComponent implements OnChanges {
private historyEntryService = inject(HistoryEntryService);
@Input() editItem: HistoryEntry | null = null;
@Output() saved = new EventEmitter<void>();
@Output() closed = new EventEmitter<void>();
id: number | null = null;
year = '';
title = '';
body = '';
display_order = 0;
active = true;
loading = false;
error = '';
submitted = false;
ngOnChanges(changes: SimpleChanges): void {
if (changes['editItem'] && this.editItem) {
this.edit(this.editItem);
}
}
edit(entry: HistoryEntry): void {
this.id = entry.id;
this.year = String(entry.year);
this.title = entry.title;
this.body = entry.body;
this.display_order = entry.display_order;
this.active = entry.active;
}
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.year = '';
this.title = '';
this.body = '';
this.display_order = 0;
this.active = true;
this.error = '';
this.submitted = false;
}
onClose(): void {
this.closed.emit();
this.reset();
}
async onSubmit(): Promise<void> {
this.submitted = true;
if (!this.year || !this.title || !this.body) {
this.error = 'Please fill in all required fields.';
return;
}
this.loading = true;
this.error = '';
const payload = {
year: parseInt(this.year, 10),
title: this.title,
body: this.body,
display_order: this.display_order,
active: this.active,
};
try {
if (this.id) {
await firstValueFrom(this.historyEntryService.updateHistoryEntry(this.id, payload));
} else {
await firstValueFrom(this.historyEntryService.createHistoryEntry(payload));
}
this.saved.emit();
this.reset();
} catch (err: any) {
this.error = this.formatError(err);
} finally {
this.loading = false;
}
}
}
@@ -0,0 +1,55 @@
<div class="form-overlay" (click)="onClose()">
<div class="form-dialog" (click)="$event.stopPropagation()">
<div class="form-header">
<h2>{{ id ? 'Edit Team Member' : 'Add Team Member' }}</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="Full name" required [class.is-invalid]="submitted && !name">
</div>
<div class="form-group">
<label for="display_order">Display Order</label>
<input id="display_order" type="number" [(ngModel)]="display_order" name="display_order">
</div>
</div>
<div class="form-group">
<label for="title">Title</label>
<input id="title" type="text" [(ngModel)]="title" name="title" placeholder="e.g., Board Chair" required [class.is-invalid]="submitted && !title">
</div>
<div class="form-group">
<label for="bio">Bio (optional)</label>
<textarea id="bio" [(ngModel)]="bio" name="bio" rows="3" placeholder="Short biography..."></textarea>
</div>
<div class="form-group">
<label for="photo_url">Photo URL (optional)</label>
<input id="photo_url" type="url" [(ngModel)]="photo_url" name="photo_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;
}
}
+108
View File
@@ -0,0 +1,108 @@
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 { TeamMember } from '../interfaces/team-member';
import { TeamMemberService } from '../services/team-member.service';
@Component({
selector: 'app-admin-team-form',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './admin-team-form.component.html',
styleUrl: './admin-team-form.component.scss',
})
export class AdminTeamFormComponent implements OnChanges {
private teamMemberService = inject(TeamMemberService);
@Input() editItem: TeamMember | null = null;
@Output() saved = new EventEmitter<void>();
@Output() closed = new EventEmitter<void>();
id: number | null = null;
name = '';
title = '';
bio = '';
photo_url = '';
display_order = 0;
active = true;
loading = false;
error = '';
submitted = false;
ngOnChanges(changes: SimpleChanges): void {
if (changes['editItem'] && this.editItem) {
this.edit(this.editItem);
}
}
edit(member: TeamMember): void {
this.id = member.id;
this.name = member.name;
this.title = member.title;
this.bio = member.bio ?? '';
this.photo_url = member.photo_url ?? '';
this.display_order = member.display_order;
this.active = member.active;
}
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.name = '';
this.title = '';
this.bio = '';
this.photo_url = '';
this.display_order = 0;
this.active = true;
this.error = '';
this.submitted = false;
}
onClose(): void {
this.closed.emit();
this.reset();
}
async onSubmit(): Promise<void> {
this.submitted = true;
if (!this.name || !this.title) {
this.error = 'Please fill in all required fields.';
return;
}
this.loading = true;
this.error = '';
const payload = {
name: this.name,
title: this.title,
bio: this.bio || null,
photo_url: this.photo_url || null,
display_order: this.display_order,
active: this.active,
};
try {
if (this.id) {
await firstValueFrom(this.teamMemberService.updateTeamMember(this.id, payload));
} else {
await firstValueFrom(this.teamMemberService.createTeamMember(payload));
}
this.saved.emit();
this.reset();
} catch (err: any) {
this.error = this.formatError(err);
} finally {
this.loading = false;
}
}
}
+146 -1
View File
@@ -2,7 +2,7 @@
<div class="container">
<div class="admin-header">
<h1>Admin Dashboard</h1>
<p>Manage programs, events, and donation tiers</p>
<p>Manage programs, events, tiers, history, team, and community content</p>
</div>
<!-- Tabs -->
@@ -22,6 +22,21 @@
[class.active]="activeTab() === 'tiers'"
(click)="activeTab.set('tiers')"
>Tiers</button>
<button
class="tab-btn"
[class.active]="activeTab() === 'history'"
(click)="activeTab.set('history')"
>History</button>
<button
class="tab-btn"
[class.active]="activeTab() === 'team'"
(click)="activeTab.set('team')"
>Team</button>
<button
class="tab-btn"
[class.active]="activeTab() === 'community'"
(click)="activeTab.set('community')"
>Community</button>
<button
class="tab-btn"
[class.active]="activeTab() === 'station'"
@@ -150,6 +165,127 @@
</div>
}
}
<!-- History Tab -->
@if (activeTab() === 'history') {
@if (historyEntries$ | async; as entries) {
<div class="tab-content">
<div class="tab-toolbar">
<h2>History Timeline ({{ entries.length }})</h2>
<button class="btn btn-add" (click)="openHistoryForm()">+ Add Entry</button>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Year</th>
<th>Title</th>
<th>Order</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (entry of entries; track entry.id) {
<tr>
<td>{{ entry.year }}</td>
<td>{{ entry.title }}</td>
<td>{{ entry.display_order }}</td>
<td>{{ entry.active ? 'Yes' : 'No' }}</td>
<td class="actions">
<button class="btn-icon btn-edit" (click)="editHistory(entry)">Edit</button>
<button class="btn-icon btn-delete" (click)="deleteHistory(entry.id)">Delete</button>
</td>
</tr>
} @empty {
<tr><td colspan="5" class="empty-row">No history entries yet. Click "Add Entry" to create one.</td></tr>
}
</tbody>
</table>
</div>
}
}
<!-- Team Tab -->
@if (activeTab() === 'team') {
@if (teamMembers$ | async; as members) {
<div class="tab-content">
<div class="tab-toolbar">
<h2>Team Members ({{ members.length }})</h2>
<button class="btn btn-add" (click)="openTeamForm()">+ Add Member</button>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Name</th>
<th>Title</th>
<th>Order</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (member of members; track member.id) {
<tr>
<td>{{ member.name }}</td>
<td>{{ member.title }}</td>
<td>{{ member.display_order }}</td>
<td>{{ member.active ? 'Yes' : 'No' }}</td>
<td class="actions">
<button class="btn-icon btn-edit" (click)="editTeam(member)">Edit</button>
<button class="btn-icon btn-delete" (click)="deleteTeam(member.id)">Delete</button>
</td>
</tr>
} @empty {
<tr><td colspan="5" class="empty-row">No team members yet. Click "Add Member" to create one.</td></tr>
}
</tbody>
</table>
</div>
}
}
<!-- Community Tab -->
@if (activeTab() === 'community') {
@if (communityHighlights$ | async; as highlights) {
<div class="tab-content">
<div class="tab-toolbar">
<h2>Community Highlights ({{ highlights.length }})</h2>
<button class="btn btn-add" (click)="openCommunityForm()">+ Add Highlight</button>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Icon</th>
<th>Title</th>
<th>Order</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (highlight of highlights; track highlight.id) {
<tr>
<td>{{ highlight.icon }}</td>
<td>{{ highlight.title }}</td>
<td>{{ highlight.display_order }}</td>
<td>{{ highlight.active ? 'Yes' : 'No' }}</td>
<td class="actions">
<button class="btn-icon btn-edit" (click)="editCommunity(highlight)">Edit</button>
<button class="btn-icon btn-delete" (click)="deleteCommunity(highlight.id)">Delete</button>
</td>
</tr>
} @empty {
<tr><td colspan="5" class="empty-row">No highlights yet. Click "Add Highlight" to create one.</td></tr>
}
</tbody>
</table>
</div>
}
}
<!-- Station Tab -->
@if (activeTab() === 'station') {
@if (stationConfig$ | async; as config) {
@@ -215,4 +351,13 @@
@if (showStationForm) {
<app-admin-station-form [editItem]="stationConfig$ | async" (saved)="onStationSaved()" (closed)="onStationClosed()" />
}
@if (showHistoryForm) {
<app-admin-history-form [editItem]="editingHistory" (saved)="onHistorySaved()" (closed)="onHistoryClosed()" />
}
@if (showTeamForm) {
<app-admin-team-form [editItem]="editingTeam" (saved)="onTeamSaved()" (closed)="onTeamClosed()" />
}
@if (showCommunityForm) {
<app-admin-community-form [editItem]="editingCommunity" (saved)="onCommunitySaved()" (closed)="onCommunityClosed()" />
}
</section>
+130 -1
View File
@@ -7,16 +7,25 @@ import { Program } from '../interfaces/program';
import { Event } from '../interfaces/event';
import { Tier } from '../interfaces/tier';
import { StationConfig } from '../interfaces/station-config';
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 { EventService } from '../services/event.service';
import { TierService } from '../services/tier.service';
import { StationConfigService } from '../services/station-config.service';
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 { AdminEventFormComponent } from './admin-event-form.component';
import { AdminTierFormComponent } from './admin-tier-form.component';
import { AdminStationFormComponent } from './admin-station-form.component';
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';
type TabKey = 'programs' | 'events' | 'tiers' | 'station' | 'history' | 'team' | 'community';
@Component({
selector: 'app-admin',
@@ -29,6 +38,9 @@ type TabKey = 'programs' | 'events' | 'tiers' | 'station';
AdminEventFormComponent,
AdminTierFormComponent,
AdminStationFormComponent,
AdminHistoryFormComponent,
AdminTeamFormComponent,
AdminCommunityFormComponent,
],
templateUrl: './admin.component.html',
styleUrl: './admin.component.scss',
@@ -38,6 +50,9 @@ export class AdminComponent implements OnInit {
private eventService = inject(EventService);
private tierService = inject(TierService);
private stationConfigService = inject(StationConfigService);
private historyEntryService = inject(HistoryEntryService);
private teamMemberService = inject(TeamMemberService);
private communityHighlightService = inject(CommunityHighlightService);
activeTab = signal<TabKey>('programs');
@@ -45,17 +60,26 @@ export class AdminComponent implements OnInit {
editingProgram: Program | null = null;
editingEvent: Event | null = null;
editingTier: Tier | null = null;
editingHistory: HistoryEntry | null = null;
editingTeam: TeamMember | null = null;
editingCommunity: CommunityHighlight | 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[]>([]);
readonly stationConfig$ = new BehaviorSubject<StationConfig | null>(null);
readonly historyEntries$ = new BehaviorSubject<HistoryEntry[]>([]);
readonly teamMembers$ = new BehaviorSubject<TeamMember[]>([]);
readonly communityHighlights$ = new BehaviorSubject<CommunityHighlight[]>([]);
showProgramForm = false;
showEventForm = false;
showTierForm = false;
showStationForm = false;
showHistoryForm = false;
showTeamForm = false;
showCommunityForm = false;
ngOnInit(): void {
this.loadAll();
@@ -81,12 +105,30 @@ export class AdminComponent implements OnInit {
this.stationConfig$.next(this.stationConfigService.config());
}
async reloadHistoryEntries(): Promise<void> {
const data = await firstValueFrom(this.historyEntryService.getHistoryEntries());
this.historyEntries$.next(data);
}
async reloadTeamMembers(): Promise<void> {
const data = await firstValueFrom(this.teamMemberService.getTeamMembers());
this.teamMembers$.next(data);
}
async reloadCommunityHighlights(): Promise<void> {
const data = await firstValueFrom(this.communityHighlightService.getCommunityHighlights());
this.communityHighlights$.next(data);
}
async loadAll(): Promise<void> {
await Promise.all([
this.reloadPrograms(),
this.reloadEvents(),
this.reloadTiers(),
this.reloadStationConfig(),
this.reloadHistoryEntries(),
this.reloadTeamMembers(),
this.reloadCommunityHighlights(),
]);
}
@@ -191,4 +233,91 @@ export class AdminComponent implements OnInit {
onStationClosed(): void {
this.showStationForm = false;
}
// ── History CRUD ────────────────────────────────────────
openHistoryForm(): void {
this.editingHistory = null;
this.showHistoryForm = true;
}
editHistory(entry: HistoryEntry): void {
this.editingHistory = entry;
this.showHistoryForm = true;
}
onHistorySaved(): void {
this.reloadHistoryEntries();
this.showHistoryForm = false;
this.editingHistory = null;
}
onHistoryClosed(): void {
this.showHistoryForm = false;
this.editingHistory = null;
}
async deleteHistory(id: number): Promise<void> {
if (!confirm('Delete this history entry?')) return;
await firstValueFrom(this.historyEntryService.deleteHistoryEntry(id));
await this.reloadHistoryEntries();
}
// ── Team CRUD ───────────────────────────────────────────
openTeamForm(): void {
this.editingTeam = null;
this.showTeamForm = true;
}
editTeam(member: TeamMember): void {
this.editingTeam = member;
this.showTeamForm = true;
}
onTeamSaved(): void {
this.reloadTeamMembers();
this.showTeamForm = false;
this.editingTeam = null;
}
onTeamClosed(): void {
this.showTeamForm = false;
this.editingTeam = null;
}
async deleteTeam(id: number): Promise<void> {
if (!confirm('Delete this team member?')) return;
await firstValueFrom(this.teamMemberService.deleteTeamMember(id));
await this.reloadTeamMembers();
}
// ── Community CRUD ──────────────────────────────────────
openCommunityForm(): void {
this.editingCommunity = null;
this.showCommunityForm = true;
}
editCommunity(highlight: CommunityHighlight): void {
this.editingCommunity = highlight;
this.showCommunityForm = true;
}
onCommunitySaved(): void {
this.reloadCommunityHighlights();
this.showCommunityForm = false;
this.editingCommunity = null;
}
onCommunityClosed(): void {
this.showCommunityForm = false;
this.editingCommunity = null;
}
async deleteCommunity(id: number): Promise<void> {
if (!confirm('Delete this community highlight?')) return;
await firstValueFrom(this.communityHighlightService.deleteCommunityHighlight(id));
await this.reloadCommunityHighlights();
}
}
@@ -0,0 +1,8 @@
export interface CommunityHighlight {
id: number;
icon: string;
title: string;
description: string;
display_order: number;
active: boolean;
}
+8
View File
@@ -0,0 +1,8 @@
export interface HistoryEntry {
id: number;
year: number;
title: string;
body: string;
display_order: number;
active: boolean;
}
+9
View File
@@ -0,0 +1,9 @@
export interface TeamMember {
id: number;
name: string;
title: string;
bio: string | null;
photo_url: string | null;
display_order: number;
active: boolean;
}
@@ -0,0 +1,37 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { CommunityHighlight } from '../interfaces/community-highlight';
import { getAppConfig } from './app-config.service';
@Injectable({
providedIn: 'root',
})
export class CommunityHighlightService {
private http = inject(HttpClient);
private baseUrl = `${getAppConfig().apiBaseUrl}/api/community`;
/** Fetch community highlights, optionally filtered by active status. */
getCommunityHighlights(active?: boolean): Observable<CommunityHighlight[]> {
let params = new HttpParams();
if (active !== undefined) params = params.set('active', String(active));
return this.http.get<CommunityHighlight[]>(this.baseUrl, { params });
}
getCommunityHighlight(id: number): Observable<CommunityHighlight> {
return this.http.get<CommunityHighlight>(`${this.baseUrl}/${id}`);
}
createCommunityHighlight(payload: Omit<CommunityHighlight, 'id'>): Observable<CommunityHighlight> {
return this.http.post<CommunityHighlight>(this.baseUrl, payload);
}
updateCommunityHighlight(id: number, payload: Partial<CommunityHighlight>): Observable<CommunityHighlight> {
return this.http.put<CommunityHighlight>(`${this.baseUrl}/${id}`, payload);
}
deleteCommunityHighlight(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
}
+37
View File
@@ -0,0 +1,37 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { HistoryEntry } from '../interfaces/history-entry';
import { getAppConfig } from './app-config.service';
@Injectable({
providedIn: 'root',
})
export class HistoryEntryService {
private http = inject(HttpClient);
private baseUrl = `${getAppConfig().apiBaseUrl}/api/history`;
/** Fetch history entries, optionally filtered by active status. */
getHistoryEntries(active?: boolean): Observable<HistoryEntry[]> {
let params = new HttpParams();
if (active !== undefined) params = params.set('active', String(active));
return this.http.get<HistoryEntry[]>(this.baseUrl, { params });
}
getHistoryEntry(id: number): Observable<HistoryEntry> {
return this.http.get<HistoryEntry>(`${this.baseUrl}/${id}`);
}
createHistoryEntry(payload: Omit<HistoryEntry, 'id'>): Observable<HistoryEntry> {
return this.http.post<HistoryEntry>(this.baseUrl, payload);
}
updateHistoryEntry(id: number, payload: Partial<HistoryEntry>): Observable<HistoryEntry> {
return this.http.put<HistoryEntry>(`${this.baseUrl}/${id}`, payload);
}
deleteHistoryEntry(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
}
+37
View File
@@ -0,0 +1,37 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { TeamMember } from '../interfaces/team-member';
import { getAppConfig } from './app-config.service';
@Injectable({
providedIn: 'root',
})
export class TeamMemberService {
private http = inject(HttpClient);
private baseUrl = `${getAppConfig().apiBaseUrl}/api/team`;
/** Fetch team members, optionally filtered by active status. */
getTeamMembers(active?: boolean): Observable<TeamMember[]> {
let params = new HttpParams();
if (active !== undefined) params = params.set('active', String(active));
return this.http.get<TeamMember[]>(this.baseUrl, { params });
}
getTeamMember(id: number): Observable<TeamMember> {
return this.http.get<TeamMember>(`${this.baseUrl}/${id}`);
}
createTeamMember(payload: Omit<TeamMember, 'id'>): Observable<TeamMember> {
return this.http.post<TeamMember>(this.baseUrl, payload);
}
updateTeamMember(id: number, payload: Partial<TeamMember>): Observable<TeamMember> {
return this.http.put<TeamMember>(`${this.baseUrl}/${id}`, payload);
}
deleteTeamMember(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
}