Adds the new shows admin screen and drives schedule from it

This commit is contained in:
2026-06-22 04:19:51 +00:00
parent e627fe3637
commit eb9a00f21a
28 changed files with 546 additions and 54 deletions
+23 -2
View File
@@ -8,6 +8,9 @@
@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">
<div class="form-group">
@@ -35,12 +38,30 @@
<div class="form-row">
<div class="form-group">
<label for="show_art_url">Art URL</label>
<input id="show_art_url" type="text" [(ngModel)]="show_art_url" name="show_art_url" placeholder="uploads/show-art.jpg">
<div class="url-with-upload">
<input id="show_art_url" type="text" [(ngModel)]="show_art_url" name="show_art_url" placeholder="uploads/show-art.jpg">
<label class="btn btn-upload">
Upload
<input type="file" accept="image/*" (change)="onImageUpload($event, 'show_art_url')" [disabled]="uploading">
</label>
</div>
@if (show_art_url) {
<img [src]="getPreviewUrl(show_art_url)" [alt]="title + ' art'" class="image-preview" (error)="$event.target.style.display='none'">
}
</div>
<div class="form-group">
<label for="hero_image_url">Hero Image URL</label>
<input id="hero_image_url" type="text" [(ngModel)]="hero_image_url" name="hero_image_url" placeholder="uploads/hero-bg.jpg">
<div class="url-with-upload">
<input id="hero_image_url" type="text" [(ngModel)]="hero_image_url" name="hero_image_url" placeholder="uploads/hero-bg.jpg">
<label class="btn btn-upload">
Upload
<input type="file" accept="image/*" (change)="onImageUpload($event, 'hero_image_url')" [disabled]="uploading">
</label>
</div>
@if (hero_image_url) {
<img [src]="getPreviewUrl(hero_image_url)" [alt]="title + ' hero'" class="image-preview" (error)="$event.target.style.display='none'">
}
</div>
</div>
+23 -1
View File
@@ -58,6 +58,8 @@
}
.admin-form {
@include url-with-upload;
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -67,7 +69,7 @@
.form-group {
margin-bottom: $spacing-md;
label {
label:not(.btn-upload) {
display: block;
font-size: 0.875rem;
font-weight: 600;
@@ -96,6 +98,26 @@
}
}
}
.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;
}
.image-preview {
display: block;
margin-top: $spacing-xs;
width: 48px;
height: 48px;
object-fit: cover;
border-radius: $radius-md;
border: 1px solid $neutral-light;
}
}
// ── Schedule slots ─────────────────────────────────────────
+43 -1
View File
@@ -5,6 +5,8 @@ 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';
@Component({
selector: 'app-admin-show-form',
@@ -15,6 +17,7 @@ import { ShowService, ShowScheduleCreate, ShowCreatePayload, ShowUpdatePayload }
})
export class AdminShowFormComponent implements OnChanges {
private showService = inject(ShowService);
private uploadService = inject(UploadService);
@Input() editItem: Show | null = null;
@Output() saved = new EventEmitter<void>();
@@ -43,7 +46,9 @@ export class AdminShowFormComponent implements OnChanges {
];
loading = false;
uploading = false;
error = '';
success = '';
submitted = false;
ngOnChanges(changes: SimpleChanges): void {
@@ -85,6 +90,7 @@ export class AdminShowFormComponent implements OnChanges {
this.display_order = 0;
this.schedules = [{ day_of_week: 1, time: '' }];
this.error = '';
this.success = '';
this.submitted = false;
}
@@ -103,6 +109,42 @@ export class AdminShowFormComponent implements OnChanges {
this.schedules.splice(index, 1);
}
/** 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.uploading = true;
this.error = '';
try {
const url = await this.uploadService.uploadImage(file);
(this as any)[field] = url;
this.success = 'Image uploaded successfully.';
} catch (err: any) {
this.error = this.formatError(err);
} finally {
this.uploading = false;
}
}
/** 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}`;
}
async onSubmit(): Promise<void> {
this.submitted = true;
@@ -150,4 +192,4 @@ export class AdminShowFormComponent implements OnChanges {
this.loading = false;
}
}
}
}
@@ -119,48 +119,7 @@
}
}
.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;
}
}
}
@include url-with-upload;
}
.form-actions {
+13 -1
View File
@@ -8,6 +8,9 @@
@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">
<div class="form-row">
@@ -34,7 +37,16 @@
<div class="form-group">
<label for="photo_url">Photo URL (optional)</label>
<input id="photo_url" type="url" [(ngModel)]="photo_url" name="photo_url" placeholder="https://...">
<div class="url-with-upload">
<input id="photo_url" type="text" [(ngModel)]="photo_url" name="photo_url" placeholder="uploads/photo.jpg">
<label class="btn btn-upload">
Upload
<input type="file" accept="image/*" (change)="onImageUpload($event, 'photo_url')" [disabled]="uploading">
</label>
</div>
@if (photo_url) {
<img [src]="getPreviewUrl(photo_url)" [alt]="name + ' photo'" class="image-preview" (error)="$event.target.style.display='none'">
}
</div>
<div class="form-group checkbox-group">
+23 -1
View File
@@ -58,6 +58,8 @@
}
.admin-form {
@include url-with-upload;
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -67,7 +69,7 @@
.form-group {
margin-bottom: $spacing-md;
label {
label:not(.btn-upload) {
display: block;
font-size: 0.875rem;
font-weight: 600;
@@ -111,6 +113,26 @@
}
}
}
.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;
}
.image-preview {
display: block;
margin-top: $spacing-xs;
width: 48px;
height: 48px;
object-fit: cover;
border-radius: $radius-md;
border: 1px solid $neutral-light;
}
}
.form-actions {
@@ -5,6 +5,8 @@ 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';
@Component({
selector: 'app-admin-team-form',
@@ -15,6 +17,7 @@ import { TeamMemberService } from '../services/team-member.service';
})
export class AdminTeamFormComponent implements OnChanges {
private teamMemberService = inject(TeamMemberService);
private uploadService = inject(UploadService);
@Input() editItem: TeamMember | null = null;
@Output() saved = new EventEmitter<void>();
@@ -29,7 +32,9 @@ export class AdminTeamFormComponent implements OnChanges {
active = true;
loading = false;
uploading = false;
error = '';
success = '';
submitted = false;
ngOnChanges(changes: SimpleChanges): void {
@@ -64,6 +69,7 @@ export class AdminTeamFormComponent implements OnChanges {
this.display_order = 0;
this.active = true;
this.error = '';
this.success = '';
this.submitted = false;
}
@@ -72,6 +78,42 @@ export class AdminTeamFormComponent implements OnChanges {
this.reset();
}
/** Handle image file upload and set the photo 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.uploading = true;
this.error = '';
try {
const url = await this.uploadService.uploadImage(file);
(this as any)[field] = url;
this.success = 'Image uploaded successfully.';
} catch (err: any) {
this.error = this.formatError(err);
} finally {
this.uploading = false;
}
}
/** 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}`;
}
async onSubmit(): Promise<void> {
this.submitted = true;
if (!this.name || !this.title) {
+2 -2
View File
@@ -21,7 +21,7 @@
<!-- Left: Show art badge -->
<div class="show-card-art">
@if (show.show_art_url) {
<img [src]="show.show_art_url" [alt]="show.title + ' art'" class="show-art-img" />
<img [src]="resolveAsset(show.show_art_url)" [alt]="show.title + ' art'" class="show-art-img" />
} @else {
<div class="show-art-placeholder">
<span aria-hidden="true">&#x1F3B5;</span>
@@ -30,7 +30,7 @@
</div>
<!-- Right: Hero-backed info area -->
<div class="show-card-right" [class.has-hero]="show.hero_image_url" [style.background-image]="show.hero_image_url ? 'url(' + show.hero_image_url + ')' : ''">
<div class="show-card-right" [class.has-hero]="show.hero_image_url" [style.background-image]="show.hero_image_url ? 'url(' + resolveAsset(show.hero_image_url) + ')' : ''">
<div class="show-card-overlay"></div>
<div class="show-card-info">
<h3 class="show-title">{{ show.title }}</h3>
+3 -3
View File
@@ -56,7 +56,8 @@
.show-card-art {
flex-shrink: 0;
width: 140px;
width: 256px;
height: 256px;
display: flex;
align-items: center;
justify-content: center;
@@ -73,7 +74,7 @@
.show-art-placeholder {
width: 100%;
aspect-ratio: 1;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
@@ -195,7 +196,6 @@
@include responsive(md) {
.show-card-art {
width: 110px;
padding: $spacing-sm;
}
+8
View File
@@ -6,6 +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';
interface ScheduleState {
loading: boolean;
@@ -29,4 +30,11 @@ export class ScheduleComponent {
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 {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://')) return url;
return `${getAppConfig().apiBaseUrl}/${url}`;
}
}
+24
View File
@@ -0,0 +1,24 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { getAppConfig } from './app-config.service';
/** Shared service for uploading image files via the admin API. */
@Injectable({
providedIn: 'root',
})
export class UploadService {
private http = inject(HttpClient);
private baseUrl = `${getAppConfig().apiBaseUrl}/api/upload/image`;
/** Upload an image file. Returns the relative URL (e.g. `uploads/abc123.png`). */
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, formData),
);
return response.url;
}
}
+47
View File
@@ -104,3 +104,50 @@
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
// ── URL field with upload button ──────────────────────────────
@mixin url-with-upload {
.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;
}
}
}
}