working basic site admin
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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()">×</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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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>Mon–Fri 8 AM – 6 MT</p>
|
||||
<p>{{ config().phone }}<br>Mon–Fri 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>
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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">📻</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">
|
||||
|
||||
@@ -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> =
|
||||
|
||||
@@ -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>© 1987–2026 KMountain Flower Radio. All rights reserved.</p>
|
||||
<p>© {{ 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>
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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()">
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user