Adds the new shows admin screen and drives schedule from it
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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 ─────────────────────────────────────────
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">🎵</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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user