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
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -89,6 +89,7 @@ async def update_show(
# Replace all schedule slots
for sched in show.schedules:
await session.delete(sched)
await session.flush() # execute deletes before inserts to avoid UNIQUE conflict
for slot in payload.schedules:
session.add(ShowSchedule(show_id=show.id, **slot.model_dump()))
+59
View File
@@ -0,0 +1,59 @@
"""Shared image upload router — admin-only, reused across all forms."""
import os
import uuid
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from app.auth import get_current_admin_user
from app.user_models import User
router = APIRouter()
UPLOAD_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads",
)
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB
@router.post("/image")
async def upload_image(
file: UploadFile,
current_user: User = Depends(get_current_admin_user),
):
"""Upload an image file. Admin only.
Saves the file to the uploads/ directory with a UUID filename.
Returns the relative URL path.
"""
# Validate MIME type
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Only image files are allowed",
)
# Read and validate size
content = await file.read()
if len(content) > MAX_FILE_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File size exceeds 5 MB limit",
)
# Ensure upload directory exists
os.makedirs(UPLOAD_DIR, exist_ok=True)
# Generate unique filename preserving extension
ext = (
os.path.splitext(file.filename or "upload")[1]
if file.filename
else ".bin"
)
filename = f"{uuid.uuid4().hex}{ext}"
filepath = os.path.join(UPLOAD_DIR, filename)
with open(filepath, "wb") as f:
f.write(content)
return {"url": f"uploads/{filename}"}
+2 -1
View File
@@ -10,7 +10,7 @@ from app.config import settings
from app.database import async_session, engine
from app.models import Base, Program, Show, StationConfig, HistoryEntry, TeamMember, CommunityHighlight
from app.user_models import User
from app.api import programs, events, tiers, auth, station_config, history, team, community, shows
from app.api import programs, events, tiers, auth, station_config, history, team, community, shows, upload
async def _ensure_station_config(session):
@@ -100,6 +100,7 @@ app.include_router(history.router, prefix="/api/history", tags=["history"])
app.include_router(team.router, prefix="/api/team", tags=["team"])
app.include_router(community.router, prefix="/api/community", tags=["community"])
app.include_router(shows.router, prefix="/api/shows", tags=["shows"])
app.include_router(upload.router, prefix="/api/upload", tags=["upload"])
# Serve uploaded files (dev only — Nginx handles this in production)
if settings.ENV != "production":
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

