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

20
.env.example Normal file
View File

@@ -0,0 +1,20 @@
# Absolute path to the games root directory (must contain public/ and private/ subdirs)
GAMES_PATH=/path/to/games
# Where to store the SQLite database file
DB_PATH=/path/to/data/game-grid.db
# The shared login password for the private library
APP_PASSWORD=changeme
# Random 32+ character secret for signing session cookies
SESSION_SECRET=replace-with-a-random-string-at-least-32-chars
# The URL clients use to reach this app — required for login to work in production.
# Examples: http://192.168.1.100:3000 | https://games.yourdomain.com
# Not needed in dev (npm run dev) — only for built/Docker deployments.
ORIGIN=http://localhost:3000
# Set to "true" only when the app is behind an HTTPS reverse proxy (nginx, Caddy, etc.)
# Leave unset or "false" for plain HTTP on a local network
SECURE_COOKIES=false

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ data/
.env
.env.*
!.env.example
games/

35
Dockerfile Normal file
View File

@@ -0,0 +1,35 @@
# ---- Build stage ----
FROM node:22-slim AS builder
WORKDIR /app
# Native module build tools (better-sqlite3, bcrypt)
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --production
# ---- Production stage ----
FROM node:22-slim AS runner
WORKDIR /app
COPY --from=builder /app/build ./build
COPY --from=builder /app/node_modules ./node_modules
COPY package.json ./
# Directories that will be bind-mounted at runtime
RUN mkdir -p /data /games/public /games/private
EXPOSE 3000
ENV NODE_ENV=production \
GAMES_PATH=/games \
DB_PATH=/data/game-grid.db
CMD ["node", "build/index.js"]

27
Project.md Normal file
View File

@@ -0,0 +1,27 @@
# Game Grid
a web ui for displaying games saved on a NAS in a easy to browse and download format. Games are stored in two locations that we will call libraries one public and one private. games in the public library are visable to all without a sign in while the games in the private library require the user to be signed in to be visable.
when signed in, games from both libraries should be displayed together in a grid format showcasing their cover art. Clicking on a game should display more information about the game and provide download buttons.
## Library Folder Conventions
Games ("type": "games")
Each game is a subdirectory containing:
Games/
└── My Game Title/
├── My Game Title.zip # Required — the downloadable archive.
├── cover.png # Optional — portrait cover art (case-insensitive)
├── widecover.jpg # Optional — landscape/hero cover art (case-insensitive)
└── screenshots/
└── image1.jpg #optional screenshot images
the game archive will be in different formats depending on the platform it supports. zip files used for windows games, .app files used for macos, and the various linux archive types like tar.gz for linux. If multiple archives exist the download button should include a drop down to allow the user to select the specific version to download. The drop down items should indicate their platform with an icon
## Game metadata
Data to store about games include:
- title (taken from folder name)
- description (editable by signed in user)
- genre (editable by signed in user)
- tags (many to many relationship with games, assignable by signed in user)

26
docker-compose.yml Normal file
View File

@@ -0,0 +1,26 @@
services:
game-grid:
build: .
ports:
- "3060:3000" # change the left side to expose on a different host port
volumes:
# Games library — mount your NAS share here (read-only)
# For a local path: ./games:/games:ro
# For a remote NAS already mounted on the host: /mnt/nas/games:/games:ro
- /data/smb/adult/Games:/games/private:ro
- /data/smb/media/Games/Computer:/games/public:ro
# Persistent SQLite database
- ./data:/data
environment:
NODE_ENV: production
GAMES_PATH: /games
DB_PATH: /data/game-grid.db
# The URL clients use to reach this app — required for login to work.
# Examples: http://192.168.1.100:3000 | https://games.yourdomain.com
ORIGIN: http://games.lan
# Change these before deploying!
APP_PASSWORD: checkoutmygape
SESSION_SECRET: baodinvelsldhvoihyew93ldnvis9387
# Set to "true" only when the app sits behind an HTTPS reverse proxy
SECURE_COOKIES: "false"
restart: unless-stopped

4858
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "game-grid",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.2.0",
"@sveltejs/kit": "^2.5.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/archiver": "^6.0.0",
"@types/bcrypt": "^5.0.0",
"@types/better-sqlite3": "^7.6.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.4.0",
"vite": "^6.0.0"
},
"dependencies": {
"archiver": "^7.0.0",
"bcrypt": "^5.1.0",
"better-sqlite3": "^12.10.0"
},
"type": "module"
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

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

15
svelte.config.js Normal file
View File

@@ -0,0 +1,15 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
// Disable origin check — the app is accessed via many possible URLs
// (IP, hostname, localhost) on a local network, so strict origin
// matching would break form submissions for most users.
csrf: { checkOrigin: false }
}
};
export default config;

9
tailwind.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import type { Config } from 'tailwindcss';
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {}
},
plugins: []
} satisfies Config;

13
tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true
}
}

6
vite.config.ts Normal file
View File

@@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});