data driven about us screen
This commit is contained in:
@@ -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="">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()">×</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()">×</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()">×</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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface HistoryEntry {
|
||||
id: number;
|
||||
year: number;
|
||||
title: string;
|
||||
body: string;
|
||||
display_order: number;
|
||||
active: boolean;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user