Move card action buttons to top-right corner and add Mark Played to alternates

Reposition approve/reject/played buttons as absolute top-right overlay
on each card. Add Mark Played buttons to alternate match cards with a
new play-alt API endpoint, and right-align all alternate action buttons.

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-28 12:53:33 -04:00
parent 7ebd22568b
commit 3372ce77fa
2 changed files with 66 additions and 5 deletions

View File

@@ -338,6 +338,7 @@
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
transition: all 0.2s ease; transition: all 0.2s ease;
position: relative;
} }
.request-card:hover { border-color: #3a3a3a; } .request-card:hover { border-color: #3a3a3a; }
@@ -422,7 +423,9 @@
.card-actions { .card-actions {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
margin-top: 0.5rem; position: absolute;
top: 0.625rem;
right: 0.625rem;
} }
.btn { .btn {
@@ -541,6 +544,14 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0; min-width: 0;
flex: 1;
}
.alt-actions {
display: flex;
gap: 0.375rem;
flex-shrink: 0;
margin-left: auto;
} }
.alt-title { .alt-title {

View File

@@ -32,26 +32,35 @@ def _render_alternates(request_id: int, alternates_json: str, status: str) -> st
esc = html.escape esc = html.escape
show_approve = status == "pending" show_approve = status == "pending"
show_play = status in ("pending", "approved")
items = [] items = []
for idx, alt in enumerate(alts): for idx, alt in enumerate(alts):
artwork = esc(alt.get("artwork_url", "").replace("600x600", "100x100")) artwork = esc(alt.get("artwork_url", "").replace("600x600", "100x100"))
title = esc(alt.get("title", "")) title = esc(alt.get("title", ""))
artist = esc(alt.get("artist", "")) artist = esc(alt.get("artist", ""))
url = esc(alt.get("apple_music_url", "")) url = esc(alt.get("apple_music_url", ""))
approve_btn = "" buttons = []
if show_approve: if show_approve:
approve_btn = ( buttons.append(
f'<button hx-post="/api/requests/{request_id}/approve-alt/{idx}"' f'<button hx-post="/api/requests/{request_id}/approve-alt/{idx}"'
f' hx-swap="outerHTML" hx-target="#request-{request_id}"' f' hx-swap="outerHTML" hx-target="#request-{request_id}"'
f' class="btn btn-approve btn-sm admin-only"' f' class="btn btn-approve btn-sm admin-only"'
f' onclick="event.stopPropagation();">Approve</button>' f' onclick="event.stopPropagation();">Approve</button>'
) )
if show_play:
buttons.append(
f'<button hx-post="/api/requests/{request_id}/play-alt/{idx}"'
f' hx-swap="outerHTML" hx-target="#request-{request_id}"'
f' class="btn btn-played btn-sm admin-only"'
f' onclick="event.stopPropagation();">Mark Played</button>'
)
actions_html = f'<div class="alt-actions admin-only">{"".join(buttons)}</div>' if buttons else ""
items.append( items.append(
f'<div class="alt-card" onclick="event.stopPropagation(); window.open(\'{url}\',\'_blank\')">' f'<div class="alt-card" onclick="event.stopPropagation(); window.open(\'{url}\',\'_blank\')">'
f'<img class="alt-art" src="{artwork}" alt="" loading="lazy" />' f'<img class="alt-art" src="{artwork}" alt="" loading="lazy" />'
f'<div class="alt-info"><span class="alt-title">{title}</span>' f'<div class="alt-info"><span class="alt-title">{title}</span>'
f'<span class="alt-artist">{artist}</span></div>' f'<span class="alt-artist">{artist}</span></div>'
f'{approve_btn}</div>' f'{actions_html}</div>'
) )
return ( return (
'<details class="alternates" onclick="event.stopPropagation();">' '<details class="alternates" onclick="event.stopPropagation();">'
@@ -104,8 +113,8 @@ def render_request_card(req: SongRequestModel) -> str:
<div class="card-album">{esc(req.album)}</div> <div class="card-album">{esc(req.album)}</div>
<div class="card-meta">Requested by {esc(req.requester_nick)} in {esc(req.channel)} at {created}</div> <div class="card-meta">Requested by {esc(req.requester_nick)} in {esc(req.channel)} at {created}</div>
<span class="card-status-badge">{esc(req.status)}</span> <span class="card-status-badge">{esc(req.status)}</span>
{actions}
</div> </div>
{actions}
</div> </div>
{alternates_html} {alternates_html}
</div>""" </div>"""
@@ -181,6 +190,7 @@ class WebServer:
app.router.add_get("/api/channels", self._handle_channels) app.router.add_get("/api/channels", self._handle_channels)
app.router.add_get("/api/requests", self._handle_api_get) app.router.add_get("/api/requests", self._handle_api_get)
app.router.add_post("/api/requests/{request_id}/approve-alt/{alt_idx}", self._handle_approve_alt) app.router.add_post("/api/requests/{request_id}/approve-alt/{alt_idx}", self._handle_approve_alt)
app.router.add_post("/api/requests/{request_id}/play-alt/{alt_idx}", self._handle_play_alt)
app.router.add_post("/api/requests/{request_id}/{action}", self._handle_api_action) app.router.add_post("/api/requests/{request_id}/{action}", self._handle_api_action)
app.router.add_get("/api/export/markdown", self._handle_export_markdown) app.router.add_get("/api/export/markdown", self._handle_export_markdown)
app.router.add_get("/api/status", self._handle_get_status) app.router.add_get("/api/status", self._handle_get_status)
@@ -446,6 +456,46 @@ class WebServer:
return web.Response(text=card_html, content_type="text/html") return web.Response(text=card_html, content_type="text/html")
async def _handle_play_alt(self, request: web.Request) -> web.Response:
denied = self._require_auth(request)
if denied:
return denied
try:
request_id = int(request.match_info["request_id"])
alt_idx = int(request.match_info["alt_idx"])
except (ValueError, KeyError):
return web.Response(text="Bad request", status=400)
req = self._store.get(request_id)
if not req:
return web.Response(text="Request not found", status=404)
try:
alts = json.loads(req.alternates_json) if req.alternates_json else []
except (json.JSONDecodeError, TypeError):
alts = []
if alt_idx < 0 or alt_idx >= len(alts):
return web.Response(text="Invalid alternate index", status=400)
alt = alts[alt_idx]
self._store.swap_alternate(request_id, alt)
req = self._store.update_status(request_id, "played")
if not req:
return web.Response(text="Request not found", status=404)
card_html = render_request_card(req)
await self._broadcast("request-update", card_html)
if self._on_status_change:
try:
self._on_status_change(req)
except Exception:
log.exception("SongRequest: on_status_change callback failed")
return web.Response(text=card_html, content_type="text/html")
async def _handle_export_markdown(self, request: web.Request) -> web.Response: async def _handle_export_markdown(self, request: web.Request) -> web.Response:
denied = self._require_auth(request) denied = self._require_auth(request)
if denied: if denied: