diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js
index b4de421..7f859dd 100644
--- a/frontend/src/lib/api.js
+++ b/frontend/src/lib/api.js
@@ -59,9 +59,35 @@ export async function uploadGame(file, title) {
return res.json();
}
-/** Search IGDB (placeholder) */
+/** Search IGDB for a game title (requires TWITCH_CLIENT_ID/SECRET set) */
export async function searchIGDB(query) {
- return request(`/api/igdb/search?query=${encodeURIComponent(query)}`);
+ const res = await fetch(`${BASE}/api/igdb/search?q=${encodeURIComponent(query)}`);
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ error: res.statusText }));
+ throw new Error(err.error || `HTTP ${res.status}`);
+ }
+ return res.json();
+}
+
+/** Apply IGDB metadata + cover to a game (auto or by igdb_id) */
+export async function scrapeIGDB(gameId, igdbId) {
+ const body = igdbId ? { igdb_id: igdbId } : {};
+ const res = await fetch(`${BASE}/api/igdb/scrape/${gameId}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ error: res.statusText }));
+ throw new Error(err.error || `HTTP ${res.status}`);
+ }
+ return res.json();
+}
+
+/** Check IGDB status */
+export async function igdbStatus() {
+ const res = await fetch(`${BASE}/api/igdb/status`);
+ return res.json();
}
/** Get artwork URL for a path — returns proxy URL or placeholder */
diff --git a/frontend/src/pages/GameDetail.svelte b/frontend/src/pages/GameDetail.svelte
index 0e48923..2b79a14 100644
--- a/frontend/src/pages/GameDetail.svelte
+++ b/frontend/src/pages/GameDetail.svelte
@@ -1,5 +1,5 @@
@@ -132,6 +185,73 @@
+
+
+
+
+
+ {#if igdbResults !== null}
+
+ e.key === "Enter" && handleSearchIGDB()}
+ />
+
+ {#if igdbResults.length > 0}
+
+ {#each igdbResults as result (result.igdb_id)}
+
+ {/each}
+
+ {/if}
+ {/if}
+
+ {#if scrapeError}
+
+ {scrapeError}
+
+ {/if}
+
{/if}
@@ -154,9 +274,131 @@
.meta-item { background: var(--surface-hover); color: var(--phosphor-dim); padding: 4px 12px; border-radius: 12px; font-size: 0.85rem; }
.dev { color: var(--text-dim); margin-bottom: 16px; }
.description { color: var(--text); line-height: 1.7; margin-bottom: 24px; }
- .actions { display: flex; gap: 8px; flex-wrap: wrap; }
+ .actions { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 24px; }
.edit-title { font-size: 1.5rem; margin-bottom: 12px; width: 100%; }
.edit-row { display: flex; gap: 8px; margin-bottom: 8px; }
.edit-actions { display: flex; gap: 8px; margin-top: 12px; }
textarea { width: 100%; min-height: 100px; }
-
\ No newline at end of file
+
+ /* IGDB Scrape */
+ .scrape-section {
+ margin-top: 24px;
+ padding-top: 20px;
+ border-top: 1px solid var(--border);
+ }
+ .scrape-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 12px;
+ }
+ .scrape-label {
+ font-size: 0.85rem;
+ color: var(--text-dim);
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ }
+ .scrape-buttons {
+ display: flex;
+ gap: 6px;
+ }
+ .btn-sm {
+ padding: 4px 12px;
+ font-size: 0.8rem;
+ }
+ .igdb-query-row {
+ margin-bottom: 8px;
+ }
+ .igdb-query-row input {
+ width: 100%;
+ box-sizing: border-box;
+ }
+ .igdb-results {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ max-height: 320px;
+ overflow-y: auto;
+ }
+ .igdb-result {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+ background: var(--surface);
+ 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: 48px;
+ height: 64px;
+ object-fit: cover;
+ border-radius: 4px;
+ flex-shrink: 0;
+ }
+ .igdb-thumb-placeholder {
+ width: 48px;
+ height: 64px;
+ background: var(--surface-hover);
+ 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.9rem;
+ color: var(--text-bright);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ .igdb-meta {
+ font-size: 0.8rem;
+ color: var(--text-dim);
+ }
+ .dos-badge {
+ background: var(--phosphor-dark);
+ color: var(--phosphor);
+ padding: 1px 6px;
+ border-radius: 6px;
+ font-size: 0.7rem;
+ font-weight: 600;
+ }
+ .igdb-dev {
+ display: block;
+ font-size: 0.75rem;
+ color: var(--text-dim);
+ margin-top: 2px;
+ }
+ .scrape-status {
+ margin-top: 8px;
+ padding: 6px 10px;
+ border-radius: var(--radius-sm);
+ font-size: 0.85rem;
+ background: #221100;
+ color: #ff9933;
+ }
+ .scrape-status.scrape-ok {
+ background: #112211;
+ color: #66ff66;
+ }
+
diff --git a/frontend/src/pages/Library.svelte b/frontend/src/pages/Library.svelte
index f68f5ea..ad96158 100644
--- a/frontend/src/pages/Library.svelte
+++ b/frontend/src/pages/Library.svelte
@@ -7,6 +7,7 @@
let search = $state("");
let uploading = $state(false);
let uploadError = $state("");
+ let uploadTitle = $state("");
let fileInput;
async function loadGames() {
@@ -26,7 +27,8 @@
uploading = true;
uploadError = "";
try {
- await uploadGame(file);
+ await uploadGame(file, uploadTitle.trim() || undefined);
+ uploadTitle = "";
await loadGames();
fileInput.value = "";
} catch (err) {
@@ -50,17 +52,26 @@
bind:value={search}
oninput={() => loadGames()}
/>
-