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(); return res.json();
} }
/** Download a game's ZIP bundle */
export function downloadGame(id) {
window.open(`${BASE}/api/games/${id}/download`, '_blank');
}
/** Check IGDB status */ /** Check IGDB status */
export async function igdbStatus() { export async function igdbStatus() {
const res = await fetch(`${BASE}/api/igdb/status`); const res = await fetch(`${BASE}/api/igdb/status`);
+92 -35
View File
@@ -1,5 +1,5 @@
<script> <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"; import { push } from "../lib/router.js";
let { id } = $props(); let { id } = $props();
@@ -24,11 +24,32 @@
let igdbQuery = $state(""); let igdbQuery = $state("");
let applying = $state(null); // igdb_id being applied 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() { async function load() {
loading = true; loading = true;
try { try {
game = await getGame(id); game = await getGame(id);
igdbQuery = game.title || ""; 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) { } catch (e) {
error = "Game not found"; error = "Game not found";
} }
@@ -183,37 +204,49 @@
<button class="btn btn-primary" onclick={handlePlay}> Play</button> <button class="btn btn-primary" onclick={handlePlay}> Play</button>
{/if} {/if}
<button class="btn" onclick={startEdit}> Edit</button> <button class="btn" onclick={startEdit}> Edit</button>
<button class="btn" onclick={handleDownload}> Download</button>
<button class="btn btn-danger" onclick={handleDelete}> Delete</button> <button class="btn btn-danger" onclick={handleDelete}> Delete</button>
</div> </div>
<!-- Media Section — video + screenshots, handles missing media gracefully --> <!-- Media Section — container + clickable row for all videos & screenshots -->
{#if game.videos?.length || game.screenshots?.length} {#if selectedMedia}
<div class="media-section"> <div class="media-section">
<span class="media-label">📺 Media</span> <span class="media-label">📺 Media</span>
<div class="media-grid"> <div class="media-container">
{#if game.videos?.length} {#if selectedMedia.type === 'video'}
<div class="video-container">
<iframe <iframe
src="https://www.youtube.com/embed/{game.videos[0]}" src="https://www.youtube.com/embed/{selectedMedia.id}"
title="Gameplay video" title="Gameplay video"
allowfullscreen allowfullscreen
frameborder="0" frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
></iframe> ></iframe>
</div> {:else if selectedMedia.type === 'screenshot'}
<img src={selectedMedia.url} alt="Screenshot" class="media-main-img" />
{/if} {/if}
{#if game.screenshots?.length} </div>
<div class="screenshots-row"> <div class="media-row">
{#each game.screenshots as shot} {#each game.videos ?? [] as video, i}
<img <button
src={shot} class="media-thumb"
alt="Screenshot" class:active={selectedMedia.type === 'video' && selectedMedia.id === video}
class="screenshot-thumb" onclick={() => selectMedia('video', video)}
loading="lazy" 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} {/each}
</div>
{/if}
</div> </div>
</div> </div>
{/if} {/if}
@@ -312,7 +345,7 @@
.edit-actions { display: flex; gap: 8px; margin-top: 12px; } .edit-actions { display: flex; gap: 8px; margin-top: 12px; }
textarea { width: 100%; min-height: 100px; } textarea { width: 100%; min-height: 100px; }
/* Media Section — video + screenshots */ /* Media Section — container + clickable thumbnail row */
.media-section { .media-section {
margin-top: 24px; margin-top: 24px;
padding-top: 20px; padding-top: 20px;
@@ -327,44 +360,68 @@
letter-spacing: 0.05em; letter-spacing: 0.05em;
margin-bottom: 12px; margin-bottom: 12px;
} }
.media-grid { .media-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.video-container {
position: relative; position: relative;
width: 100%; width: 100%;
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
border-radius: var(--radius); border-radius: var(--radius);
overflow: hidden; overflow: hidden;
background: #000; background: #000;
margin-bottom: 12px;
} }
.video-container iframe { .media-container iframe {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.screenshots-row { .media-main-img {
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
.media-row {
display: flex; display: flex;
gap: 8px; gap: 8px;
overflow-x: auto; overflow-x: auto;
padding-bottom: 4px; padding-bottom: 4px;
} }
.screenshot-thumb { .media-thumb {
flex-shrink: 0; flex-shrink: 0;
width: 240px; position: relative;
height: 135px; width: 200px;
object-fit: cover; height: 113px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
border: 1px solid var(--border); border: 2px solid var(--border);
transition: border-color 0.15s; overflow: hidden;
cursor: pointer; 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); 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 */ /* IGDB Scrape */
@@ -4,8 +4,13 @@ import jakarta.inject.Inject;
import jakarta.ws.rs.*; import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.StreamingOutput;
import java.nio.file.NoSuchFileException; import java.nio.file.NoSuchFileException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*; import java.util.*;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@Path("/api/games") @Path("/api/games")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@@ -90,6 +95,56 @@ public class GameResource {
} }
} }
@GET
@Path("/{id}/download")
@Produces("application/zip")
public Response download(@PathParam("id") String id) {
try {
var game = svc.load(id);
String filename = (game.getTitle() != null ? game.getTitle() : id) + ".zip";
if ("sockdrive".equals(game.getBundleType())) {
// ZIP all game files on the fly, excluding metadata
StreamingOutput stream = output -> {
Path gameDir = svc.gameDir(id);
try (var zos = new ZipOutputStream(output)) {
Files.walk(gameDir)
.filter(Files::isRegularFile)
.filter(p -> !p.getFileName().toString().equals("game.json"))
.filter(p -> !p.getFileName().toString().startsWith("cover."))
.forEach(p -> {
try {
String entryName = gameDir.relativize(p).toString().replace('\\', '/');
zos.putNextEntry(new ZipEntry(entryName));
Files.copy(p, zos);
zos.closeEntry();
} catch (Exception e) {
throw new RuntimeException(e);
}
});
}
};
return Response.ok(stream)
.header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
.build();
} else {
// Standard .jsdos bundle
Path bundlePath = svc.getGamesDir().resolve(game.getBundleFile());
if (!Files.exists(bundlePath)) {
return Response.status(404).entity(Map.of("error", "Bundle file not found")).build();
}
StreamingOutput stream = output -> Files.copy(bundlePath, output);
return Response.ok(stream)
.header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
.build();
}
} catch (NoSuchFileException e) {
return Response.status(404).entity(Map.of("error", "Game not found")).build();
} catch (Exception e) {
return Response.serverError().entity(Map.of("error", e.getMessage())).build();
}
}
@DELETE @DELETE
@Path("/{id}") @Path("/{id}")
public Response delete(@PathParam("id") String id) { public Response delete(@PathParam("id") String id) {