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

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, '/');
}
};