feat: auto-search IGDB on file select, pick match or type custom title
Build & Deploy / build-and-deploy (push) Successful in 54s

Upload flow is now:
1. Click +Upload → select a .zip file
2. IGDB is auto-searched with the filename
3. If matches found → show results as selectable cards + fallback
   custom title search at the bottom
4. If no matches → show 'no matches' + manual title + Upload btn
   + a fallback 'Use filename: xxx' button
5. Clicking a result uploads immediately with that game's title
   (so IGDB auto-scrape picks it up correctly)
6. Cancel button to dismiss
This commit is contained in:
David Alvarez
2026-05-27 11:25:03 +02:00
parent 3c49d72f56
commit 72391fb577
+206 -14
View File
@@ -1,5 +1,5 @@
<script> <script>
import { listGames, uploadGame } from "../lib/api.js"; import { listGames, uploadGame, searchIGDB } from "../lib/api.js";
import GameCard from "../lib/GameCard.svelte"; import GameCard from "../lib/GameCard.svelte";
let games = $state([]); let games = $state([]);
@@ -12,6 +12,9 @@
let pendingFile = $state(null); let pendingFile = $state(null);
let pendingFileName = $state(""); let pendingFileName = $state("");
let pendingTitle = $state(""); let pendingTitle = $state("");
let searching = $state(false);
let searchError = $state("");
let igdbResults = $state(null); // null=loading, []=no results, [{...}]=matches
let fileInput; let fileInput;
@@ -33,14 +36,46 @@
pendingFile = file; pendingFile = file;
pendingFileName = name; pendingFileName = name;
pendingTitle = ""; pendingTitle = "";
// Reset input so the same file can be re-selected igdbResults = null;
searching = true;
searchError = "";
fileInput.value = ""; fileInput.value = "";
// Auto-search IGDB with the filename
searchIGDB(name).then(data => {
igdbResults = data.results || [];
}).catch(err => {
searchError = "IGDB search failed: " + err.message;
igdbResults = [];
}).finally(() => {
searching = false;
});
}
async function handleManualSearch() {
const q = pendingTitle.trim();
if (!q) return;
searching = true;
searchError = "";
igdbResults = null;
try {
const data = await searchIGDB(q);
igdbResults = data.results || [];
if (igdbResults.length === 0) {
searchError = "No matches found on IGDB.";
}
} catch (err) {
searchError = "IGDB search failed: " + err.message;
igdbResults = [];
}
searching = false;
} }
function cancelUpload() { function cancelUpload() {
pendingFile = null; pendingFile = null;
pendingFileName = ""; pendingFileName = "";
pendingTitle = ""; pendingTitle = "";
igdbResults = null;
searchError = "";
} }
async function doUpload(title) { async function doUpload(title) {
@@ -93,23 +128,74 @@
<div class="upload-dialog" onclick={(e) => e.stopPropagation()} role="dialog"> <div class="upload-dialog" onclick={(e) => e.stopPropagation()} role="dialog">
<h3>Upload Game</h3> <h3>Upload Game</h3>
<p class="upload-file-name">{pendingFileName}.zip</p> <p class="upload-file-name">{pendingFileName}.zip</p>
<div class="upload-dialog-actions">
<button class="btn btn-primary" onclick={() => doUpload("")} disabled={uploading}> <div class="upload-dialog-body">
{uploading ? "Uploading..." : "Use filename as title"} {#if searching}
<div class="status-msg">Searching IGDB for "{pendingFileName}"...</div>
{:else if igdbResults !== null && igdbResults.length > 0}
<p class="match-label">Select a match or enter a custom title:</p>
<div class="upload-igdb-results">
{#each igdbResults as result (result.igdb_id)}
<button
class="igdb-result"
onclick={() => doUpload(result.name)}
disabled={uploading}
>
{#if result.cover_url}
<img src={result.cover_url} alt={result.name} class="igdb-thumb" loading="lazy" />
{:else}
<div class="igdb-thumb-placeholder">🎮</div>
{/if}
<div class="igdb-info">
<strong>{result.name}</strong>
<span class="igdb-meta">
{result.year || "—"}
{#if result.genres?.length} · {result.genres.slice(0, 2).join(", ")}{/if}
</span>
{#if result.developer}
<span class="igdb-dev">{result.developer}</span>
{/if}
</div>
</button> </button>
{/each}
</div>
<div class="upload-custom-row"> <div class="upload-custom-row">
<input <input
type="text" type="text"
placeholder="Enter a custom title..." placeholder="Or type a different title..."
bind:value={pendingTitle} bind:value={pendingTitle}
disabled={uploading} disabled={uploading}
onkeydown={(e) => e.key === "Enter" && handleManualSearch()}
/> />
<button class="btn" onclick={() => doUpload(pendingTitle.trim())} disabled={uploading || !pendingTitle.trim()}> <button class="btn" onclick={handleManualSearch} disabled={uploading || !pendingTitle.trim()}>
Upload Search
</button> </button>
</div> </div>
<button class="btn" onclick={cancelUpload} disabled={uploading}>Cancel</button> {:else}
{#if searchError}
<div class="status-msg error-msg">{searchError}</div>
{:else}
<p class="match-label">No matches found on IGDB. Enter a title manually:</p>
{/if}
<div class="upload-custom-row">
<input
type="text"
placeholder="Enter game title..."
bind:value={pendingTitle}
disabled={uploading}
onkeydown={(e) => e.key === "Enter" && doUpload(pendingTitle.trim())}
/>
<button class="btn" onclick={() => doUpload(pendingTitle.trim())} disabled={uploading || !pendingTitle.trim()}>
{uploading ? "Uploading..." : "Upload"}
</button>
</div> </div>
<button class="btn btn-secondary" onclick={() => doUpload(pendingFileName)} disabled={uploading}>
Use filename: {pendingFileName}
</button>
{/if}
</div>
<button class="btn btn-cancel" onclick={cancelUpload} disabled={uploading}>Cancel</button>
</div> </div>
</div> </div>
{/if} {/if}
@@ -127,7 +213,7 @@
<p>Upload a DOS game ZIP to get started</p> <p>Upload a DOS game ZIP to get started</p>
<label class="btn btn-primary" style="margin-top: 16px;"> <label class="btn btn-primary" style="margin-top: 16px;">
Upload your first game Upload your first game
<input type="file" accept=".zip" class="hidden-input" onchange={handleUpload} /> <input type="file" accept=".zip" class="hidden-input" onchange={handleFileSelected} />
</label> </label>
</div> </div>
{:else} {:else}
@@ -231,7 +317,8 @@
font-size: 0.85rem; font-size: 0.85rem;
} }
/* Upload dialog overlay */ /* ─── Upload Dialog ─────────────────────────────── */
.upload-overlay { .upload-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -247,8 +334,11 @@
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
padding: 24px; padding: 24px;
max-width: 420px; max-width: 480px;
width: 100%; width: 100%;
max-height: 90vh;
display: flex;
flex-direction: column;
} }
.upload-dialog h3 { .upload-dialog h3 {
margin-bottom: 4px; margin-bottom: 4px;
@@ -258,14 +348,101 @@
.upload-file-name { .upload-file-name {
color: var(--text-dim); color: var(--text-dim);
font-size: 0.85rem; font-size: 0.85rem;
margin-bottom: 20px; margin-bottom: 4px;
word-break: break-all; word-break: break-all;
} }
.upload-dialog-actions { .upload-dialog-body {
flex: 1;
overflow-y: auto;
margin: 16px 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
} }
.match-label {
font-size: 0.85rem;
color: var(--text-dim);
}
.status-msg {
padding: 12px;
text-align: center;
color: var(--text-dim);
font-style: italic;
}
.status-msg.error-msg {
color: #ff6666;
font-style: normal;
}
/* IGDB results list inside dialog */
.upload-igdb-results {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 240px;
overflow-y: auto;
}
.igdb-result {
display: flex;
gap: 10px;
align-items: center;
background: var(--surface-hover);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px;
cursor: pointer;
text-align: left;
transition: border-color 0.15s;
width: 100%;
}
.igdb-result:hover {
border-color: var(--phosphor-dim);
}
.igdb-result:disabled {
opacity: 0.6;
cursor: wait;
}
.igdb-thumb {
width: 42px;
height: 56px;
object-fit: cover;
border-radius: 4px;
flex-shrink: 0;
}
.igdb-thumb-placeholder {
width: 42px;
height: 56px;
background: var(--surface);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
flex-shrink: 0;
}
.igdb-info {
flex: 1;
min-width: 0;
}
.igdb-info strong {
display: block;
font-size: 0.85rem;
color: var(--text-bright);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.igdb-meta {
font-size: 0.75rem;
color: var(--text-dim);
}
.igdb-dev {
display: block;
font-size: 0.7rem;
color: var(--text-dim);
margin-top: 1px;
}
.upload-custom-row { .upload-custom-row {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -274,4 +451,19 @@
flex: 1; flex: 1;
} }
.btn-cancel {
border-color: var(--border);
color: var(--text-dim);
}
.btn-cancel:hover {
border-color: var(--text-dim);
color: var(--text);
}
.btn-secondary {
border-color: var(--border);
color: var(--text);
}
.btn-secondary:hover {
border-color: var(--text-dim);
}
</style> </style>