feat: all-video media row, clickable media selector, download button
Build & Deploy / build-and-deploy (push) Failing after 44s
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:
@@ -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`);
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user