@@ -0,0 +1,116 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 500" width="100%" height="100%">
<defs>
<!-- Background Gradient: Dawn/Morning Sky -->
<linearGradient id="skyGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#0d1b2a"/>
<stop offset="40%" stop-color="#1b263b"/>
<stop offset="75%" stop-color="#415a77"/>
<stop offset="100%" stop-color="#a3b18a"/>
</linearGradient>
<!-- Sun Glow -->
<radialGradient id="sunGlow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#ffe5ec" stop-opacity="1"/>
<stop offset="30%" stop-color="#ffb703" stop-opacity="0.5"/>
<stop offset="100%" stop-color="#ffb703" stop-opacity="0"/>
</radialGradient>
<!-- Mountain Gradients (Back to Front) -->
<linearGradient id="mountainBack" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#485a6a"/>
<stop offset="100%" stop-color="#2a3439"/>
</linearGradient>
<linearGradient id="mountainMid" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#374a43"/>
<stop offset="100%" stop-color="#1c2826"/>
</linearGradient>
<linearGradient id="mountainFront" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#283618"/>
<stop offset="100%" stop-color="#060c05"/>
</linearGradient>
<!-- Mist Gradients -->
<linearGradient id="mistGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="25%" stop-color="#e0e1dd" stop-opacity="0.4"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.6"/>
<stop offset="75%" stop-color="#e0e1dd" stop-opacity="0.4"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<linearGradient id="mistSoft" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.5"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<!-- Soundwave Glow -->
<linearGradient id="soundGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#ffb703" stop-opacity="0.2"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.8"/>
<stop offset="100%" stop-color="#ffb703" stop-opacity="0.2"/>
</linearGradient>
</defs>
<!-- Background Sky -->
<rect width="800" height="500" fill="url(#skyGrad)"/>
<!-- Morning Sun -->
<circle cx="400" cy="220" r="180" fill="url(#sunGlow)"/>
<circle cx="400" cy="220" r="45" fill="#fffef7" opacity="0.9"/>
<!-- Audio Waveform / Ambient Soundscape (Distant Sky Layer) -->
<path d="M 0 240 Q 100 180, 200 230 T 400 210 T 600 250 T 800 220 L 800 500 L 0 500 Z" fill="#ffffff" opacity="0.03"/>
<path d="M 0 210 Q 150 260, 300 200 T 600 220 T 800 190 L 800 500 L 0 500 Z" fill="#ffb703" opacity="0.03"/>
<!-- Distant Mountain Range -->
<polygon points="-50,380 150,180 380,320 600,140 850,350 850,500 -50,500" fill="url(#mountainBack)"/>
<!-- Deep Mist Layer 1 -->
<path d="M -50 300 Q 200 250, 450 320 T 850 280 L 850 420 L -50 420 Z" fill="url(#mistGrad)"/>
<!-- Mid-ground Mountain Range -->
<polygon points="-50,440 80,260 300,380 520,240 720,360 850,290 850,500 -50,500" fill="url(#mountainMid)"/>
<!-- Audio Waveform Integration (Wind/Melody lines cutting through mountains) -->
<path d="M -50 310 C 150 240, 250 400, 450 300 C 650 200, 700 350, 850 310" fill="none" stroke="url(#soundGrad)" stroke-width="2" opacity="0.6"/>
<path d="M -50 325 C 100 265, 280 370, 480 280 C 620 210, 740 330, 850 295" fill="none" stroke="url(#soundGrad)" stroke-width="1" stroke-dasharray="4,4" opacity="0.4"/>
<!-- Mid-ground Mist Layer 2 -->
<path d="M -50 340 Q 150 380, 400 320 T 850 350 L 850 500 -50 500" fill="url(#mistGrad)"/>
<ellipse cx="450" cy="340" rx="250" ry="30" fill="url(#mistSoft)"/>
<ellipse cx="150" cy="360" rx="180" ry="20" fill="url(#mistSoft)"/>
<!-- Foreground Foreground Ridge (Dark Pine Silhouettes) -->
<polygon points="-50,460 180,340 420,440 680,310 850,390 850,500 -50,500" fill="url(#mountainFront)"/>
<!-- Stylized Pine Trees (Trangles on Ridge) -->
<polygon points="165,350 180,330 195,350" fill="#060c05"/>
<polygon points="172,345 180,320 188,345" fill="#060c05"/>
<polygon points="110,390 120,370 130,390" fill="#0d1f10"/>
<polygon points="140,375 152,355 164,375" fill="#0d1f10"/>
<polygon points="665,320 680,295 695,320" fill="#060c05"/>
<polygon points="672,305 680,285 688,305" fill="#060c05"/>
<polygon points="635,340 648,320 660,340" fill="#0d1f10"/>
<!-- Foreground Low Mist Blanket -->
<path d="M -50 420 Q 200 390, 400 440 T 850 410 L 850 500 L -50 500 Z" fill="url(#mistGrad)" opacity="0.7"/>
<!-- Birds Taking Flight (Representing Birdsong / Awakening) -->
<g fill="#1b263b" opacity="0.85">
<!-- Bird 1 -->
<path d="M 280 150 Q 288 142, 295 148 Q 302 142, 310 150 Q 301 153, 295 149 Q 289 153, 280 150 Z" />
<!-- Bird 2 -->
<path d="M 315 135 Q 321 129, 327 133 Q 333 129, 339 135 Q 332 137, 327 134 Q 322 137, 315 135 Z" transform="rotate(-5, 327, 134) scale(0.8)"/>
<!-- Bird 3 -->
<path d="M 260 165 Q 265 160, 271 163 Q 277 160, 282 165 Q 276 167, 271 164 Q 266 167, 260 165 Z" transform="rotate(5, 271, 164) scale(0.7)"/>
<!-- Distant Birds -->
<path d="M 510 105 Q 514 100, 519 102 Q 524 100, 528 105 Q 523 106, 519 104 Q 515 106, 510 105 Z" opacity="0.5" scale="0.5"/>
<path d="M 535 98 Q 538 94, 542 96 Q 546 94, 550 98 Q 546 99, 542 97 Q 538 99, 535 98 Z" opacity="0.5" scale="0.4"/>
</g>
<!-- Elegant Radio/Audio Frame Overlay -->
<rect x="20" y="20" width="760" height="460" fill="none" stroke="#ffffff" stroke-width="1.5" stroke-opacity="0.2"/>
<rect x="25" y="25" width="750" height="450" fill="none" stroke="#ffffff" stroke-width="0.5" stroke-opacity="0.1"/>
</svg>

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

