feat: all-video media row, clickable media selector, download button
Build & Deploy / build-and-deploy (push) Failing after 44s

- Backend: Add GET /api/games/{id}/download endpoint (ZIP streaming)
- Frontend: All videos now shown in media row alongside screenshots
- Click any media thumbnail to load it into the media container
- Videos show YouTube thumbnail, screenshots show image preview
- Active thumbnail has green border highlight
- Download button between Edit and Delete triggers ZIP download
- Early return: if no media, the entire section is hidden
This commit is contained in:
David Alvarez
2026-05-26 16:47:17 +02:00
parent 0912e43d99
commit 446a9d61b8
3 changed files with 159 additions and 42 deletions
+5
View File
@@ -84,6 +84,11 @@ export async function scrapeIGDB(gameId, igdbId) {
return res.json();
}
/** Download a game's ZIP bundle */
export function downloadGame(id) {
window.open(`${BASE}/api/games/${id}/download`, '_blank');
}
/** Check IGDB status */
export async function igdbStatus() {
const res = await fetch(`${BASE}/api/igdb/status`);
+99 -42
View File
@@ -1,5 +1,5 @@
<script>
import { getGame, deleteGame, updateGame, searchIGDB, scrapeIGDB, bundleUrl, artworkUrl } from "../lib/api.js";
import { getGame, deleteGame, updateGame, searchIGDB, scrapeIGDB, downloadGame, bundleUrl, artworkUrl } from "../lib/api.js";
import { push } from "../lib/router.js";
let { id } = $props();
@@ -24,11 +24,32 @@
let igdbQuery = $state("");
let applying = $state(null); // igdb_id being applied
// Media selection — tracks which media item is shown in the media container
let selectedMedia = $state(null);
function selectMedia(type, idOrUrl) {
if (type === 'video') {
selectedMedia = { type: 'video', id: idOrUrl };
} else if (type === 'screenshot') {
selectedMedia = { type: 'screenshot', url: idOrUrl };
}
}
function handleDownload() {
downloadGame(id);
}
async function load() {
loading = true;
try {
game = await getGame(id);
igdbQuery = game.title || "";
// Set initial selected media to first available item
if (game.videos?.length) {
selectedMedia = { type: 'video', id: game.videos[0] };
} else if (game.screenshots?.length) {
selectedMedia = { type: 'screenshot', url: game.screenshots[0] };
}
} catch (e) {
error = "Game not found";
}
@@ -183,38 +204,50 @@
<button class="btn btn-primary" onclick={handlePlay}> Play</button>
{/if}
<button class="btn" onclick={startEdit}> Edit</button>
<button class="btn" onclick={handleDownload}> Download</button>
<button class="btn btn-danger" onclick={handleDelete}> Delete</button>
</div>
<!-- Media Section — video + screenshots, handles missing media gracefully -->
{#if game.videos?.length || game.screenshots?.length}
<!-- Media Section — container + clickable row for all videos & screenshots -->
{#if selectedMedia}
<div class="media-section">
<span class="media-label">📺 Media</span>
<div class="media-grid">
{#if game.videos?.length}
<div class="video-container">
<iframe
src="https://www.youtube.com/embed/{game.videos[0]}"
title="Gameplay video"
allowfullscreen
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
></iframe>
</div>
{/if}
{#if game.screenshots?.length}
<div class="screenshots-row">
{#each game.screenshots as shot}
<img
src={shot}
alt="Screenshot"
class="screenshot-thumb"
loading="lazy"
/>
{/each}
</div>
<div class="media-container">
{#if selectedMedia.type === 'video'}
<iframe
src="https://www.youtube.com/embed/{selectedMedia.id}"
title="Gameplay video"
allowfullscreen
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
></iframe>
{:else if selectedMedia.type === 'screenshot'}
<img src={selectedMedia.url} alt="Screenshot" class="media-main-img" />
{/if}
</div>
<div class="media-row">
{#each game.videos ?? [] as video, i}
<button
class="media-thumb"
class:active={selectedMedia.type === 'video' && selectedMedia.id === video}
onclick={() => selectMedia('video', video)}
title="Video {i + 1}"
>
<img src="https://img.youtube.com/vi/{video}/mqdefault.jpg" alt="Video {i + 1}" loading="lazy" />
<span class="media-thumb-icon"></span>
</button>
{/each}
{#each game.screenshots ?? [] as shot}
<button
class="media-thumb"
class:active={selectedMedia.type === 'screenshot' && selectedMedia.url === shot}
onclick={() => selectMedia('screenshot', shot)}
title="Screenshot"
>
<img src={shot} alt="Screenshot" loading="lazy" />
</button>
{/each}
</div>
</div>
{/if}
@@ -312,7 +345,7 @@
.edit-actions { display: flex; gap: 8px; margin-top: 12px; }
textarea { width: 100%; min-height: 100px; }
/* Media Section — video + screenshots */
/* Media Section — container + clickable thumbnail row */
.media-section {
margin-top: 24px;
padding-top: 20px;
@@ -327,44 +360,68 @@
letter-spacing: 0.05em;
margin-bottom: 12px;
}
.media-grid {
display: flex;
flex-direction: column;
gap: 16px;
}
.video-container {
.media-container {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
border-radius: var(--radius);
overflow: hidden;
background: #000;
margin-bottom: 12px;
}
.video-container iframe {
.media-container iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.screenshots-row {
.media-main-img {
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
.media-row {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 4px;
}
.screenshot-thumb {
.media-thumb {
flex-shrink: 0;
width: 240px;
height: 135px;
object-fit: cover;
position: relative;
width: 200px;
height: 113px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
transition: border-color 0.15s;
border: 2px solid var(--border);
overflow: hidden;
cursor: pointer;
padding: 0;
background: none;
transition: border-color 0.15s, opacity 0.15s;
}
.screenshot-thumb:hover {
.media-thumb:hover {
border-color: var(--phosphor-dim);
opacity: 0.9;
}
.media-thumb.active {
border-color: var(--phosphor);
}
.media-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.media-thumb-icon {
position: absolute;
bottom: 4px;
left: 4px;
background: rgba(0,0,0,0.7);
color: #fff;
font-size: 0.7rem;
padding: 1px 6px;
border-radius: 4px;
}
/* IGDB Scrape */