add project
This commit is contained in:
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, '/');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user