database driven image uploader and storage service model
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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,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,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,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,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user