database driven image uploader and storage service model

This commit is contained in:
2026-06-22 19:29:06 +00:00
parent 76894061c5
commit 3b61a131c1
31 changed files with 433 additions and 149 deletions
+3 -3
View File
@@ -2,7 +2,7 @@
<div class="container">
<!-- Intro -->
<div class="about-intro">
<img [src]="config().hero_icon_url" alt="" class="about-flower-decor" aria-hidden="true">
<img [src]="resolveImageUrl(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, {{ config().name_primary }} {{ config().name_secondary }} has been the voice of our
@@ -56,7 +56,7 @@
<div class="team-card">
<div class="team-photo">
@if (member.photo_url) {
<img [src]="member.photo_url" [alt]="member.name">
<img [src]="resolveImageUrl(member.photo_url)" [alt]="member.name">
} @else {
<span>{{ member.name.charAt(0) }}</span>
}
@@ -76,7 +76,7 @@
<!-- Donation CTA -->
<div class="about-cta">
<div class="about-flower-divider" aria-hidden="true">
<img [src]="config().logo_url" alt="">
<img [src]="resolveImageUrl(config().logo_url)" alt="">
</div>
<h3>Become a Member</h3>
<p class="section-intro">
+5
View File
@@ -10,6 +10,7 @@ import { CommunityHighlightService } from '../services/community-highlight.servi
import { HistoryEntry } from '../interfaces/history-entry';
import { TeamMember } from '../interfaces/team-member';
import { CommunityHighlight } from '../interfaces/community-highlight';
import { resolveImageUrl } from '../services/app-config.service';
@Component({
selector: 'app-about',
@@ -29,6 +30,10 @@ export class AboutComponent {
readonly teamMembers$ = new BehaviorSubject<TeamMember[]>([]);
readonly communityHighlights$ = new BehaviorSubject<CommunityHighlight[]>([]);
resolveImageUrl(url: string): string {
return resolveImageUrl(url);
}
constructor() {
this.load();
}
+1 -1
View File
@@ -91,7 +91,7 @@
<div class="form-actions">
<button type="button" class="btn btn-cancel" (click)="onClose()">Cancel</button>
<button type="submit" class="btn btn-save" [disabled]="loading">
<button type="submit" class="btn btn-save" [disabled]="loading || uploading">
{{ loading ? 'Saving...' : (id ? 'Update' : 'Create') }}
</button>
</div>
+9 -5
View File
@@ -1,4 +1,4 @@
import { Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { ChangeDetectorRef, Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
@@ -6,7 +6,7 @@ import { firstValueFrom } from 'rxjs';
import { Show } from '../interfaces/show';
import { ShowService, ShowScheduleCreate, ShowCreatePayload, ShowUpdatePayload } from '../services/show.service';
import { UploadService } from '../services/upload.service';
import { getAppConfig } from '../services/app-config.service';
import { resolveImageUrl } from '../services/app-config.service';
@Component({
selector: 'app-admin-show-form',
@@ -16,6 +16,7 @@ import { getAppConfig } from '../services/app-config.service';
styleUrl: './admin-show-form.component.scss',
})
export class AdminShowFormComponent implements OnChanges {
private cdr = inject(ChangeDetectorRef);
private showService = inject(ShowService);
private uploadService = inject(UploadService);
@@ -117,15 +118,19 @@ export class AdminShowFormComponent implements OnChanges {
const file = input.files[0];
if (!file.type.startsWith('image/')) {
this.error = 'Only image files are allowed.';
this.cdr.detectChanges();
return;
}
if (file.size > 5 * 1024 * 1024) {
this.error = 'File size exceeds 5 MB limit.';
this.cdr.detectChanges();
return;
}
this.uploading = true;
this.error = '';
this.success = '';
this.cdr.detectChanges();
try {
const url = await this.uploadService.uploadImage(file);
@@ -135,14 +140,13 @@ export class AdminShowFormComponent implements OnChanges {
this.error = this.formatError(err);
} finally {
this.uploading = false;
this.cdr.detectChanges();
}
}
/** Resolve a relative uploads/… path to an absolute URL for preview. */
getPreviewUrl(url: string): string {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://')) return url;
return `${getAppConfig().apiBaseUrl}/${url}`;
return resolveImageUrl(url);
}
async onSubmit(): Promise<void> {
@@ -1,4 +1,4 @@
import { Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { ChangeDetectorRef, Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
@@ -14,6 +14,7 @@ import { StationConfigService } from '../services/station-config.service';
styleUrl: './admin-station-form.component.scss',
})
export class AdminStationFormComponent implements OnChanges {
private cdr = inject(ChangeDetectorRef);
private stationConfigService = inject(StationConfigService);
@Input() editItem: StationConfig | null = null;
@@ -98,15 +99,19 @@ export class AdminStationFormComponent implements OnChanges {
const file = input.files[0];
if (!file.type.startsWith('image/')) {
this.error = 'Only image files are allowed.';
this.cdr.detectChanges();
return;
}
if (file.size > 5 * 1024 * 1024) {
this.error = 'File size exceeds 5 MB limit.';
this.cdr.detectChanges();
return;
}
this.loading = true;
this.error = '';
this.success = '';
this.cdr.detectChanges();
try {
const url = await this.stationConfigService.uploadImage(file);
@@ -116,6 +121,7 @@ export class AdminStationFormComponent implements OnChanges {
this.error = this.formatError(err);
} finally {
this.loading = false;
this.cdr.detectChanges();
}
}
+9 -5
View File
@@ -1,4 +1,4 @@
import { Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { ChangeDetectorRef, Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
@@ -6,7 +6,7 @@ import { firstValueFrom } from 'rxjs';
import { TeamMember } from '../interfaces/team-member';
import { TeamMemberService } from '../services/team-member.service';
import { UploadService } from '../services/upload.service';
import { getAppConfig } from '../services/app-config.service';
import { resolveImageUrl } from '../services/app-config.service';
@Component({
selector: 'app-admin-team-form',
@@ -16,6 +16,7 @@ import { getAppConfig } from '../services/app-config.service';
styleUrl: './admin-team-form.component.scss',
})
export class AdminTeamFormComponent implements OnChanges {
private cdr = inject(ChangeDetectorRef);
private teamMemberService = inject(TeamMemberService);
private uploadService = inject(UploadService);
@@ -86,15 +87,19 @@ export class AdminTeamFormComponent implements OnChanges {
const file = input.files[0];
if (!file.type.startsWith('image/')) {
this.error = 'Only image files are allowed.';
this.cdr.detectChanges();
return;
}
if (file.size > 5 * 1024 * 1024) {
this.error = 'File size exceeds 5 MB limit.';
this.cdr.detectChanges();
return;
}
this.uploading = true;
this.error = '';
this.success = '';
this.cdr.detectChanges();
try {
const url = await this.uploadService.uploadImage(file);
@@ -104,14 +109,13 @@ export class AdminTeamFormComponent implements OnChanges {
this.error = this.formatError(err);
} finally {
this.uploading = false;
this.cdr.detectChanges();
}
}
/** Resolve a relative uploads/… path to an absolute URL for preview. */
getPreviewUrl(url: string): string {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://')) return url;
return `${getAppConfig().apiBaseUrl}/${url}`;
return resolveImageUrl(url);
}
async onSubmit(): Promise<void> {
+2 -2
View File
@@ -3,7 +3,7 @@
<div class="donate-banner">
<div class="donate-banner-overlay">
<div class="container donate-banner-content">
<img [src]="config().hero_icon_url" alt="" class="donate-flower-hero" aria-hidden="true">
<img [src]="resolveImageUrl(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">
{{ config().name_primary }} {{ config().name_secondary }} Radio is 100% listener-funded and independent.
@@ -42,7 +42,7 @@
<!-- One-time donation -->
<div class="donate-one-time">
<div class="donate-flower-divider" aria-hidden="true">
<img [src]="config().logo_url" alt="">
<img [src]="resolveImageUrl(config().logo_url)" alt="">
</div>
<h3>Or Make a One-Time Gift</h3>
<p class="section-intro">
+5
View File
@@ -5,6 +5,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';
import { resolveImageUrl } from '../services/app-config.service';
interface TiersState {
loading: boolean;
@@ -22,6 +23,10 @@ export class DonateComponent {
private tierService = inject(TierService);
readonly config = inject(StationConfigService).config;
resolveImageUrl(url: string): string {
return resolveImageUrl(url);
}
/** Async pipe drives the template — guarantees change detection on every emission. */
readonly tiers$: Observable<TiersState> =
this.tierService.getTiers().pipe(
+1 -1
View File
@@ -2,7 +2,7 @@
<div class="container">
<div class="footer-top">
<div class="footer-brand">
<img [src]="config().logo_url" alt="" class="footer-flower" aria-hidden="true">
<img [src]="resolveImageUrl(config().logo_url)" alt="" class="footer-flower" aria-hidden="true">
<h3>
<span class="brand-m">{{ config().name_primary | slice:0:1 }}</span>{{ config().name_primary | slice:1 }}
<span class="brand-a">{{ config().name_secondary }}</span>
+5
View File
@@ -3,6 +3,7 @@ import { SlicePipe } from '@angular/common';
import { RouterLink } from '@angular/router';
import { StationConfigService } from '../services/station-config.service';
import { resolveImageUrl } from '../services/app-config.service';
@Component({
selector: 'app-footer',
@@ -14,4 +15,8 @@ import { StationConfigService } from '../services/station-config.service';
export class FooterComponent {
readonly config = inject(StationConfigService).config;
readonly currentYear = computed(() => new Date().getFullYear());
resolveImageUrl(url: string): string {
return resolveImageUrl(url);
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
<section class="hero">
<!-- Mountain background layer -->
<div class="hero-mountains" aria-hidden="true">
<img [src]="config().hero_background_url" alt="">
<img [src]="resolveImageUrl(config().hero_background_url)" alt="">
</div>
<!-- Gradient overlay -->
@@ -11,7 +11,7 @@
<div class="hero-content container">
<div class="hero-inner">
<div class="hero-icon">
<img [src]="config().hero_icon_url" alt="" class="hero-flower">
<img [src]="resolveImageUrl(config().hero_icon_url)" alt="" class="hero-flower">
</div>
<h1 class="hero-title">
<span class="hero-line1">{{ config().name_primary }}</span>
@@ -37,6 +37,6 @@
<!-- Mountain divider at bottom -->
<div class="hero-divider" aria-hidden="true">
<img [src]="config().hero_divider_url" alt="">
<img [src]="resolveImageUrl(config().hero_divider_url)" alt="">
</div>
</section>
+5
View File
@@ -2,6 +2,7 @@ import { Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { StationConfigService } from '../services/station-config.service';
import { resolveImageUrl } from '../services/app-config.service';
@Component({
selector: 'app-hero',
@@ -12,4 +13,8 @@ import { StationConfigService } from '../services/station-config.service';
})
export class HeroComponent {
readonly config = inject(StationConfigService).config;
resolveImageUrl(url: string): string {
return resolveImageUrl(url);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
<section class="login-section">
<div class="login-card">
<div class="login-header">
<img [src]="config().logo_url" alt="" class="login-flower" aria-hidden="true">
<img [src]="resolveImageUrl(config().logo_url)" alt="" class="login-flower" aria-hidden="true">
<h1>Sign In</h1>
<p>Admin access for {{ config().name_primary }} {{ config().name_secondary }} Radio</p>
</div>
+5
View File
@@ -6,6 +6,7 @@ import { firstValueFrom } from 'rxjs';
import { AuthService } from '../services/auth.service';
import { StationConfigService } from '../services/station-config.service';
import { resolveImageUrl } from '../services/app-config.service';
@Component({
selector: 'app-login',
@@ -25,6 +26,10 @@ export class LoginComponent {
error = '';
loading = false;
resolveImageUrl(url: string): string {
return resolveImageUrl(url);
}
async onSubmit(): Promise<void> {
if (!this.username || !this.password) return;
+1 -1
View File
@@ -1,7 +1,7 @@
<nav class="navbar" role="navigation" aria-label="Main navigation">
<div class="container navbar-inner">
<a routerLink="/" class="navbar-brand" routerLinkActive="active">
<img [src]="config().logo_url" alt="" class="brand-flower" aria-hidden="true">
<img [src]="resolveImageUrl(config().logo_url)" alt="" class="brand-flower" aria-hidden="true">
<span class="brand-name">
<span class="brand-mountain">{{ config().callsign | uppercase }}</span>
<span class="brand-tagline">{{ config().tagline }}</span>
+5
View File
@@ -5,6 +5,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { AuthService } from '../services/auth.service';
import { StationConfigService } from '../services/station-config.service';
import { resolveImageUrl } from '../services/app-config.service';
@Component({
selector: 'app-navbar',
@@ -47,6 +48,10 @@ export class NavbarComponent implements OnInit {
this.menuOpen = false;
}
resolveImageUrl(url: string): string {
return resolveImageUrl(url);
}
onLogout(): void {
this.auth.logout();
this.router.navigate(['/']);
+1 -1
View File
@@ -1,7 +1,7 @@
<section class="schedule">
<div class="container">
<div class="schedule-header">
<img [src]="config().logo_url" alt="" class="schedule-flower" aria-hidden="true">
<img [src]="resolveImageUrl(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
+6 -4
View File
@@ -6,7 +6,7 @@ import { ShowService } from '../services/show.service';
import { Show } from '../interfaces/show';
import { StationConfigService } from '../services/station-config.service';
import { SortSchedulesPipe } from '../pipes/sort-schedules.pipe';
import { getAppConfig } from '../services/app-config.service';
import { resolveImageUrl } from '../services/app-config.service';
interface ScheduleState {
loading: boolean;
@@ -33,8 +33,10 @@ export class ScheduleComponent {
/** Resolve a relative uploads/… path to an absolute URL for image rendering. */
resolveAsset(url: string | null): string {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://')) return url;
return `${getAppConfig().apiBaseUrl}/${url}`;
return resolveImageUrl(url ?? '');
}
resolveImageUrl(url: string): string {
return resolveImageUrl(url);
}
}
+13
View File
@@ -42,3 +42,16 @@ export const appConfigInitProvider = {
export function getAppConfig(): AppConfig {
return _appConfig;
}
/** Resolve an image URL for rendering.
* - Absolute HTTP(S) URLs are returned as-is.
* - Static SVG assets (svg/…) are served by the Angular build — returned as-is.
* - API paths (/api/storage/…) and legacy paths (uploads/…) are prefixed with apiBaseUrl.
*/
export function resolveImageUrl(url: string): string {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://')) return url;
if (url.startsWith('svg/')) return url;
if (url.startsWith('/')) return `${getAppConfig().apiBaseUrl}${url}`;
return `${getAppConfig().apiBaseUrl}/${url}`;
}
+1 -1
View File
@@ -60,7 +60,7 @@ export class StationConfigService {
const formData = new FormData();
formData.append('file', file);
const response = await firstValueFrom(
this.http.post<{ url: string }>(`${this.baseUrl}/upload`, formData),
this.http.post<{ url: string }>(`${getAppConfig().apiBaseUrl}/api/storage/upload`, formData),
);
return response.url;
}
+1 -1
View File
@@ -10,7 +10,7 @@ import { getAppConfig } from './app-config.service';
})
export class UploadService {
private http = inject(HttpClient);
private baseUrl = `${getAppConfig().apiBaseUrl}/api/upload/image`;
private baseUrl = `${getAppConfig().apiBaseUrl}/api/storage/upload`;
/** Upload an image file. Returns the relative URL (e.g. `uploads/abc123.png`). */
async uploadImage(file: File): Promise<string> {