working basic site admin

This commit is contained in:
2026-06-21 02:56:49 +00:00
parent 53d932655e
commit f52625b37b
38 changed files with 1163 additions and 56 deletions
+6 -6
View File
@@ -1,10 +1,10 @@
<section class="about">
<div class="container">
<div class="about-intro">
<img src="svg/orange-flower.svg" alt="" class="about-flower-decor" aria-hidden="true">
<h2>About <span class="text-accent">KMountain Flower</span></h2>
<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>
<p class="section-intro">
For nearly four decades, KMountain Flower has been the voice of our
For nearly four decades, {{ config().name_primary }} {{ config().name_secondary }} has been the voice of our
community — a nonprofit, listener-supported radio station dedicated to
music, local news, and the stories that bind mountain towns together.
</p>
@@ -33,7 +33,7 @@
<div class="card-icon">📡</div>
<h3>Our Reach</h3>
<p>
Over the air on 98.7 FM and streaming live worldwide. Whether you're
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>
@@ -51,11 +51,11 @@
<div class="about-cta">
<div class="about-flower-divider" aria-hidden="true">
<img src="svg/small-flower.svg" alt="">
<img [src]="config().logo_url" alt="">
</div>
<h3>Become a Member</h3>
<p class="section-intro">
As a 501(c)(3) nonprofit, every donation keeps our airwaves free and our
As a {{ config().nonprofit_type }} nonprofit, every donation keeps our airwaves free and our
signal strong. Members receive exclusive perks and the satisfaction of
knowing they helped make this possible.
</p>
+8 -3
View File
@@ -1,10 +1,15 @@
import { Component } from '@angular/core';
import { Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { StationConfigService } from '../services/station-config.service';
@Component({
selector: 'app-about',
standalone: true,
imports: [],
imports: [RouterLink],
templateUrl: './about.component.html',
styleUrl: './about.component.scss',
})
export class AboutComponent {}
export class AboutComponent {
readonly config = inject(StationConfigService).config;
}
@@ -0,0 +1,141 @@
<div class="form-overlay" (click)="onClose()">
<div class="form-dialog form-dialog--wide" (click)="$event.stopPropagation()">
<div class="form-header">
<h2>Edit Station Config</h2>
<button type="button" class="btn-close" (click)="onClose()">&times;</button>
</div>
@if (error) {
<div class="form-error" role="alert">{{ error }}</div>
}
@if (success) {
<div class="form-success" role="alert">{{ success }}</div>
}
<form (ngSubmit)="onSubmit()" class="admin-form" #form="ngForm">
<!-- Identity -->
<div class="form-section">
<h3>Identity</h3>
<div class="form-row">
<div class="form-group">
<label for="callsign">Callsign</label>
<input id="callsign" type="text" [(ngModel)]="callsign" name="callsign" required>
</div>
<div class="form-group">
<label for="frequency">Frequency</label>
<input id="frequency" type="text" [(ngModel)]="frequency" name="frequency" placeholder="98.7 FM">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="name_primary">Primary Name</label>
<input id="name_primary" type="text" [(ngModel)]="name_primary" name="name_primary" required>
</div>
<div class="form-group">
<label for="name_secondary">Secondary Name</label>
<input id="name_secondary" type="text" [(ngModel)]="name_secondary" name="name_secondary">
</div>
</div>
<div class="form-group">
<label for="tagline">Tagline</label>
<input id="tagline" type="text" [(ngModel)]="tagline" name="tagline">
</div>
<div class="form-group">
<label for="founded_year">Founded Year</label>
<input id="founded_year" type="number" [(ngModel)]="founded_year" name="founded_year">
</div>
</div>
<!-- Images -->
<div class="form-section">
<h3>Images</h3>
<div class="form-group">
<label for="logo_url">Logo URL</label>
<div class="url-with-upload">
<input id="logo_url" type="text" [(ngModel)]="logo_url" name="logo_url" placeholder="uploads/…">
<label class="btn btn-upload">
Upload
<input type="file" accept="image/*" (change)="onImageUpload($event, 'logo_url')" [disabled]="loading">
</label>
</div>
</div>
<div class="form-group">
<label for="hero_background_url">Hero Background URL</label>
<div class="url-with-upload">
<input id="hero_background_url" type="text" [(ngModel)]="hero_background_url" name="hero_background_url">
<label class="btn btn-upload">
Upload
<input type="file" accept="image/*" (change)="onImageUpload($event, 'hero_background_url')" [disabled]="loading">
</label>
</div>
</div>
<div class="form-group">
<label for="hero_icon_url">Hero Icon URL</label>
<div class="url-with-upload">
<input id="hero_icon_url" type="text" [(ngModel)]="hero_icon_url" name="hero_icon_url">
<label class="btn btn-upload">
Upload
<input type="file" accept="image/*" (change)="onImageUpload($event, 'hero_icon_url')" [disabled]="loading">
</label>
</div>
</div>
<div class="form-group">
<label for="hero_divider_url">Hero Divider URL</label>
<div class="url-with-upload">
<input id="hero_divider_url" type="text" [(ngModel)]="hero_divider_url" name="hero_divider_url">
<label class="btn btn-upload">
Upload
<input type="file" accept="image/*" (change)="onImageUpload($event, 'hero_divider_url')" [disabled]="loading">
</label>
</div>
</div>
</div>
<!-- Nonprofit -->
<div class="form-section">
<h3>Nonprofit</h3>
<div class="form-row">
<div class="form-group">
<label for="nonprofit_type">Nonprofit Type</label>
<input id="nonprofit_type" type="text" [(ngModel)]="nonprofit_type" name="nonprofit_type" placeholder="501(c)(3)">
</div>
<div class="form-group">
<label for="ein">EIN</label>
<input id="ein" type="text" [(ngModel)]="ein" name="ein" placeholder="84-XXXXXXX">
</div>
</div>
</div>
<!-- Contact -->
<div class="form-section">
<h3>Contact</h3>
<div class="form-group">
<label for="address">Address</label>
<textarea id="address" [(ngModel)]="address" name="address" rows="2"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="phone">Phone</label>
<input id="phone" type="text" [(ngModel)]="phone" name="phone">
</div>
<div class="form-group">
<label for="email">Email</label>
<input id="email" type="email" [(ngModel)]="email" name="email">
</div>
</div>
<div class="form-group">
<label for="website">Website</label>
<input id="website" type="text" [(ngModel)]="website" name="website">
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-cancel" (click)="onClose()">Cancel</button>
<button type="submit" class="btn btn-save" [disabled]="saving">
{{ saving ? 'Saving…' : 'Save Config' }}
</button>
</div>
</form>
</div>
</div>
@@ -0,0 +1,196 @@
@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;
&--wide {
max-width: 720px;
max-height: 85vh;
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;
}
.form-success {
background: rgba(#2e7d32, 0.1);
color: #2e7d32;
padding: $spacing-sm $spacing-md;
border-radius: $radius-md;
font-size: 0.875rem;
margin-bottom: $spacing-md;
text-align: center;
}
.admin-form {
.form-section {
margin-bottom: $spacing-xl;
h3 {
font-family: $font-heading;
color: $primary-blue;
font-size: 1.1rem;
margin: 0 0 $spacing-md 0;
padding-bottom: $spacing-xs;
border-bottom: 1px solid $neutral-light;
}
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $spacing-md;
}
.form-group {
margin-bottom: $spacing-md;
label:not(.btn-upload) {
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);
}
}
}
.url-with-upload {
display: flex;
gap: $spacing-sm;
align-items: center;
input[type="text"] {
flex: 1;
}
}
.btn-upload {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
background: $neutral-light;
color: $neutral-dark;
border: none;
border-radius: $radius-md;
padding: $spacing-sm $spacing-md;
font-size: 0.875rem;
cursor: pointer;
transition: background $transition-fast;
white-space: nowrap;
&:hover {
background: $neutral-medium;
color: $neutral-white;
}
input[type="file"] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
width: 100%;
&:disabled {
cursor: not-allowed;
}
}
}
}
.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,161 @@
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 { StationConfig } from '../interfaces/station-config';
import { StationConfigService } from '../services/station-config.service';
@Component({
selector: 'app-admin-station-form',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './admin-station-form.component.html',
styleUrl: './admin-station-form.component.scss',
})
export class AdminStationFormComponent implements OnChanges {
private stationConfigService = inject(StationConfigService);
@Input() editItem: StationConfig | null = null;
@Output() saved = new EventEmitter<void>();
@Output() closed = new EventEmitter<void>();
callsign = '';
name_primary = '';
name_secondary = '';
tagline = '';
frequency = '';
founded_year = 0;
nonprofit_type = '';
ein = '';
logo_url = '';
hero_background_url = '';
hero_icon_url = '';
hero_divider_url = '';
address = '';
phone = '';
email = '';
website = '';
loading = false;
saving = false;
error = '';
success = '';
ngOnChanges(changes: SimpleChanges): void {
if (changes['editItem'] && this.editItem) {
this.populate(this.editItem);
}
}
/** Populate form with existing station config. */
populate(config: StationConfig): void {
this.callsign = config.callsign;
this.name_primary = config.name_primary;
this.name_secondary = config.name_secondary;
this.tagline = config.tagline;
this.frequency = config.frequency;
this.founded_year = config.founded_year;
this.nonprofit_type = config.nonprofit_type;
this.ein = config.ein;
this.logo_url = config.logo_url;
this.hero_background_url = config.hero_background_url;
this.hero_icon_url = config.hero_icon_url;
this.hero_divider_url = config.hero_divider_url;
this.address = config.address;
this.phone = config.phone;
this.email = config.email;
this.website = config.website;
}
/** Reset form with current service values. */
reset(): void {
const config = this.stationConfigService.config();
this.populate(config);
this.error = '';
this.success = '';
this.loading = false;
}
/** Close the dialog without saving. */
onClose(): void {
this.closed.emit();
this.reset();
}
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.';
}
/** Handle image file upload and set the URL field. */
async onImageUpload(event: Event, field: string): Promise<void> {
const input = event.target as HTMLInputElement;
if (!input?.files?.length) return;
const file = input.files[0];
if (!file.type.startsWith('image/')) {
this.error = 'Only image files are allowed.';
return;
}
if (file.size > 5 * 1024 * 1024) {
this.error = 'File size exceeds 5 MB limit.';
return;
}
this.loading = true;
this.error = '';
try {
const url = await this.stationConfigService.uploadImage(file);
(this as any)[field] = url;
this.success = 'Image uploaded successfully.';
} catch (err: any) {
this.error = this.formatError(err);
} finally {
this.loading = false;
}
}
async onSubmit(): Promise<void> {
if (!this.name_primary || !this.callsign) {
this.error = 'Callsign and primary name are required.';
return;
}
this.saving = true;
this.error = '';
this.success = '';
const payload: Partial<StationConfig> = {
callsign: this.callsign,
name_primary: this.name_primary,
name_secondary: this.name_secondary,
tagline: this.tagline,
frequency: this.frequency,
founded_year: this.founded_year,
nonprofit_type: this.nonprofit_type,
ein: this.ein,
logo_url: this.logo_url,
hero_background_url: this.hero_background_url,
hero_icon_url: this.hero_icon_url,
hero_divider_url: this.hero_divider_url,
address: this.address,
phone: this.phone,
email: this.email,
website: this.website,
};
try {
await this.stationConfigService.updateConfig(payload);
this.success = 'Station config updated successfully.';
this.saved.emit();
} catch (err: any) {
this.error = this.formatError(err);
} finally {
this.saving = false;
}
}
}
+58
View File
@@ -22,6 +22,11 @@
[class.active]="activeTab() === 'tiers'"
(click)="activeTab.set('tiers')"
>Tiers</button>
<button
class="tab-btn"
[class.active]="activeTab() === 'station'"
(click)="activeTab.set('station')"
>Station</button>
</div>
<!-- Programs Tab -->
@@ -145,6 +150,56 @@
</div>
}
}
<!-- Station Tab -->
@if (activeTab() === 'station') {
@if (stationConfig$ | async; as config) {
<div class="tab-content">
<div class="tab-toolbar">
<h2>Station Configuration</h2>
<button class="btn btn-add" (click)="openStationForm()">Edit Config</button>
</div>
<div class="station-config-view">
<div class="config-row">
<span class="config-label">Callsign</span>
<span class="config-value">{{ config.callsign }}</span>
</div>
<div class="config-row">
<span class="config-label">Name</span>
<span class="config-value">{{ config.name_primary }} {{ config.name_secondary }}</span>
</div>
<div class="config-row">
<span class="config-label">Tagline</span>
<span class="config-value">{{ config.tagline }}</span>
</div>
<div class="config-row">
<span class="config-label">Frequency</span>
<span class="config-value">{{ config.frequency }}</span>
</div>
<div class="config-row">
<span class="config-label">Founded</span>
<span class="config-value">{{ config.founded_year }}</span>
</div>
<div class="config-row">
<span class="config-label">Nonprofit</span>
<span class="config-value">{{ config.nonprofit_type }} — EIN {{ config.ein }}</span>
</div>
<div class="config-row">
<span class="config-label">Email</span>
<span class="config-value">{{ config.email }}</span>
</div>
<div class="config-row">
<span class="config-label">Phone</span>
<span class="config-value">{{ config.phone }}</span>
</div>
<div class="config-row">
<span class="config-label">Website</span>
<span class="config-value">{{ config.website }}</span>
</div>
</div>
</div>
}
}
</div>
<!-- Form modals -->
@@ -157,4 +212,7 @@
@if (showTierForm) {
<app-admin-tier-form [editItem]="editingTier" (saved)="onTierSaved()" (closed)="onTierClosed()" />
}
@if (showStationForm) {
<app-admin-station-form [editItem]="stationConfig$ | async" (saved)="onStationSaved()" (closed)="onStationClosed()" />
}
</section>
+27
View File
@@ -157,6 +157,33 @@
font-size: 1.1rem;
}
// ── Station config view ──────────────────────────────────
.station-config-view {
.config-row {
display: flex;
padding: $spacing-sm 0;
border-bottom: 1px solid $neutral-light;
&:last-child {
border-bottom: none;
}
}
.config-label {
font-weight: 600;
color: $neutral-medium;
width: 140px;
flex-shrink: 0;
font-size: 0.9rem;
}
.config-value {
color: $neutral-dark;
font-size: 0.95rem;
}
}
// ── Responsive ───────────────────────────────────────────
@include responsive(md) {
+29 -1
View File
@@ -6,14 +6,17 @@ import { BehaviorSubject, firstValueFrom } from 'rxjs';
import { Program } from '../interfaces/program';
import { Event } from '../interfaces/event';
import { Tier } from '../interfaces/tier';
import { StationConfig } from '../interfaces/station-config';
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 { 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';
type TabKey = 'programs' | 'events' | 'tiers';
type TabKey = 'programs' | 'events' | 'tiers' | 'station';
@Component({
selector: 'app-admin',
@@ -25,6 +28,7 @@ type TabKey = 'programs' | 'events' | 'tiers';
AdminProgramFormComponent,
AdminEventFormComponent,
AdminTierFormComponent,
AdminStationFormComponent,
],
templateUrl: './admin.component.html',
styleUrl: './admin.component.scss',
@@ -33,6 +37,7 @@ export class AdminComponent implements OnInit {
private programService = inject(ProgramService);
private eventService = inject(EventService);
private tierService = inject(TierService);
private stationConfigService = inject(StationConfigService);
activeTab = signal<TabKey>('programs');
@@ -45,10 +50,12 @@ export class AdminComponent implements OnInit {
readonly programs$ = new BehaviorSubject<Program[]>([]);
readonly events$ = new BehaviorSubject<Event[]>([]);
readonly tiers$ = new BehaviorSubject<Tier[]>([]);
readonly stationConfig$ = new BehaviorSubject<StationConfig | null>(null);
showProgramForm = false;
showEventForm = false;
showTierForm = false;
showStationForm = false;
ngOnInit(): void {
this.loadAll();
@@ -69,11 +76,17 @@ export class AdminComponent implements OnInit {
this.tiers$.next(data);
}
async reloadStationConfig(): Promise<void> {
await this.stationConfigService.load();
this.stationConfig$.next(this.stationConfigService.config());
}
async loadAll(): Promise<void> {
await Promise.all([
this.reloadPrograms(),
this.reloadEvents(),
this.reloadTiers(),
this.reloadStationConfig(),
]);
}
@@ -163,4 +176,19 @@ export class AdminComponent implements OnInit {
await firstValueFrom(this.tierService.deleteTier(id));
await this.reloadTiers();
}
// ── Station Config ──────────────────────────────────────
openStationForm(): void {
this.showStationForm = true;
}
onStationSaved(): void {
this.reloadStationConfig();
this.showStationForm = false;
}
onStationClosed(): void {
this.showStationForm = false;
}
}
+12 -1
View File
@@ -1,4 +1,4 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { APP_INITIALIZER, ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideRouter } from '@angular/router';
@@ -8,6 +8,16 @@ import {
appConfigInitProvider,
appConfigProvider,
} from './services/app-config.service';
import { StationConfigService } from './services/station-config.service';
export const stationConfigInitProvider = {
provide: APP_INITIALIZER,
useFactory: (stationConfigService: StationConfigService) => {
return () => stationConfigService.load();
},
deps: [StationConfigService],
multi: true,
};
export const appConfig: ApplicationConfig = {
providers: [
@@ -16,5 +26,6 @@ export const appConfig: ApplicationConfig = {
provideHttpClient(withInterceptors([authInterceptor])),
appConfigProvider,
appConfigInitProvider,
stationConfigInitProvider,
],
};
+4 -4
View File
@@ -45,19 +45,19 @@
<div class="contact-info">
<div class="info-card">
<h4><span aria-hidden="true">📍</span> Studio Location</h4>
<p>42 Pine Street<br>Mountain View, CO 80424</p>
<p [innerHTML]="config().address"></p>
</div>
<div class="info-card">
<h4><span aria-hidden="true">📞</span> Phone</h4>
<p>(970) 555-0198<br>MonFri 8 AM 6 MT</p>
<p>{{ config().phone }}<br>MonFri 8 AM 6 MT</p>
</div>
<div class="info-card">
<h4><span aria-hidden="true">✉️</span> Email</h4>
<p><a href="mailto:hello@kmountainflower.org">hello@kmountainflower.org</a></p>
<p><a [href]="'mailto:' + config().email">{{ config().email }}</a></p>
</div>
<div class="info-card">
<h4><span aria-hidden="true">📡</span> Frequencies</h4>
<p>98.7 FM — Mountain Region<br>Stream: kmountainflower.org/live</p>
<p>{{ config().frequency }} — Mountain Region<br>Stream: {{ config().website }}/live</p>
</div>
</div>
</div>
+5 -1
View File
@@ -1,6 +1,8 @@
import { Component } from '@angular/core';
import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { StationConfigService } from '../services/station-config.service';
@Component({
selector: 'app-contact',
standalone: true,
@@ -9,6 +11,8 @@ import { FormsModule } from '@angular/forms';
styleUrl: './contact.component.scss',
})
export class ContactComponent {
readonly config = inject(StationConfigService).config;
onSubmit(): void {
alert('Thank you for your message! We will get back to you shortly.');
}
+5 -5
View File
@@ -3,10 +3,10 @@
<div class="donate-banner">
<div class="donate-banner-overlay">
<div class="container donate-banner-content">
<img src="svg/orange-flower.svg" alt="" class="donate-flower-hero" aria-hidden="true">
<img [src]="config().hero_icon_url" alt="" class="donate-flower-hero" aria-hidden="true">
<h2>Keep the Music <span class="text-accent">Free</span></h2>
<p class="donate-subtitle">
KMountain Flower Radio is 100% listener-funded and independent.
{{ config().name_primary }} {{ config().name_secondary }} Radio is 100% listener-funded and independent.
Your gift keeps our signal strong and our airwaves free for everyone.
</p>
</div>
@@ -42,12 +42,12 @@
<!-- One-time donation -->
<div class="donate-one-time">
<div class="donate-flower-divider" aria-hidden="true">
<img src="svg/small-flower.svg" alt="">
<img [src]="config().logo_url" alt="">
</div>
<h3>Or Make a One-Time Gift</h3>
<p class="section-intro">
Every dollar matters — from $5 to $5,000. No amount is too small to make
a difference. All donations are tax-deductible (EIN: 84-XXXXXXX).
a difference. All donations are tax-deductible (EIN: {{ config().ein }}).
</p>
<a href="#" class="btn btn-lg">Give Any Amount</a>
</div>
@@ -57,7 +57,7 @@
<div class="why-grid">
<div class="why-item">
<span class="why-icon">&#x1F4FB;</span>
<strong>98.7 FM Signal</strong>
<strong>{{ config().frequency }} Signal</strong>
<p>Covering 3 mountain counties and the valleys between</p>
</div>
<div class="why-item">
+2
View File
@@ -4,6 +4,7 @@ import { map, Observable, startWith } from 'rxjs';
import { TierService } from '../services/tier.service';
import { Tier } from '../interfaces/tier';
import { StationConfigService } from '../services/station-config.service';
interface TiersState {
loading: boolean;
@@ -19,6 +20,7 @@ interface TiersState {
})
export class DonateComponent {
private tierService = inject(TierService);
readonly config = inject(StationConfigService).config;
/** Async pipe drives the template — guarantees change detection on every emission. */
readonly tiers$: Observable<TiersState> =
+6 -6
View File
@@ -2,17 +2,17 @@
<div class="container">
<div class="footer-top">
<div class="footer-brand">
<img src="svg/small-flower.svg" alt="" class="footer-flower" aria-hidden="true">
<img [src]="config().logo_url" alt="" class="footer-flower" aria-hidden="true">
<h3>
<span class="brand-m">K</span>MOUNTAIN
<span class="brand-a">FLOWER</span> Radio
<span class="brand-m">{{ config().name_primary | slice:0:1 }}</span>{{ config().name_primary | slice:1 }}
<span class="brand-a">{{ config().name_secondary }}</span> Radio
</h3>
<p class="footer-tagline">
Community-powered nonprofit public radio — free to stream, funded by you.
</p>
<p class="footer-legal">
KMountain Flower Radio is a 501(c)(3) nonprofit organization.<br>
EIN: 84-XXXXXXX. All donations are tax-deductible.
{{ config().name_primary }} {{ config().name_secondary }} Radio is a {{ config().nonprofit_type }} nonprofit organization.<br>
EIN: {{ config().ein }}. All donations are tax-deductible.
</p>
</div>
@@ -49,7 +49,7 @@
</div>
<div class="footer-bottom">
<p>&copy; 19872026 KMountain Flower Radio. All rights reserved.</p>
<p>&copy; {{ config().founded_year }}{{ currentYear() }} {{ config().name_primary }} {{ config().name_secondary }} Radio. All rights reserved.</p>
<p class="footer-made">
Made with 🧡 in the mountains
</p>
+10 -3
View File
@@ -1,10 +1,17 @@
import { Component } from '@angular/core';
import { Component, inject, computed } from '@angular/core';
import { SlicePipe } from '@angular/common';
import { RouterLink } from '@angular/router';
import { StationConfigService } from '../services/station-config.service';
@Component({
selector: 'app-footer',
standalone: true,
imports: [],
imports: [RouterLink, SlicePipe],
templateUrl: './footer.component.html',
styleUrl: './footer.component.scss',
})
export class FooterComponent {}
export class FooterComponent {
readonly config = inject(StationConfigService).config;
readonly currentYear = computed(() => new Date().getFullYear());
}
+7 -7
View File
@@ -1,7 +1,7 @@
<section class="hero">
<!-- Mountain background layer -->
<div class="hero-mountains" aria-hidden="true">
<img src="svg/mountain-landscape.svg" alt="">
<img [src]="config().hero_background_url" alt="">
</div>
<!-- Gradient overlay -->
@@ -11,18 +11,18 @@
<div class="hero-content container">
<div class="hero-inner">
<div class="hero-icon">
<img src="svg/orange-flower.svg" alt="" class="hero-flower">
<img [src]="config().hero_icon_url" alt="" class="hero-flower">
</div>
<h1 class="hero-title">
<span class="hero-line1">KMountain</span>
<span class="hero-line2 text-accent">Flower Radio</span>
<span class="hero-line1">{{ config().name_primary }}</span>
<span class="hero-line2 text-accent">{{ config().name_secondary }}</span>
</h1>
<p class="hero-subtitle">
Your community-powered, nonprofit radio station — bringing music,
stories, and connection to the mountain towns and beyond since 1987.
stories, and connection to the mountain towns and beyond since {{ config().founded_year }}.
</p>
<p class="hero-subtitle hero-tagline">
Free to stream. Funded by you. Always on-air.
{{ config().tagline }}
</p>
<div class="hero-actions">
<a routerLink="/donate" class="btn btn-hero-primary">Support the Station</a>
@@ -37,6 +37,6 @@
<!-- Mountain divider at bottom -->
<div class="hero-divider" aria-hidden="true">
<img src="svg/mountain-divider.svg" alt="">
<img [src]="config().hero_divider_url" alt="">
</div>
</section>
+8 -3
View File
@@ -1,10 +1,15 @@
import { Component } from '@angular/core';
import { Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { StationConfigService } from '../services/station-config.service';
@Component({
selector: 'app-hero',
standalone: true,
imports: [],
imports: [RouterLink],
templateUrl: './hero.component.html',
styleUrl: './hero.component.scss',
})
export class HeroComponent {}
export class HeroComponent {
readonly config = inject(StationConfigService).config;
}
+20
View File
@@ -0,0 +1,20 @@
export interface StationConfig {
id: number;
key: string;
callsign: string;
name_primary: string;
name_secondary: string;
tagline: string;
frequency: string;
founded_year: number;
nonprofit_type: string;
ein: string;
logo_url: string;
hero_background_url: string;
hero_icon_url: string;
hero_divider_url: string;
address: string;
phone: string;
email: string;
website: string;
}
+2 -2
View File
@@ -1,9 +1,9 @@
<section class="login-section">
<div class="login-card">
<div class="login-header">
<img src="svg/small-flower.svg" alt="" class="login-flower" aria-hidden="true">
<img [src]="config().logo_url" alt="" class="login-flower" aria-hidden="true">
<h1>Sign In</h1>
<p>Admin access for KMountain Flower Radio</p>
<p>Admin access for {{ config().name_primary }} {{ config().name_secondary }} Radio</p>
</div>
<form (ngSubmit)="onSubmit()" class="login-form">
+2
View File
@@ -5,6 +5,7 @@ import { Router } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { AuthService } from '../services/auth.service';
import { StationConfigService } from '../services/station-config.service';
@Component({
selector: 'app-login',
@@ -17,6 +18,7 @@ export class LoginComponent {
private auth = inject(AuthService);
private router = inject(Router);
private cdr = inject(ChangeDetectorRef);
readonly config = inject(StationConfigService).config;
username = '';
password = '';
+4 -4
View File
@@ -1,12 +1,12 @@
<nav class="navbar" role="navigation" aria-label="Main navigation">
<div class="container navbar-inner">
<a routerLink="/" class="navbar-brand" routerLinkActive="active">
<img src="svg/small-flower.svg" alt="" class="brand-flower" aria-hidden="true">
<img [src]="config().logo_url" alt="" class="brand-flower" aria-hidden="true">
<span class="brand-name">
<span class="brand-mountain">K</span>MOUNTAIN
<span class="brand-accent">FLOWER</span>
<span class="brand-mountain">{{ config().name_primary | slice:0:1 }}</span>{{ config().name_primary | uppercase }}
<span class="brand-accent">{{ config().name_secondary | uppercase }}</span>
</span>
<span class="brand-tagline">Community Radio — Est. 1987</span>
<span class="brand-tagline">{{ config().tagline }}</span>
</a>
<button class="navbar-toggle" aria-label="Toggle menu" (click)="toggleMenu()">
+7 -3
View File
@@ -1,14 +1,15 @@
import { Component, DestroyRef, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Component, DestroyRef, inject, computed, OnInit } from '@angular/core';
import { CommonModule, SlicePipe, UpperCasePipe } from '@angular/common';
import { RouterLink, RouterLinkActive, Router } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { AuthService } from '../services/auth.service';
import { StationConfigService } from '../services/station-config.service';
@Component({
selector: 'app-navbar',
standalone: true,
imports: [CommonModule, RouterLink, RouterLinkActive],
imports: [CommonModule, RouterLink, RouterLinkActive, SlicePipe, UpperCasePipe],
templateUrl: './navbar.component.html',
styleUrl: './navbar.component.scss',
})
@@ -16,6 +17,9 @@ export class NavbarComponent implements OnInit {
private auth = inject(AuthService);
private router = inject(Router);
private destroyRef = inject(DestroyRef);
private stationConfigService = inject(StationConfigService);
readonly config = computed(() => this.stationConfigService.config());
menuOpen = false;
+2 -2
View File
@@ -1,11 +1,11 @@
<section class="schedule">
<div class="container">
<div class="schedule-header">
<img src="svg/small-flower.svg" alt="" class="schedule-flower" aria-hidden="true">
<img [src]="config().logo_url" alt="" class="schedule-flower" aria-hidden="true">
<h2>Program <span class="text-accent">Schedule</span></h2>
<p class="section-intro">
All times are local (Mountain Time). Every show is free to listen to
live at 98.7 FM or via our online stream.
live at {{ config().frequency }} or via our online stream.
</p>
</div>
+3 -1
View File
@@ -4,6 +4,7 @@ import { map, Observable, startWith } from 'rxjs';
import { ProgramService } from '../services/program.service';
import { Program } from '../interfaces/program';
import { StationConfigService } from '../services/station-config.service';
interface DayGroup {
day_label: string;
@@ -36,12 +37,13 @@ function groupByDay(programs: Program[]): DayGroup[] {
@Component({
selector: 'app-schedule',
standalone: true,
imports: [AsyncPipe, NgFor],
imports: [AsyncPipe],
templateUrl: './schedule.component.html',
styleUrl: './schedule.component.scss',
})
export class ScheduleComponent {
private programService = inject(ProgramService);
readonly config = inject(StationConfigService).config;
/** Async pipe drives the template — guarantees change detection on every emission. */
readonly schedule$: Observable<ScheduleState> =
@@ -0,0 +1,67 @@
import { Injectable, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { StationConfig } from '../interfaces/station-config';
import { getAppConfig } from './app-config.service';
/** Hardcoded defaults — used when the API is unavailable (dev fallback). */
const DEFAULT_CONFIG: StationConfig = {
id: 0,
key: 'default',
callsign: 'KMTN',
name_primary: 'KMountain',
name_secondary: 'Flower Radio',
tagline: 'Community Radio — Est. 1987',
frequency: '98.7 FM',
founded_year: 1987,
nonprofit_type: '501(c)(3)',
ein: '84-XXXXXXX',
logo_url: 'svg/small-flower.svg',
hero_background_url: 'svg/mountain-landscape.svg',
hero_icon_url: 'svg/orange-flower.svg',
hero_divider_url: 'svg/mountain-divider.svg',
address: '42 Pine Street\nMountain View, CO 80424',
phone: '(970) 555-0198',
email: 'hello@kmountainflower.org',
website: 'kmountainflower.org',
};
@Injectable({
providedIn: 'root',
})
export class StationConfigService {
private http = inject(HttpClient);
private baseUrl = `${getAppConfig().apiBaseUrl}/api/station-config`;
readonly config = signal<StationConfig>(DEFAULT_CONFIG);
/** Load station config from the API. Falls back to defaults on error. */
async load(): Promise<void> {
try {
const data = await firstValueFrom(this.http.get<StationConfig>(this.baseUrl));
this.config.set(data);
} catch {
// Keep the default config if the API is unavailable.
console.warn('StationConfigService: Failed to load config, using defaults.');
}
}
/** Update station config (admin only). */
async updateConfig(payload: Partial<StationConfig>): Promise<void> {
const data = await firstValueFrom(
this.http.put<StationConfig>(this.baseUrl, payload),
);
this.config.set(data);
}
/** Upload an image file for station branding (admin only). Returns the relative URL. */
async uploadImage(file: File): Promise<string> {
const formData = new FormData();
formData.append('file', file);
const response = await firstValueFrom(
this.http.post<{ url: string }>(`${this.baseUrl}/upload`, formData),
);
return response.url;
}
}