add project
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user