Adds the new shows admin screen and drives schedule from it
@@ -89,6 +89,7 @@ async def update_show(
|
|||||||
# Replace all schedule slots
|
# Replace all schedule slots
|
||||||
for sched in show.schedules:
|
for sched in show.schedules:
|
||||||
await session.delete(sched)
|
await session.delete(sched)
|
||||||
|
await session.flush() # execute deletes before inserts to avoid UNIQUE conflict
|
||||||
for slot in payload.schedules:
|
for slot in payload.schedules:
|
||||||
session.add(ShowSchedule(show_id=show.id, **slot.model_dump()))
|
session.add(ShowSchedule(show_id=show.id, **slot.model_dump()))
|
||||||
|
|
||||||
|
|||||||
@@ -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}"}
|
||||||
@@ -10,7 +10,7 @@ from app.config import settings
|
|||||||
from app.database import async_session, engine
|
from app.database import async_session, engine
|
||||||
from app.models import Base, Program, Show, StationConfig, HistoryEntry, TeamMember, CommunityHighlight
|
from app.models import Base, Program, Show, StationConfig, HistoryEntry, TeamMember, CommunityHighlight
|
||||||
from app.user_models import User
|
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):
|
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(team.router, prefix="/api/team", tags=["team"])
|
||||||
app.include_router(community.router, prefix="/api/community", tags=["community"])
|
app.include_router(community.router, prefix="/api/community", tags=["community"])
|
||||||
app.include_router(shows.router, prefix="/api/shows", tags=["shows"])
|
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)
|
# Serve uploaded files (dev only — Nginx handles this in production)
|
||||||
if settings.ENV != "production":
|
if settings.ENV != "production":
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 538 KiB |
|
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 |
|
After Width: | Height: | Size: 33 KiB |
|
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 |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 591 KiB |
@@ -8,6 +8,9 @@
|
|||||||
@if (error) {
|
@if (error) {
|
||||||
<div class="form-error" role="alert">{{ error }}</div>
|
<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">
|
<form (ngSubmit)="onSubmit()" class="admin-form" #form="ngForm">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -35,12 +38,30 @@
|
|||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="show_art_url">Art URL</label>
|
<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>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="hero_image_url">Hero Image URL</label>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-form {
|
.admin-form {
|
||||||
|
@include url-with-upload;
|
||||||
|
|
||||||
.form-row {
|
.form-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
@@ -67,7 +69,7 @@
|
|||||||
.form-group {
|
.form-group {
|
||||||
margin-bottom: $spacing-md;
|
margin-bottom: $spacing-md;
|
||||||
|
|
||||||
label {
|
label:not(.btn-upload) {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
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 ─────────────────────────────────────────
|
// ── Schedule slots ─────────────────────────────────────────
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { firstValueFrom } from 'rxjs';
|
|||||||
|
|
||||||
import { Show } from '../interfaces/show';
|
import { Show } from '../interfaces/show';
|
||||||
import { ShowService, ShowScheduleCreate, ShowCreatePayload, ShowUpdatePayload } from '../services/show.service';
|
import { ShowService, ShowScheduleCreate, ShowCreatePayload, ShowUpdatePayload } from '../services/show.service';
|
||||||
|
import { UploadService } from '../services/upload.service';
|
||||||
|
import { getAppConfig } from '../services/app-config.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin-show-form',
|
selector: 'app-admin-show-form',
|
||||||
@@ -15,6 +17,7 @@ import { ShowService, ShowScheduleCreate, ShowCreatePayload, ShowUpdatePayload }
|
|||||||
})
|
})
|
||||||
export class AdminShowFormComponent implements OnChanges {
|
export class AdminShowFormComponent implements OnChanges {
|
||||||
private showService = inject(ShowService);
|
private showService = inject(ShowService);
|
||||||
|
private uploadService = inject(UploadService);
|
||||||
|
|
||||||
@Input() editItem: Show | null = null;
|
@Input() editItem: Show | null = null;
|
||||||
@Output() saved = new EventEmitter<void>();
|
@Output() saved = new EventEmitter<void>();
|
||||||
@@ -43,7 +46,9 @@ export class AdminShowFormComponent implements OnChanges {
|
|||||||
];
|
];
|
||||||
|
|
||||||
loading = false;
|
loading = false;
|
||||||
|
uploading = false;
|
||||||
error = '';
|
error = '';
|
||||||
|
success = '';
|
||||||
submitted = false;
|
submitted = false;
|
||||||
|
|
||||||
ngOnChanges(changes: SimpleChanges): void {
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
@@ -85,6 +90,7 @@ export class AdminShowFormComponent implements OnChanges {
|
|||||||
this.display_order = 0;
|
this.display_order = 0;
|
||||||
this.schedules = [{ day_of_week: 1, time: '' }];
|
this.schedules = [{ day_of_week: 1, time: '' }];
|
||||||
this.error = '';
|
this.error = '';
|
||||||
|
this.success = '';
|
||||||
this.submitted = false;
|
this.submitted = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +109,42 @@ export class AdminShowFormComponent implements OnChanges {
|
|||||||
this.schedules.splice(index, 1);
|
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> {
|
async onSubmit(): Promise<void> {
|
||||||
this.submitted = true;
|
this.submitted = true;
|
||||||
|
|
||||||
|
|||||||
@@ -119,48 +119,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.url-with-upload {
|
@include 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-actions {
|
.form-actions {
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
@if (error) {
|
@if (error) {
|
||||||
<div class="form-error" role="alert">{{ error }}</div>
|
<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 (ngSubmit)="onSubmit()" class="admin-form">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
@@ -34,7 +37,16 @@
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="photo_url">Photo URL (optional)</label>
|
<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>
|
||||||
|
|
||||||
<div class="form-group checkbox-group">
|
<div class="form-group checkbox-group">
|
||||||
|
|||||||
@@ -58,6 +58,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-form {
|
.admin-form {
|
||||||
|
@include url-with-upload;
|
||||||
|
|
||||||
.form-row {
|
.form-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
@@ -67,7 +69,7 @@
|
|||||||
.form-group {
|
.form-group {
|
||||||
margin-bottom: $spacing-md;
|
margin-bottom: $spacing-md;
|
||||||
|
|
||||||
label {
|
label:not(.btn-upload) {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
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 {
|
.form-actions {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { firstValueFrom } from 'rxjs';
|
|||||||
|
|
||||||
import { TeamMember } from '../interfaces/team-member';
|
import { TeamMember } from '../interfaces/team-member';
|
||||||
import { TeamMemberService } from '../services/team-member.service';
|
import { TeamMemberService } from '../services/team-member.service';
|
||||||
|
import { UploadService } from '../services/upload.service';
|
||||||
|
import { getAppConfig } from '../services/app-config.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin-team-form',
|
selector: 'app-admin-team-form',
|
||||||
@@ -15,6 +17,7 @@ import { TeamMemberService } from '../services/team-member.service';
|
|||||||
})
|
})
|
||||||
export class AdminTeamFormComponent implements OnChanges {
|
export class AdminTeamFormComponent implements OnChanges {
|
||||||
private teamMemberService = inject(TeamMemberService);
|
private teamMemberService = inject(TeamMemberService);
|
||||||
|
private uploadService = inject(UploadService);
|
||||||
|
|
||||||
@Input() editItem: TeamMember | null = null;
|
@Input() editItem: TeamMember | null = null;
|
||||||
@Output() saved = new EventEmitter<void>();
|
@Output() saved = new EventEmitter<void>();
|
||||||
@@ -29,7 +32,9 @@ export class AdminTeamFormComponent implements OnChanges {
|
|||||||
active = true;
|
active = true;
|
||||||
|
|
||||||
loading = false;
|
loading = false;
|
||||||
|
uploading = false;
|
||||||
error = '';
|
error = '';
|
||||||
|
success = '';
|
||||||
submitted = false;
|
submitted = false;
|
||||||
|
|
||||||
ngOnChanges(changes: SimpleChanges): void {
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
@@ -64,6 +69,7 @@ export class AdminTeamFormComponent implements OnChanges {
|
|||||||
this.display_order = 0;
|
this.display_order = 0;
|
||||||
this.active = true;
|
this.active = true;
|
||||||
this.error = '';
|
this.error = '';
|
||||||
|
this.success = '';
|
||||||
this.submitted = false;
|
this.submitted = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +78,42 @@ export class AdminTeamFormComponent implements OnChanges {
|
|||||||
this.reset();
|
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> {
|
async onSubmit(): Promise<void> {
|
||||||
this.submitted = true;
|
this.submitted = true;
|
||||||
if (!this.name || !this.title) {
|
if (!this.name || !this.title) {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<!-- Left: Show art badge -->
|
<!-- Left: Show art badge -->
|
||||||
<div class="show-card-art">
|
<div class="show-card-art">
|
||||||
@if (show.show_art_url) {
|
@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 {
|
} @else {
|
||||||
<div class="show-art-placeholder">
|
<div class="show-art-placeholder">
|
||||||
<span aria-hidden="true">🎵</span>
|
<span aria-hidden="true">🎵</span>
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right: Hero-backed info area -->
|
<!-- 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-overlay"></div>
|
||||||
<div class="show-card-info">
|
<div class="show-card-info">
|
||||||
<h3 class="show-title">{{ show.title }}</h3>
|
<h3 class="show-title">{{ show.title }}</h3>
|
||||||
|
|||||||
@@ -56,7 +56,8 @@
|
|||||||
|
|
||||||
.show-card-art {
|
.show-card-art {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 140px;
|
width: 256px;
|
||||||
|
height: 256px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -73,7 +74,7 @@
|
|||||||
|
|
||||||
.show-art-placeholder {
|
.show-art-placeholder {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 1;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -195,7 +196,6 @@
|
|||||||
|
|
||||||
@include responsive(md) {
|
@include responsive(md) {
|
||||||
.show-card-art {
|
.show-card-art {
|
||||||
width: 110px;
|
|
||||||
padding: $spacing-sm;
|
padding: $spacing-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ShowService } from '../services/show.service';
|
|||||||
import { Show } from '../interfaces/show';
|
import { Show } from '../interfaces/show';
|
||||||
import { StationConfigService } from '../services/station-config.service';
|
import { StationConfigService } from '../services/station-config.service';
|
||||||
import { SortSchedulesPipe } from '../pipes/sort-schedules.pipe';
|
import { SortSchedulesPipe } from '../pipes/sort-schedules.pipe';
|
||||||
|
import { getAppConfig } from '../services/app-config.service';
|
||||||
|
|
||||||
interface ScheduleState {
|
interface ScheduleState {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -29,4 +30,11 @@ export class ScheduleComponent {
|
|||||||
map((shows) => ({ loading: false, shows })),
|
map((shows) => ({ loading: false, shows })),
|
||||||
startWith({ loading: true, 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); }
|
0%, 100% { transform: translateY(0); }
|
||||||
50% { transform: translateY(-8px); }
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||