CI Pipeline / backend-test (push) Successful in 5m16s
CI Pipeline / backend-test (pull_request) Successful in 5m5s
CI Pipeline / frontend-test (pull_request) Successful in 27s
CI Pipeline / frontend-build (pull_request) Failing after 29s
CI Pipeline / backend-lint (pull_request) Successful in 8s
CI Pipeline / quality-gate (push) Successful in 2s
CI Pipeline / frontend-test (push) Successful in 28s
CI Pipeline / frontend-build (push) Failing after 29s
CI Pipeline / backend-lint (push) Successful in 8s
CI Pipeline / quality-gate (pull_request) Successful in 2s
Add SeoService for dynamic metadata management: - Per-route document titles (e.g., 'KMountain Flower Radio — About') - Meta descriptions from station config per route - Canonical URLs, Open Graph tags, Twitter Card tags - Input sanitization (HTML stripping, URL validation, attribute escaping) - noindex robots directives for /login and /admin routes - Centralized router watcher (zero per-component boilerplate) Add StructuredDataService + StructuredDataComponent for JSON-LD: - Organization schema (NCBOrganization) site-wide with contact info - WebSite schema with publisher info - WebPage schema per-route with breadcrumbs - Dynamic Event schema on /events page - RadioSeason schema on /schedule page Wire up SeoService.watchRouter() in app-root ngOnInit. Wire up StructuredDataComponent in app-root template. Wire up EventsComponent and ScheduleComponent to feed data to StructuredDataService for dynamic schema injection. Includes full test suite for SeoService (22 tests).
63 lines
2.1 KiB
TypeScript
63 lines
2.1 KiB
TypeScript
import { Component, effect, inject } from '@angular/core';
|
|
import { AsyncPipe } from '@angular/common';
|
|
import { map, Observable, startWith } from 'rxjs';
|
|
|
|
import { ShowService } from '../services/show.service';
|
|
import { Show } from '../interfaces/show';
|
|
import { StationConfigService } from '../services/station-config.service';
|
|
import { StructuredDataService } from '../services/structured-data.service';
|
|
import { SortSchedulesPipe } from '../pipes/sort-schedules.pipe';
|
|
import { resolveImageUrl } from '../services/app-config.service';
|
|
|
|
interface ScheduleState {
|
|
loading: boolean;
|
|
shows: Show[];
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app-schedule',
|
|
standalone: true,
|
|
imports: [AsyncPipe, SortSchedulesPipe],
|
|
templateUrl: './schedule.component.html',
|
|
styleUrl: './schedule.component.scss',
|
|
})
|
|
export class ScheduleComponent {
|
|
private showService = inject(ShowService);
|
|
readonly config = inject(StationConfigService).config;
|
|
private structuredData = inject(StructuredDataService);
|
|
|
|
/** ID of the currently expanded show card — null means none. */
|
|
expandedShowId: number | null = null;
|
|
|
|
/** Toggle expansion: clicking the active card collapses it; clicking another expands it. */
|
|
toggleShow(showId: number): void {
|
|
this.expandedShowId = this.expandedShowId === showId ? null : showId;
|
|
}
|
|
|
|
/** Async pipe drives the template — guarantees change detection on every emission. */
|
|
readonly schedule$: Observable<ScheduleState> =
|
|
this.showService.getShows().pipe(
|
|
map((shows) => ({ loading: false, shows })),
|
|
startWith({ loading: true, shows: [] }),
|
|
);
|
|
|
|
/** Resolve a relative uploads/… path to an absolute URL for image rendering. */
|
|
resolveAsset(url: string | null): string {
|
|
return resolveImageUrl(url ?? '');
|
|
}
|
|
|
|
resolveImageUrl(url: string): string {
|
|
return resolveImageUrl(url);
|
|
}
|
|
|
|
constructor() {
|
|
// Feed active shows to structured data for RadioSeason schema injection.
|
|
effect(() => {
|
|
const snapshot = this.showService.getShows();
|
|
snapshot.subscribe((shows) => {
|
|
this.structuredData.activeShows.set(shows);
|
|
});
|
|
});
|
|
}
|
|
}
|