diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc index 985cda8..a02c9d8 100644 Binary files a/backend/app/__pycache__/main.cpython-312.pyc and b/backend/app/__pycache__/main.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/shows.cpython-312.pyc b/backend/app/api/__pycache__/shows.cpython-312.pyc index 6d07bfe..ee5d992 100644 Binary files a/backend/app/api/__pycache__/shows.cpython-312.pyc and b/backend/app/api/__pycache__/shows.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/upload.cpython-312.pyc b/backend/app/api/__pycache__/upload.cpython-312.pyc new file mode 100644 index 0000000..41cfc31 Binary files /dev/null and b/backend/app/api/__pycache__/upload.cpython-312.pyc differ diff --git a/backend/app/api/shows.py b/backend/app/api/shows.py index 0115bb8..159e0f4 100644 --- a/backend/app/api/shows.py +++ b/backend/app/api/shows.py @@ -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())) diff --git a/backend/app/api/upload.py b/backend/app/api/upload.py new file mode 100644 index 0000000..8050a55 --- /dev/null +++ b/backend/app/api/upload.py @@ -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}"} diff --git a/backend/app/main.py b/backend/app/main.py index 390a9d2..c042cf3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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": diff --git a/backend/uploads/310efff04f7541fd99fe9999ed3c9514.png b/backend/uploads/310efff04f7541fd99fe9999ed3c9514.png new file mode 100644 index 0000000..f26e887 Binary files /dev/null and b/backend/uploads/310efff04f7541fd99fe9999ed3c9514.png differ diff --git a/backend/uploads/505882024b134ecab7e18cd3b379ffb4.avif b/backend/uploads/505882024b134ecab7e18cd3b379ffb4.avif new file mode 100644 index 0000000..85d4c6c Binary files /dev/null and b/backend/uploads/505882024b134ecab7e18cd3b379ffb4.avif differ diff --git a/backend/uploads/6716edc12951477ab017fc4ca1631f58.jpeg b/backend/uploads/6716edc12951477ab017fc4ca1631f58.jpeg new file mode 100644 index 0000000..29e377c Binary files /dev/null and b/backend/uploads/6716edc12951477ab017fc4ca1631f58.jpeg differ diff --git a/backend/uploads/84e7deb652ed4680abfd5b24af6e1115.avif b/backend/uploads/84e7deb652ed4680abfd5b24af6e1115.avif new file mode 100644 index 0000000..22189ee Binary files /dev/null and b/backend/uploads/84e7deb652ed4680abfd5b24af6e1115.avif differ diff --git a/backend/uploads/90e029c6be204cb8b227b9362b7b6cd6.svg b/backend/uploads/90e029c6be204cb8b227b9362b7b6cd6.svg new file mode 100644 index 0000000..51236a5 --- /dev/null +++ b/backend/uploads/90e029c6be204cb8b227b9362b7b6cd6.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/uploads/ae5da74eca0345a09406c58a638c15c1.avif b/backend/uploads/ae5da74eca0345a09406c58a638c15c1.avif new file mode 100644 index 0000000..1293528 Binary files /dev/null and b/backend/uploads/ae5da74eca0345a09406c58a638c15c1.avif differ diff --git a/backend/uploads/b174d771f87343249ff90e8e310876cc.png b/backend/uploads/b174d771f87343249ff90e8e310876cc.png new file mode 100644 index 0000000..f26e887 Binary files /dev/null and b/backend/uploads/b174d771f87343249ff90e8e310876cc.png differ diff --git a/backend/uploads/bd117493900e4cc89d5bcc8ff0071355.svg b/backend/uploads/bd117493900e4cc89d5bcc8ff0071355.svg new file mode 100644 index 0000000..51236a5 --- /dev/null +++ b/backend/uploads/bd117493900e4cc89d5bcc8ff0071355.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/uploads/f4e5c71530a24463b665666a14ad9996.png b/backend/uploads/f4e5c71530a24463b665666a14ad9996.png new file mode 100644 index 0000000..f26e887 Binary files /dev/null and b/backend/uploads/f4e5c71530a24463b665666a14ad9996.png differ diff --git a/backend/uploads/ff1b086f19a248708ad8093559adbc6f.jpeg b/backend/uploads/ff1b086f19a248708ad8093559adbc6f.jpeg new file mode 100644 index 0000000..d6cc8aa Binary files /dev/null and b/backend/uploads/ff1b086f19a248708ad8093559adbc6f.jpeg differ diff --git a/src/app/admin/admin-show-form.component.html b/src/app/admin/admin-show-form.component.html index 2da8825..87656bf 100644 --- a/src/app/admin/admin-show-form.component.html +++ b/src/app/admin/admin-show-form.component.html @@ -8,6 +8,9 @@ @if (error) { } + @if (success) { + + }
@@ -35,12 +38,30 @@
- +
+ + +
+ @if (show_art_url) { + + }
- +
+ + +
+ @if (hero_image_url) { + + }
diff --git a/src/app/admin/admin-show-form.component.scss b/src/app/admin/admin-show-form.component.scss index c234e53..6a13db1 100644 --- a/src/app/admin/admin-show-form.component.scss +++ b/src/app/admin/admin-show-form.component.scss @@ -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 ───────────────────────────────────────── diff --git a/src/app/admin/admin-show-form.component.ts b/src/app/admin/admin-show-form.component.ts index 5187e12..2ef376d 100644 --- a/src/app/admin/admin-show-form.component.ts +++ b/src/app/admin/admin-show-form.component.ts @@ -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(); @@ -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 { + 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 { this.submitted = true; @@ -150,4 +192,4 @@ export class AdminShowFormComponent implements OnChanges { this.loading = false; } } -} \ No newline at end of file +} diff --git a/src/app/admin/admin-station-form.component.scss b/src/app/admin/admin-station-form.component.scss index fedc857..d5682f6 100644 --- a/src/app/admin/admin-station-form.component.scss +++ b/src/app/admin/admin-station-form.component.scss @@ -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 { diff --git a/src/app/admin/admin-team-form.component.html b/src/app/admin/admin-team-form.component.html index 2da2fbd..845b698 100644 --- a/src/app/admin/admin-team-form.component.html +++ b/src/app/admin/admin-team-form.component.html @@ -8,6 +8,9 @@ @if (error) { } + @if (success) { + + }
@@ -34,7 +37,16 @@
- +
+ + +
+ @if (photo_url) { + + }
diff --git a/src/app/admin/admin-team-form.component.scss b/src/app/admin/admin-team-form.component.scss index 6d7afb2..0e3b5c0 100644 --- a/src/app/admin/admin-team-form.component.scss +++ b/src/app/admin/admin-team-form.component.scss @@ -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 { diff --git a/src/app/admin/admin-team-form.component.ts b/src/app/admin/admin-team-form.component.ts index e3e389d..f378766 100644 --- a/src/app/admin/admin-team-form.component.ts +++ b/src/app/admin/admin-team-form.component.ts @@ -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(); @@ -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 { + 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 { this.submitted = true; if (!this.name || !this.title) { diff --git a/src/app/schedule/schedule.component.html b/src/app/schedule/schedule.component.html index 360b54d..caf4fc8 100644 --- a/src/app/schedule/schedule.component.html +++ b/src/app/schedule/schedule.component.html @@ -21,7 +21,7 @@
@if (show.show_art_url) { - + } @else {
@@ -30,7 +30,7 @@
-
+

{{ show.title }}

diff --git a/src/app/schedule/schedule.component.scss b/src/app/schedule/schedule.component.scss index 211ee51..80e0473 100644 --- a/src/app/schedule/schedule.component.scss +++ b/src/app/schedule/schedule.component.scss @@ -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; } diff --git a/src/app/schedule/schedule.component.ts b/src/app/schedule/schedule.component.ts index fad23b5..bd0c0f5 100644 --- a/src/app/schedule/schedule.component.ts +++ b/src/app/schedule/schedule.component.ts @@ -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}`; + } } diff --git a/src/app/services/upload.service.ts b/src/app/services/upload.service.ts new file mode 100644 index 0000000..bffee0c --- /dev/null +++ b/src/app/services/upload.service.ts @@ -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 { + const formData = new FormData(); + formData.append('file', file); + const response = await firstValueFrom( + this.http.post<{ url: string }>(this.baseUrl, formData), + ); + return response.url; + } +} diff --git a/src/styles/_mixins.scss b/src/styles/_mixins.scss index 059792f..504b57b 100644 --- a/src/styles/_mixins.scss +++ b/src/styles/_mixins.scss @@ -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; + } + } + } +}