add project

This commit is contained in:
2026-05-17 14:51:37 -04:00
parent 2f1a0d6020
commit fe8dc317b2
40 changed files with 6137 additions and 0 deletions

3
src/app.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

12
src/app.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,76 @@
<script lang="ts">
import type { GameFile } from '$lib/types';
import { PLATFORM_LABELS } from '$lib/platform';
import PlatformIcon from './PlatformIcon.svelte';
import { formatBytes } from '$lib/utils';
let { files }: { files: GameFile[] } = $props();
let open = $state(false);
let container = $state<HTMLDivElement | undefined>(undefined);
</script>
<svelte:window onclick={(e) => { if (container && !container.contains(e.target as Node)) open = false; }} />
{#if files.length === 0}
<p class="text-gray-500 text-sm">No downloads available.</p>
{:else if files.length === 1}
<a
href="/api/download/{files[0].id}"
download
class="inline-flex items-center gap-2 px-5 py-2.5 bg-purple-600 hover:bg-purple-500 text-white font-medium rounded-lg transition-colors"
>
<PlatformIcon platform={files[0].platform} />
{PLATFORM_LABELS[files[0].platform]}
{#if files[0].file_size > 0}
<span class="text-purple-200 text-sm">({formatBytes(files[0].file_size)})</span>
{/if}
</a>
{:else}
<div class="relative inline-block" bind:this={container}>
<!-- Primary download: first file -->
<div class="flex rounded-lg overflow-hidden shadow-md">
<a
href="/api/download/{files[0].id}"
download
class="inline-flex items-center gap-2 px-5 py-2.5 bg-purple-600 hover:bg-purple-500 text-white font-medium transition-colors"
>
<PlatformIcon platform={files[0].platform} />
{PLATFORM_LABELS[files[0].platform]}
{#if files[0].file_size > 0}
<span class="text-purple-200 text-sm">({formatBytes(files[0].file_size)})</span>
{/if}
</a>
<button
onclick={() => (open = !open)}
aria-label="More download options"
class="px-3 bg-purple-700 hover:bg-purple-600 text-white border-l border-purple-500 transition-colors"
>
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd" />
</svg>
</button>
</div>
{#if open}
<div
class="absolute left-0 top-full mt-1 w-56 bg-gray-800 border border-gray-700 rounded-lg shadow-xl z-20 py-1"
>
{#each files as file}
<a
href="/api/download/{file.id}"
download
onclick={() => (open = false)}
class="flex items-center gap-3 px-4 py-2.5 hover:bg-gray-700 text-gray-200 text-sm transition-colors"
>
<PlatformIcon platform={file.platform} />
<span class="flex-1">{PLATFORM_LABELS[file.platform]}</span>
{#if file.file_size > 0}
<span class="text-gray-400 text-xs">{formatBytes(file.file_size)}</span>
{/if}
</a>
{/each}
</div>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,37 @@
<script lang="ts">
import type { Game } from '$lib/types';
let { game, loggedIn }: { game: Game; loggedIn: boolean } = $props();
</script>
<a href="/games/{game.slug}" class="group block">
<div
class="relative aspect-[2/3] rounded-lg overflow-hidden bg-gray-800 shadow-md transition-transform duration-200 group-hover:-translate-y-1 group-hover:shadow-2xl"
>
{#if game.has_cover}
<img
src="/api/cover/{game.slug}"
alt={game.title}
class="w-full h-full object-cover"
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-end p-3 bg-gradient-to-br from-gray-700 to-gray-900">
<span class="text-sm font-medium text-gray-200 leading-tight line-clamp-3"
>{game.title}</span
>
</div>
{/if}
{#if game.library === 'private' && loggedIn}
<div
class="absolute top-2 right-2 bg-purple-600 text-white text-xs font-medium px-2 py-0.5 rounded-full"
>
Private
</div>
{/if}
</div>
<p class="mt-2 text-sm text-gray-300 truncate group-hover:text-white transition-colors">
{game.title}
</p>
</a>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import type { Game } from '$lib/types';
import GameCard from './GameCard.svelte';
let {
games,
loggedIn,
filter = ''
}: { games: Game[]; loggedIn: boolean; filter?: string } = $props();
const filtered = $derived(
filter.trim()
? games.filter(
(g) =>
g.title.toLowerCase().includes(filter.toLowerCase()) ||
g.genre.toLowerCase().includes(filter.toLowerCase())
)
: games
);
</script>
{#if filtered.length === 0}
<p class="text-gray-500 text-center py-16">No games found.</p>
{:else}
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{#each filtered as game (game.id)}
<GameCard {game} {loggedIn} />
{/each}
</div>
{/if}

View File

@@ -0,0 +1,82 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { untrack } from 'svelte';
import type { Game } from '$lib/types';
let { game, loggedIn }: { game: Game; loggedIn: boolean } = $props();
let editing = $state(false);
let description = $state(untrack(() => game.description));
let genre = $state(untrack(() => game.genre));
</script>
<div class="space-y-4">
{#if loggedIn && editing}
<form
method="POST"
action="?/updateMeta"
use:enhance={() => {
return ({ result, update }) => {
if (result.type === 'success') editing = false;
update();
};
}}
class="space-y-3"
>
<div>
<label for="genre" class="block text-xs font-medium text-gray-400 mb-1">Genre</label>
<input
id="genre"
name="genre"
type="text"
bind:value={genre}
class="w-full max-w-xs px-3 py-1.5 text-sm rounded-md bg-gray-800 border border-gray-600 text-gray-200 focus:outline-none focus:border-purple-500 transition-colors"
/>
</div>
<div>
<label for="description" class="block text-xs font-medium text-gray-400 mb-1"
>Description</label
>
<textarea
id="description"
name="description"
bind:value={description}
rows="5"
class="w-full px-3 py-2 text-sm rounded-md bg-gray-800 border border-gray-600 text-gray-200 focus:outline-none focus:border-purple-500 transition-colors resize-none"
></textarea>
</div>
<div class="flex gap-2">
<button
type="submit"
class="px-4 py-1.5 text-sm bg-purple-600 hover:bg-purple-500 text-white rounded-md transition-colors"
>
Save
</button>
<button
type="button"
onclick={() => { editing = false; description = game.description; genre = game.genre; }}
class="px-4 py-1.5 text-sm bg-gray-700 hover:bg-gray-600 text-gray-200 rounded-md transition-colors"
>
Cancel
</button>
</div>
</form>
{:else}
{#if genre}
<p class="text-sm text-purple-400 font-medium">{genre}</p>
{/if}
{#if description}
<p class="text-gray-300 text-sm leading-relaxed whitespace-pre-wrap">{description}</p>
{:else if loggedIn}
<p class="text-gray-600 text-sm italic">No description yet.</p>
{/if}
{#if loggedIn}
<button
onclick={() => (editing = true)}
class="text-xs text-gray-500 hover:text-gray-300 transition-colors"
>
{description || genre ? 'Edit' : 'Add description / genre'}
</button>
{/if}
{/if}
</div>

View File

@@ -0,0 +1,35 @@
<script lang="ts">
let { loggedIn }: { loggedIn: boolean } = $props();
async function rescan() {
await fetch('/api/scan', { method: 'POST' });
window.location.reload();
}
</script>
<nav class="bg-gray-900 border-b border-gray-800 sticky top-0 z-50">
<div class="max-w-screen-2xl mx-auto px-4 h-14 flex items-center justify-between">
<a href="/" class="text-xl font-bold text-white tracking-tight hover:text-purple-400 transition-colors">
Game Grid
</a>
<div class="flex items-center gap-4">
{#if loggedIn}
<button
onclick={rescan}
class="text-sm text-gray-400 hover:text-gray-200 transition-colors"
>
Rescan
</button>
<form method="POST" action="/logout">
<button type="submit" class="text-sm text-gray-400 hover:text-gray-200 transition-colors">
Log out
</button>
</form>
{:else}
<a href="/login" class="text-sm text-gray-400 hover:text-gray-200 transition-colors">
Log in
</a>
{/if}
</div>
</div>
</nav>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import type { Platform } from '$lib/platform';
let { platform }: { platform: Platform } = $props();
</script>
{#if platform === 'windows'}
<!-- Windows logo: 4 panes -->
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M0 3.449L9.75 2.1v9.451H0V3.449zM10.949-6.153L24 0v11.551H10.949V-6.153zM0 12.6h9.75v9.451L0 20.699V12.6zm10.949 0H24V24l-13.051-1.799V12.6z"/>
</svg>
{:else if platform === 'macos'}
<!-- Apple logo -->
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"/>
</svg>
{:else if platform === 'linux'}
<!-- Linux: simple penguin-like silhouette -->
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 1C8.5 1 6 3.5 6 7c0 2 .8 3.7 2 5l-2.5 4c-.5.9-.2 2 .7 2.5.3.2.6.3 1 .2l1-.3c.5 1.2 1.7 2 3 2h1.6c1.3 0 2.5-.8 3-2l1 .3c.4.1.7 0 1-.2.9-.5 1.2-1.6.7-2.5L16 12c1.2-1.3 2-3 2-5 0-3.5-2.5-6-6-6zm0 2c2.2 0 4 1.8 4 4s-1.8 4-4 4-4-1.8-4-4 1.8-4 4-4zm-1.5 2c-.8 0-1.5.7-1.5 1.5S9.7 8 10.5 8 12 7.3 12 6.5 11.3 5 10.5 5zm3 0c-.8 0-1.5.7-1.5 1.5S12.7 8 13.5 8 15 7.3 15 6.5 14.3 5 13.5 5z"/>
</svg>
{:else}
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 2a10 10 0 110 20A10 10 0 0112 2zm0 2a8 8 0 100 16A8 8 0 0012 4zm0 3l3 3H9l3-3zm0 12l-3-3h6l-3 3zm5-5v-4h-2v4h2zm-8 0v-4H7v4h2z"/>
</svg>
{/if}

View File

@@ -0,0 +1,82 @@
<script lang="ts">
import type { Screenshot } from '$lib/types';
let { screenshots }: { screenshots: Screenshot[] } = $props();
let lightboxIndex = $state<number | null>(null);
function open(i: number) {
lightboxIndex = i;
}
function close() {
lightboxIndex = null;
}
function prev() {
if (lightboxIndex !== null) lightboxIndex = (lightboxIndex - 1 + screenshots.length) % screenshots.length;
}
function next() {
if (lightboxIndex !== null) lightboxIndex = (lightboxIndex + 1) % screenshots.length;
}
</script>
<svelte:window
onkeydown={(e) => {
if (lightboxIndex === null) return;
if (e.key === 'Escape') close();
if (e.key === 'ArrowLeft') prev();
if (e.key === 'ArrowRight') next();
}}
/>
<div class="flex gap-3 overflow-x-auto pb-2">
{#each screenshots as ss, i (ss.id)}
<button onclick={() => open(i)} class="flex-none rounded-md overflow-hidden bg-gray-800 focus:outline-none focus:ring-2 focus:ring-purple-500">
<img
src="/api/screenshot/{ss.id}"
alt="Screenshot {i + 1}"
class="h-32 w-auto object-cover hover:opacity-80 transition-opacity"
loading="lazy"
/>
</button>
{/each}
</div>
{#if lightboxIndex !== null}
<div
class="fixed inset-0 bg-black/90 z-50 flex items-center justify-center"
role="dialog"
aria-modal="true"
>
<button
onclick={close}
class="absolute inset-0 w-full h-full cursor-default"
aria-label="Close lightbox"
></button>
<button
onclick={prev}
class="absolute left-4 top-1/2 -translate-y-1/2 text-white/70 hover:text-white bg-black/40 rounded-full p-2 z-10"
aria-label="Previous"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<img
src="/api/screenshot/{screenshots[lightboxIndex].id}"
alt="Screenshot {lightboxIndex + 1}"
class="relative z-10 max-h-[90vh] max-w-[90vw] object-contain rounded-md shadow-2xl"
/>
<button
onclick={next}
class="absolute right-4 top-1/2 -translate-y-1/2 text-white/70 hover:text-white bg-black/40 rounded-full p-2 z-10"
aria-label="Next"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
{/if}

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { Tag } from '$lib/types';
let {
tags,
gameSlug,
loggedIn
}: { tags: Tag[]; gameSlug: string; loggedIn: boolean } = $props();
let newTag = $state('');
</script>
<div class="flex flex-wrap gap-2 items-center">
{#each tags as tag (tag.id)}
<span class="inline-flex items-center gap-1 bg-gray-700 text-gray-200 text-sm px-3 py-1 rounded-full">
{tag.name}
{#if loggedIn}
<form method="POST" action="?/removeTag" use:enhance class="contents">
<input type="hidden" name="tagId" value={tag.id} />
<button
type="submit"
aria-label="Remove {tag.name}"
class="text-gray-400 hover:text-red-400 transition-colors leading-none"
>
×
</button>
</form>
{/if}
</span>
{/each}
{#if loggedIn}
<form method="POST" action="?/addTag" use:enhance class="flex items-center gap-1" onsubmit={() => { newTag = ''; }}>
<input
type="text"
name="tag"
bind:value={newTag}
placeholder="Add tag…"
class="w-24 px-2 py-1 text-sm rounded-md bg-gray-800 border border-gray-600 text-gray-200 placeholder-gray-500 focus:outline-none focus:border-purple-500 transition-colors"
/>
<button
type="submit"
class="px-2 py-1 text-sm bg-gray-700 hover:bg-gray-600 text-gray-200 rounded-md transition-colors"
>
+
</button>
</form>
{/if}
</div>

29
src/lib/platform.ts Normal file
View File

@@ -0,0 +1,29 @@
export type Platform = 'windows' | 'macos' | 'linux' | 'unknown';
export function detectPlatform(name: string, isDirectory: boolean): Platform | null {
if (/^(cover|widecover|background|logo|icon|largeicon|screenshot)/i.test(name)) return null;
if (/\.(png|jpg|jpeg|webp|ico|plist|nib|txt|md|strings|json|xml|css|html|js)$/i.test(name))
return null;
if (name.toLowerCase() === 'screenshots') return null;
const lower = name.toLowerCase();
if (isDirectory && lower.endsWith('.app')) return 'macos';
if (!isDirectory) {
if (lower.endsWith('.zip')) return 'windows';
if (lower.endsWith('.dmg')) return 'macos';
if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz')) return 'linux';
if (lower.endsWith('.appimage')) return 'linux';
if (lower.endsWith('.deb') || lower.endsWith('.rpm')) return 'linux';
if (lower.endsWith('.sh')) return 'linux';
if (lower.endsWith('.app')) return 'macos';
}
return null;
}
export const PLATFORM_LABELS: Record<Platform, string> = {
windows: 'Windows',
macos: 'macOS',
linux: 'Linux',
unknown: 'Download'
};

54
src/lib/server/auth.ts Normal file
View File

@@ -0,0 +1,54 @@
import { createHmac } from 'node:crypto';
import bcrypt from 'bcrypt';
import type { Cookies } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
const SESSION_MAX_AGE_SECS = 60 * 60 * 24 * 30;
let cachedHash: string | null = null;
async function getHash(): Promise<string> {
const password = env.APP_PASSWORD ?? '';
if (!cachedHash) {
cachedHash = await bcrypt.hash(password, 10);
}
return cachedHash;
}
export async function verifyPassword(input: string): Promise<boolean> {
const password = env.APP_PASSWORD ?? '';
if (!password) return false;
return bcrypt.compare(input, await getHash());
}
export function signSession(): string {
const secret = env.SESSION_SECRET ?? 'dev-secret-change-in-production';
const payload = Buffer.from(JSON.stringify({ issuedAt: Math.floor(Date.now() / 1000) })).toString(
'base64url'
);
const sig = createHmac('sha256', secret).update(payload).digest('base64url');
return `${payload}.${sig}`;
}
export function verifySession(value: string): boolean {
const secret = env.SESSION_SECRET ?? 'dev-secret-change-in-production';
if (!value) return false;
const dot = value.lastIndexOf('.');
if (dot === -1) return false;
const payload = value.slice(0, dot);
const sig = value.slice(dot + 1);
const expected = createHmac('sha256', secret).update(payload).digest('base64url');
if (sig !== expected) return false;
try {
const { issuedAt } = JSON.parse(Buffer.from(payload, 'base64url').toString());
return Math.floor(Date.now() / 1000) - issuedAt < SESSION_MAX_AGE_SECS;
} catch {
return false;
}
}
export function getSession(cookies: Cookies): boolean {
const value = cookies.get('session');
if (!value) return false;
return verifySession(value);
}

69
src/lib/server/db.ts Normal file
View File

@@ -0,0 +1,69 @@
import Database from 'better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { env } from '$env/dynamic/private';
let _db: Database.Database | null = null;
export function getDb(): Database.Database {
if (_db) return _db;
const DB_PATH = env.DB_PATH ?? path.join(process.cwd(), 'data', 'game-grid.db');
const dir = path.dirname(DB_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
_db = new Database(DB_PATH);
_db.pragma('journal_mode = WAL');
_db.pragma('foreign_keys = ON');
initSchema(_db);
return _db;
}
function initSchema(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS games (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
library TEXT NOT NULL CHECK(library IN ('public','private')),
folder_path TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
genre TEXT NOT NULL DEFAULT '',
has_cover INTEGER NOT NULL DEFAULT 0,
has_wide INTEGER NOT NULL DEFAULT 0,
last_scanned_at TEXT NOT NULL DEFAULT (datetime('now')),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS game_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
game_id INTEGER NOT NULL REFERENCES games(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
rel_path TEXT NOT NULL,
platform TEXT NOT NULL,
is_dir INTEGER NOT NULL DEFAULT 0,
file_size INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS screenshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
game_id INTEGER NOT NULL REFERENCES games(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
rel_path TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE COLLATE NOCASE
);
CREATE TABLE IF NOT EXISTS game_tags (
game_id INTEGER NOT NULL REFERENCES games(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (game_id, tag_id)
);
`);
}

16
src/lib/server/files.ts Normal file
View File

@@ -0,0 +1,16 @@
import fs from 'node:fs';
import path from 'node:path';
export function findCoverFile(folderPath: string, wide = false): string | null {
let entries: string[];
try {
entries = fs.readdirSync(folderPath);
} catch {
return null;
}
const pattern = wide
? /^widecover\.(png|jpg|jpeg|webp)$/i
: /^cover\.(png|jpg|jpeg|webp)$/i;
const found = entries.find((e) => pattern.test(e));
return found ? path.join(folderPath, found) : null;
}

98
src/lib/server/scanner.ts Normal file
View File

@@ -0,0 +1,98 @@
import fs from 'node:fs';
import path from 'node:path';
import type Database from 'better-sqlite3';
import { detectPlatform } from '$lib/platform';
import { toSlug, isImageFile } from '$lib/utils';
import { env } from '$env/dynamic/private';
export function scanLibraries(db: Database.Database): void {
const GAMES_PATH = env.GAMES_PATH ?? path.join(process.cwd(), 'games');
for (const library of ['public', 'private'] as const) {
const libPath = path.join(GAMES_PATH, library);
if (!fs.existsSync(libPath)) continue;
let gameFolders: fs.Dirent[];
try {
gameFolders = fs.readdirSync(libPath, { withFileTypes: true }).filter((d) => d.isDirectory());
} catch {
continue;
}
for (const folder of gameFolders) {
const title = folder.name;
const slug = toSlug(title);
const folderPath = path.join(libPath, folder.name);
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(folderPath, { withFileTypes: true });
} catch {
continue;
}
const hasCover = entries.some(
(e) => e.isFile() && /^cover\.(png|jpg|jpeg|webp)$/i.test(e.name)
);
const hasWide = entries.some(
(e) => e.isFile() && /^widecover\.(png|jpg|jpeg|webp)$/i.test(e.name)
);
db.prepare(
`INSERT INTO games (slug, title, library, folder_path, has_cover, has_wide)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(slug) DO UPDATE SET
title = excluded.title,
library = excluded.library,
folder_path = excluded.folder_path,
has_cover = excluded.has_cover,
has_wide = excluded.has_wide,
last_scanned_at = datetime('now')`
).run(slug, title, library, folderPath, hasCover ? 1 : 0, hasWide ? 1 : 0);
const game = db.prepare('SELECT id FROM games WHERE slug = ?').get(slug) as { id: number };
db.prepare('DELETE FROM game_files WHERE game_id = ?').run(game.id);
for (const entry of entries) {
const platform = detectPlatform(entry.name, entry.isDirectory());
if (platform === null) continue;
const isDir = entry.isDirectory() ? 1 : 0;
let fileSize = 0;
if (!isDir) {
try {
fileSize = fs.statSync(path.join(folderPath, entry.name)).size;
} catch {
/* ignore */
}
}
db.prepare(
`INSERT INTO game_files (game_id, filename, rel_path, platform, is_dir, file_size)
VALUES (?, ?, ?, ?, ?, ?)`
).run(game.id, entry.name, entry.name, platform, isDir, fileSize);
}
db.prepare('DELETE FROM screenshots WHERE game_id = ?').run(game.id);
const ssDir = path.join(folderPath, 'screenshots');
if (fs.existsSync(ssDir)) {
const ssFiles = fs.readdirSync(ssDir).filter(isImageFile).sort();
ssFiles.forEach((filename, i) => {
db.prepare(
`INSERT INTO screenshots (game_id, filename, rel_path, sort_order)
VALUES (?, ?, ?, ?)`
).run(game.id, filename, `screenshots/${filename}`, i);
});
}
}
// Remove DB rows for game folders that no longer exist on disk
const existing = db
.prepare('SELECT id, folder_path FROM games WHERE library = ?')
.all(library) as Array<{ id: number; folder_path: string }>;
for (const row of existing) {
if (!fs.existsSync(row.folder_path)) {
db.prepare('DELETE FROM games WHERE id = ?').run(row.id);
}
}
}
}

36
src/lib/types.ts Normal file
View File

@@ -0,0 +1,36 @@
export interface Game {
id: number;
slug: string;
title: string;
library: 'public' | 'private';
folder_path: string;
description: string;
genre: string;
has_cover: number;
has_wide: number;
last_scanned_at: string;
created_at: string;
}
export interface GameFile {
id: number;
game_id: number;
filename: string;
rel_path: string;
platform: 'windows' | 'macos' | 'linux' | 'unknown';
is_dir: number;
file_size: number;
}
export interface Screenshot {
id: number;
game_id: number;
filename: string;
rel_path: string;
sort_order: number;
}
export interface Tag {
id: number;
name: string;
}

33
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,33 @@
export function toSlug(title: string): string {
return title
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
export function isImageFile(name: string): boolean {
return /\.(png|jpg|jpeg|webp|gif)$/i.test(name);
}
export function formatBytes(bytes: number): string {
if (bytes === 0) return '? MB';
const gb = bytes / 1024 ** 3;
if (gb >= 1) return `${gb.toFixed(1)} GB`;
const mb = bytes / 1024 ** 2;
return `${mb.toFixed(0)} MB`;
}
export function contentType(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
return (
{
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
gif: 'image/gif'
}[ext] ?? 'application/octet-stream'
);
}

View File

@@ -0,0 +1,6 @@
import type { LayoutServerLoad } from './$types';
import { getSession } from '$lib/server/auth';
export const load: LayoutServerLoad = ({ cookies }) => {
return { loggedIn: getSession(cookies) };
};

13
src/routes/+layout.svelte Normal file
View File

@@ -0,0 +1,13 @@
<script lang="ts">
import '../app.css';
import NavBar from '$lib/components/NavBar.svelte';
import type { Snippet } from 'svelte';
import type { LayoutData } from './$types';
let { data, children }: { data: LayoutData; children: Snippet } = $props();
</script>
<NavBar loggedIn={data.loggedIn} />
<main class="min-h-screen bg-gray-950 text-gray-100">
{@render children()}
</main>

View File

@@ -0,0 +1,26 @@
import type { PageServerLoad } from './$types';
import { getDb } from '$lib/server/db';
import { scanLibraries } from '$lib/server/scanner';
export const load: PageServerLoad = async ({ parent }) => {
const { loggedIn } = await parent();
const db = getDb();
scanLibraries(db);
const games =
loggedIn
? db
.prepare(
`SELECT id, slug, title, library, has_cover, has_wide, genre
FROM games ORDER BY title ASC`
)
.all()
: db
.prepare(
`SELECT id, slug, title, library, has_cover, has_wide, genre
FROM games WHERE library = 'public' ORDER BY title ASC`
)
.all();
return { games };
};

24
src/routes/+page.svelte Normal file
View File

@@ -0,0 +1,24 @@
<script lang="ts">
import type { PageData } from './$types';
import GameGrid from '$lib/components/GameGrid.svelte';
import type { Game } from '$lib/types';
let { data }: { data: PageData } = $props();
let filter = $state('');
</script>
<svelte:head>
<title>Game Grid</title>
</svelte:head>
<div class="max-w-screen-2xl mx-auto px-4 py-6">
<div class="mb-6">
<input
type="text"
placeholder="Search games..."
bind:value={filter}
class="w-full max-w-md px-4 py-2 rounded-lg bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-400 focus:outline-none focus:border-purple-500 transition-colors"
/>
</div>
<GameGrid games={data.games as Game[]} loggedIn={data.loggedIn} {filter} />
</div>

View File

@@ -0,0 +1,35 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
import { getSession } from '$lib/server/auth';
import { findCoverFile } from '$lib/server/files';
import { contentType } from '$lib/utils';
import fs from 'node:fs';
import { Readable } from 'node:stream';
import type { Game } from '$lib/types';
export const GET: RequestHandler = ({ params, url, cookies }) => {
const db = getDb();
const game = db.prepare('SELECT * FROM games WHERE slug = ?').get(params.slug) as
| Game
| undefined;
if (!game) return new Response('Not found', { status: 404 });
if (game.library === 'private' && !getSession(cookies)) {
return new Response('Unauthorized', { status: 401 });
}
const wide = url.searchParams.get('wide') === '1';
const coverPath = findCoverFile(game.folder_path, wide && game.has_wide === 1);
if (!coverPath) return new Response('No cover image', { status: 404 });
const mime = contentType(coverPath);
const nodeStream = fs.createReadStream(coverPath);
const webStream = Readable.toWeb(nodeStream) as ReadableStream;
return new Response(webStream, {
headers: {
'Content-Type': mime,
'Cache-Control': 'public, max-age=86400'
}
});
};

View File

@@ -0,0 +1,61 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
import { getSession } from '$lib/server/auth';
import fs from 'node:fs';
import path from 'node:path';
import { Readable } from 'node:stream';
import archiver from 'archiver';
import type { Game, GameFile } from '$lib/types';
export const GET: RequestHandler = async ({ params, cookies }) => {
const db = getDb();
const row = db
.prepare(
`SELECT gf.*, g.library, g.folder_path
FROM game_files gf
JOIN games g ON g.id = gf.game_id
WHERE gf.id = ?`
)
.get(Number(params.fileId)) as (GameFile & Pick<Game, 'library' | 'folder_path'>) | undefined;
if (!row) return new Response('Not found', { status: 404 });
if (row.library === 'private' && !getSession(cookies)) {
return new Response('Unauthorized', { status: 401 });
}
const filePath = path.join(row.folder_path, row.rel_path);
if (row.is_dir) {
// .app bundle — zip on the fly
const archive = archiver('zip', { zlib: { level: 5 } });
archive.directory(filePath, row.filename);
archive.finalize();
const webStream = Readable.toWeb(archive) as ReadableStream;
return new Response(webStream, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': contentDisposition(row.filename + '.zip')
}
});
}
if (!fs.existsSync(filePath)) return new Response('File not found on disk', { status: 404 });
const stat = fs.statSync(filePath);
const nodeStream = fs.createReadStream(filePath);
const webStream = Readable.toWeb(nodeStream) as ReadableStream;
return new Response(webStream, {
headers: {
'Content-Type': 'application/octet-stream',
'Content-Disposition': contentDisposition(row.filename),
'Content-Length': String(stat.size)
}
});
};
function contentDisposition(filename: string): string {
const encoded = encodeURIComponent(filename);
return `attachment; filename="${filename.replace(/"/g, '\\"')}"; filename*=UTF-8''${encoded}`;
}

View File

@@ -0,0 +1,15 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
import { scanLibraries } from '$lib/server/scanner';
import { getSession } from '$lib/server/auth';
export const POST: RequestHandler = ({ cookies }) => {
if (!getSession(cookies)) {
return new Response('Unauthorized', { status: 401 });
}
const db = getDb();
scanLibraries(db);
return new Response(JSON.stringify({ ok: true }), {
headers: { 'Content-Type': 'application/json' }
});
};

View File

@@ -0,0 +1,42 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
import { getSession } from '$lib/server/auth';
import { contentType } from '$lib/utils';
import fs from 'node:fs';
import path from 'node:path';
import { Readable } from 'node:stream';
import type { Game, Screenshot } from '$lib/types';
export const GET: RequestHandler = ({ params, cookies }) => {
const db = getDb();
const row = db
.prepare(
`SELECT s.*, g.library, g.folder_path
FROM screenshots s
JOIN games g ON g.id = s.game_id
WHERE s.id = ?`
)
.get(Number(params.id)) as
| (Screenshot & Pick<Game, 'library' | 'folder_path'>)
| undefined;
if (!row) return new Response('Not found', { status: 404 });
if (row.library === 'private' && !getSession(cookies)) {
return new Response('Unauthorized', { status: 401 });
}
const filePath = path.join(row.folder_path, row.rel_path);
if (!fs.existsSync(filePath)) return new Response('File not found', { status: 404 });
const mime = contentType(filePath);
const nodeStream = fs.createReadStream(filePath);
const webStream = Readable.toWeb(nodeStream) as ReadableStream;
return new Response(webStream, {
headers: {
'Content-Type': mime,
'Cache-Control': 'public, max-age=86400'
}
});
};

View File

@@ -0,0 +1,28 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { verifyPassword, signSession, getSession } from '$lib/server/auth';
export const load: PageServerLoad = ({ cookies }) => {
if (getSession(cookies)) redirect(303, '/');
};
export const actions: Actions = {
default: async ({ request, cookies }) => {
const data = await request.formData();
const password = data.get('password') as string;
const ok = await verifyPassword(password);
if (!ok) return fail(401, { error: 'Incorrect password' });
cookies.set('session', signSession(), {
path: '/',
httpOnly: true,
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 30,
// Set SECURE_COOKIES=true when running behind an HTTPS reverse proxy
secure: process.env.SECURE_COOKIES === 'true'
});
redirect(303, '/');
}
};

View File

@@ -0,0 +1,61 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
let loading = $state(false);
let fatalError = $state('');
</script>
<svelte:head>
<title>Log in — Game Grid</title>
</svelte:head>
<div class="min-h-screen flex items-center justify-center px-4">
<div class="w-full max-w-sm">
<h1 class="text-2xl font-bold text-white mb-8 text-center">Game Grid</h1>
<form
method="POST"
use:enhance={() => {
loading = true;
return async ({ update }) => {
try {
await update();
} catch (err) {
fatalError = String(err);
} finally {
loading = false;
}
};
}}
class="bg-gray-900 rounded-xl p-6 space-y-4"
>
<div>
<label for="password" class="block text-sm font-medium text-gray-300 mb-1.5">
Password
</label>
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
required
class="w-full px-4 py-2 rounded-lg bg-gray-800 border border-gray-700 text-gray-100 focus:outline-none focus:border-purple-500 transition-colors"
/>
</div>
{#if form?.error}
<p class="text-red-400 text-sm">{form.error}</p>
{/if}
{#if fatalError}
<p class="text-red-400 text-sm">Unexpected error: {fatalError}</p>
{/if}
<button
type="submit"
disabled={loading}
class="w-full py-2 px-4 bg-purple-600 hover:bg-purple-500 disabled:opacity-60 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
>
{loading ? 'Logging in…' : 'Log in'}
</button>
</form>
</div>
</div>

View File

@@ -0,0 +1,9 @@
import { redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions: Actions = {
default: ({ cookies }) => {
cookies.delete('session', { path: '/' });
redirect(303, '/');
}
};