add project
This commit is contained in:
6
src/routes/+layout.server.ts
Normal file
6
src/routes/+layout.server.ts
Normal 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
13
src/routes/+layout.svelte
Normal 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>
|
||||
26
src/routes/+page.server.ts
Normal file
26
src/routes/+page.server.ts
Normal 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
24
src/routes/+page.svelte
Normal 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>
|
||||
35
src/routes/api/cover/[slug]/+server.ts
Normal file
35
src/routes/api/cover/[slug]/+server.ts
Normal 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'
|
||||
}
|
||||
});
|
||||
};
|
||||
61
src/routes/api/download/[fileId]/+server.ts
Normal file
61
src/routes/api/download/[fileId]/+server.ts
Normal 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}`;
|
||||
}
|
||||
15
src/routes/api/scan/+server.ts
Normal file
15
src/routes/api/scan/+server.ts
Normal 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' }
|
||||
});
|
||||
};
|
||||
42
src/routes/api/screenshot/[id]/+server.ts
Normal file
42
src/routes/api/screenshot/[id]/+server.ts
Normal 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'
|
||||
}
|
||||
});
|
||||
};
|
||||
28
src/routes/login/+page.server.ts
Normal file
28
src/routes/login/+page.server.ts
Normal 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, '/');
|
||||
}
|
||||
};
|
||||
61
src/routes/login/+page.svelte
Normal file
61
src/routes/login/+page.svelte
Normal 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>
|
||||
9
src/routes/logout/+page.server.ts
Normal file
9
src/routes/logout/+page.server.ts
Normal 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, '/');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user