466299b8f0
- 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
188 lines
4.1 KiB
Svelte
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>
|