add project
This commit is contained in:
20
.env.example
Normal file
20
.env.example
Normal 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
1
.gitignore
vendored
@@ -5,3 +5,4 @@ data/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
games/
|
||||
|
||||
35
Dockerfile
Normal file
35
Dockerfile
Normal 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
27
Project.md
Normal 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
26
docker-compose.yml
Normal 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
4858
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
package.json
Normal file
33
package.json
Normal 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
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
3
src/app.css
Normal file
3
src/app.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
12
src/app.html
Normal file
12
src/app.html
Normal 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>
|
||||
76
src/lib/components/DownloadButton.svelte
Normal file
76
src/lib/components/DownloadButton.svelte
Normal 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}
|
||||
37
src/lib/components/GameCard.svelte
Normal file
37
src/lib/components/GameCard.svelte
Normal 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>
|
||||
30
src/lib/components/GameGrid.svelte
Normal file
30
src/lib/components/GameGrid.svelte
Normal 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}
|
||||
82
src/lib/components/MetaEditor.svelte
Normal file
82
src/lib/components/MetaEditor.svelte
Normal 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>
|
||||
35
src/lib/components/NavBar.svelte
Normal file
35
src/lib/components/NavBar.svelte
Normal 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>
|
||||
26
src/lib/components/PlatformIcon.svelte
Normal file
26
src/lib/components/PlatformIcon.svelte
Normal 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}
|
||||
82
src/lib/components/ScreenshotGallery.svelte
Normal file
82
src/lib/components/ScreenshotGallery.svelte
Normal 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}
|
||||
50
src/lib/components/TagEditor.svelte
Normal file
50
src/lib/components/TagEditor.svelte
Normal 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
29
src/lib/platform.ts
Normal 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
54
src/lib/server/auth.ts
Normal 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
69
src/lib/server/db.ts
Normal 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
16
src/lib/server/files.ts
Normal 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
98
src/lib/server/scanner.ts
Normal 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
36
src/lib/types.ts
Normal 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
33
src/lib/utils.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
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, '/');
|
||||
}
|
||||
};
|
||||
15
svelte.config.js
Normal file
15
svelte.config.js
Normal 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
9
tailwind.config.ts
Normal 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
13
tsconfig.json
Normal 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
6
vite.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()]
|
||||
});
|
||||
Reference in New Issue
Block a user