@@ -0,0 +1,116 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 500" width="100%" height="100%">
<defs>
<!-- Background Gradient: Dawn/Morning Sky -->
<linearGradient id="skyGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#0d1b2a"/>
<stop offset="40%" stop-color="#1b263b"/>
<stop offset="75%" stop-color="#415a77"/>
<stop offset="100%" stop-color="#a3b18a"/>
</linearGradient>
<!-- Sun Glow -->
<radialGradient id="sunGlow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#ffe5ec" stop-opacity="1"/>
<stop offset="30%" stop-color="#ffb703" stop-opacity="0.5"/>
<stop offset="100%" stop-color="#ffb703" stop-opacity="0"/>
</radialGradient>
<!-- Mountain Gradients (Back to Front) -->
<linearGradient id="mountainBack" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#485a6a"/>
<stop offset="100%" stop-color="#2a3439"/>
</linearGradient>
<linearGradient id="mountainMid" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#374a43"/>
<stop offset="100%" stop-color="#1c2826"/>
</linearGradient>
<linearGradient id="mountainFront" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#283618"/>
<stop offset="100%" stop-color="#060c05"/>
</linearGradient>
<!-- Mist Gradients -->
<linearGradient id="mistGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="25%" stop-color="#e0e1dd" stop-opacity="0.4"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.6"/>
<stop offset="75%" stop-color="#e0e1dd" stop-opacity="0.4"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<linearGradient id="mistSoft" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.5"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
<!-- Soundwave Glow -->
<linearGradient id="soundGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#ffb703" stop-opacity="0.2"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.8"/>
<stop offset="100%" stop-color="#ffb703" stop-opacity="0.2"/>
</linearGradient>
</defs>
<!-- Background Sky -->
<rect width="800" height="500" fill="url(#skyGrad)"/>
<!-- Morning Sun -->
<circle cx="400" cy="220" r="180" fill="url(#sunGlow)"/>
<circle cx="400" cy="220" r="45" fill="#fffef7" opacity="0.9"/>
<!-- Audio Waveform / Ambient Soundscape (Distant Sky Layer) -->
<path d="M 0 240 Q 100 180, 200 230 T 400 210 T 600 250 T 800 220 L 800 500 L 0 500 Z" fill="#ffffff" opacity="0.03"/>
<path d="M 0 210 Q 150 260, 300 200 T 600 220 T 800 190 L 800 500 L 0 500 Z" fill="#ffb703" opacity="0.03"/>
<!-- Distant Mountain Range -->
<polygon points="-50,380 150,180 380,320 600,140 850,350 850,500 -50,500" fill="url(#mountainBack)"/>
<!-- Deep Mist Layer 1 -->
<path d="M -50 300 Q 200 250, 450 320 T 850 280 L 850 420 L -50 420 Z" fill="url(#mistGrad)"/>
<!-- Mid-ground Mountain Range -->
<polygon points="-50,440 80,260 300,380 520,240 720,360 850,290 850,500 -50,500" fill="url(#mountainMid)"/>
<!-- Audio Waveform Integration (Wind/Melody lines cutting through mountains) -->
<path d="M -50 310 C 150 240, 250 400, 450 300 C 650 200, 700 350, 850 310" fill="none" stroke="url(#soundGrad)" stroke-width="2" opacity="0.6"/>
<path d="M -50 325 C 100 265, 280 370, 480 280 C 620 210, 740 330, 850 295" fill="none" stroke="url(#soundGrad)" stroke-width="1" stroke-dasharray="4,4" opacity="0.4"/>
<!-- Mid-ground Mist Layer 2 -->
<path d="M -50 340 Q 150 380, 400 320 T 850 350 L 850 500 -50 500" fill="url(#mistGrad)"/>
<ellipse cx="450" cy="340" rx="250" ry="30" fill="url(#mistSoft)"/>
<ellipse cx="150" cy="360" rx="180" ry="20" fill="url(#mistSoft)"/>
<!-- Foreground Foreground Ridge (Dark Pine Silhouettes) -->
<polygon points="-50,460 180,340 420,440 680,310 850,390 850,500 -50,500" fill="url(#mountainFront)"/>
<!-- Stylized Pine Trees (Trangles on Ridge) -->
<polygon points="165,350 180,330 195,350" fill="#060c05"/>
<polygon points="172,345 180,320 188,345" fill="#060c05"/>
<polygon points="110,390 120,370 130,390" fill="#0d1f10"/>
<polygon points="140,375 152,355 164,375" fill="#0d1f10"/>
<polygon points="665,320 680,295 695,320" fill="#060c05"/>
<polygon points="672,305 680,285 688,305" fill="#060c05"/>
<polygon points="635,340 648,320 660,340" fill="#0d1f10"/>
<!-- Foreground Low Mist Blanket -->
<path d="M -50 420 Q 200 390, 400 440 T 850 410 L 850 500 L -50 500 Z" fill="url(#mistGrad)" opacity="0.7"/>
<!-- Birds Taking Flight (Representing Birdsong / Awakening) -->
<g fill="#1b263b" opacity="0.85">
<!-- Bird 1 -->
<path d="M 280 150 Q 288 142, 295 148 Q 302 142, 310 150 Q 301 153, 295 149 Q 289 153, 280 150 Z" />
<!-- Bird 2 -->
<path d="M 315 135 Q 321 129, 327 133 Q 333 129, 339 135 Q 332 137, 327 134 Q 322 137, 315 135 Z" transform="rotate(-5, 327, 134) scale(0.8)"/>
<!-- Bird 3 -->
<path d="M 260 165 Q 265 160, 271 163 Q 277 160, 282 165 Q 276 167, 271 164 Q 266 167, 260 165 Z" transform="rotate(5, 271, 164) scale(0.7)"/>
<!-- Distant Birds -->
<path d="M 510 105 Q 514 100, 519 102 Q 524 100, 528 105 Q 523 106, 519 104 Q 515 106, 510 105 Z" opacity="0.5" scale="0.5"/>
<path d="M 535 98 Q 538 94, 542 96 Q 546 94, 550 98 Q 546 99, 542 97 Q 538 99, 535 98 Z" opacity="0.5" scale="0.4"/>
</g>
<!-- Elegant Radio/Audio Frame Overlay -->
<rect x="20" y="20" width="760" height="460" fill="none" stroke="#ffffff" stroke-width="1.5" stroke-opacity="0.2"/>
<rect x="25" y="25" width="750" height="450" fill="none" stroke="#ffffff" stroke-width="0.5" stroke-opacity="0.1"/>
</svg>

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 KiB

@@ -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>
<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>
<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 ─────────────────────────────────────────
@@ -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;
@@ -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;
}
}
}
}