Files
dostalgia/frontend/src/pages/Library.svelte
T
David Alvarez 466299b8f0 IGDB integration: auto-scrape on upload + manual scrape button
- New IgdbService: Twitch OAuth2, IGDB API v4 search, cover download
- Upload auto-scrapes IGDB after extracting game ZIP
- GameDetail page: 'Auto Scrape' + 'Search' buttons + result picker
- Upload dialog: optional title field (defaults to filename)
- Jackson SNAKE_CASE naming to match frontend expectations
- IGDB status endpoint to check credentials
2026-05-25 10:22:58 +02:00

188 lines
4.1 KiB
Svelte

<script>
import { listGames, uploadGame } from "../lib/api.js";
import GameCard from "../lib/GameCard.svelte";
let games = $state([]);
let loading = $state(true);
let search = $state("");
let uploading = $state(false);
let uploadError = $state("");
let uploadTitle = $state("");
let fileInput;
async function loadGames() {
loading = true;
try {
const data = await listGames(search ? { search } : {});
games = data.games || [];
} catch (e) {
console.error("Failed to load games:", e);
}
loading = false;
}
async function handleUpload(e) {
const file = e.target.files?.[0];
if (!file) return;
uploading = true;
uploadError = "";
try {
await uploadGame(file, uploadTitle.trim() || undefined);
uploadTitle = "";
await loadGames();
fileInput.value = "";
} catch (err) {
uploadError = err.message;
}
uploading = false;
}
$effect(() => {
loadGames();
});
</script>
<div class="library">
<div class="toolbar">
<h1>Library</h1>
<div class="toolbar-actions">
<input
type="text"
placeholder="Search games..."
bind:value={search}
oninput={() => loadGames()}
/>
<div class="upload-group">
<input
type="text"
class="title-input"
placeholder="Title (optional)"
bind:value={uploadTitle}
disabled={uploading}
/>
<label class="btn btn-primary upload-label">
{uploading ? "Uploading..." : "+ Upload"}
<input
bind:this={fileInput}
type="file"
accept=".zip"
class="hidden-input"
onchange={handleUpload}
disabled={uploading}
/>
</label>
</div>
</div>
</div>
{#if uploadError}
<div class="error">{uploadError}</div>
{/if}
{#if loading}
<div class="loading">Scanning library...</div>
{:else if games.length === 0}
<div class="empty-state">
<div class="empty-icon">💾</div>
<h2>No games yet</h2>
<p>Upload a DOS game ZIP to get started</p>
<label class="btn btn-primary" style="margin-top: 16px;">
Upload your first game
<input type="file" accept=".zip" class="hidden-input" onchange={handleUpload} />
</label>
</div>
{:else}
<div class="grid">
{#each games as game (game.id)}
<GameCard {game} />
{/each}
</div>
<div class="count">{games.length} game{games.length !== 1 ? "s" : ""}</div>
{/if}
</div>
<style>
.library {
padding: 32px 0;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 16px;
}
.toolbar h1 {
color: var(--phosphor);
font-family: var(--font-mono);
animation: phosphor-pulse 3s ease-in-out infinite;
}
.toolbar-actions {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.toolbar-actions input {
min-width: 200px;
}
.upload-group {
display: flex;
gap: 8px;
align-items: center;
}
.title-input {
min-width: 160px !important;
width: 160px;
}
.hidden-input {
display: none;
}
.upload-label {
cursor: pointer;
white-space: nowrap;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
}
.error {
background: #331111;
border: 1px solid #cc3333;
color: #ff6666;
padding: 12px 16px;
border-radius: var(--radius-sm);
margin-bottom: 16px;
}
.loading {
text-align: center;
padding: 60px 20px;
color: var(--text-dim);
font-style: italic;
}
.empty-state {
text-align: center;
padding: 80px 20px;
border: 1px dashed var(--border);
border-radius: var(--radius);
}
.empty-icon {
font-size: 3rem;
margin-bottom: 16px;
}
.empty-state h2 {
margin-bottom: 8px;
}
.empty-state p {
color: var(--text-dim);
}
.count {
margin-top: 24px;
text-align: center;
color: var(--text-dim);
font-size: 0.85rem;
}
</style>