handle series
This commit is contained in:
@@ -1,21 +1,34 @@
|
||||
<script lang="ts">
|
||||
import type { Game } from '$lib/types';
|
||||
import type { Game, Series } from '$lib/types';
|
||||
import GameCard from './GameCard.svelte';
|
||||
import SeriesCard from './SeriesCard.svelte';
|
||||
|
||||
let {
|
||||
games,
|
||||
series = [],
|
||||
loggedIn,
|
||||
filter = ''
|
||||
}: { games: Game[]; loggedIn: boolean; filter?: string } = $props();
|
||||
}: { games: Game[]; series?: Series[]; loggedIn: boolean; filter?: string } = $props();
|
||||
|
||||
type GameItem = { kind: 'game' } & Game;
|
||||
type SeriesItem = { kind: 'series' } & Series;
|
||||
type Item = GameItem | SeriesItem;
|
||||
|
||||
const allItems = $derived<Item[]>(
|
||||
[
|
||||
...games.map((g) => ({ kind: 'game' as const, ...g })),
|
||||
...series.map((s) => ({ kind: 'series' as const, ...s }))
|
||||
].sort((a, b) => a.title.localeCompare(b.title))
|
||||
);
|
||||
|
||||
const filtered = $derived(
|
||||
filter.trim()
|
||||
? games.filter(
|
||||
(g) =>
|
||||
g.title.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
g.genre.toLowerCase().includes(filter.toLowerCase())
|
||||
? allItems.filter(
|
||||
(item) =>
|
||||
item.title.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
(item.kind === 'game' && item.genre.toLowerCase().includes(filter.toLowerCase()))
|
||||
)
|
||||
: games
|
||||
: allItems
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -23,8 +36,12 @@
|
||||
<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 filtered as item (item.kind + item.id)}
|
||||
{#if item.kind === 'game'}
|
||||
<GameCard game={item} {loggedIn} />
|
||||
{:else}
|
||||
<SeriesCard series={item} {loggedIn} />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
43
src/lib/components/SeriesCard.svelte
Normal file
43
src/lib/components/SeriesCard.svelte
Normal file
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import type { Series } from '$lib/types';
|
||||
|
||||
let { series, loggedIn }: { series: Series; loggedIn: boolean } = $props();
|
||||
</script>
|
||||
|
||||
<a href="/series/{series.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 series.has_cover}
|
||||
<img
|
||||
src="/api/cover/series/{series.slug}"
|
||||
alt={series.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-indigo-900 to-gray-900">
|
||||
<span class="text-sm font-medium text-gray-200 leading-tight line-clamp-3"
|
||||
>{series.title}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="absolute top-2 left-2 bg-indigo-600 text-white text-xs font-medium px-2 py-0.5 rounded-full"
|
||||
>
|
||||
Series
|
||||
</div>
|
||||
|
||||
{#if series.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">
|
||||
{series.title}
|
||||
</p>
|
||||
</a>
|
||||
@@ -23,6 +23,17 @@ export function getDb(): Database.Database {
|
||||
|
||||
function initSchema(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS series (
|
||||
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,
|
||||
has_cover 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 games (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
@@ -66,4 +77,11 @@ function initSchema(db: Database.Database): void {
|
||||
PRIMARY KEY (game_id, tag_id)
|
||||
);
|
||||
`);
|
||||
|
||||
const cols = db.prepare('PRAGMA table_info(games)').all() as Array<{ name: string }>;
|
||||
if (!cols.some((c) => c.name === 'series_id')) {
|
||||
db.exec(
|
||||
'ALTER TABLE games ADD COLUMN series_id INTEGER REFERENCES series(id) ON DELETE SET NULL'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,29 +5,29 @@ 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;
|
||||
function isSeriesFolder(entries: fs.Dirent[]): boolean {
|
||||
return entries.some(
|
||||
(e) =>
|
||||
e.isDirectory() &&
|
||||
!e.name.toLowerCase().endsWith('.app') &&
|
||||
e.name.toLowerCase() !== 'screenshots'
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
function scanGameFolder(
|
||||
db: Database.Database,
|
||||
folderPath: string,
|
||||
title: string,
|
||||
library: 'public' | 'private',
|
||||
seriesId: number | null
|
||||
): void {
|
||||
const slug = toSlug(title);
|
||||
const folderPath = path.join(libPath, folder.name);
|
||||
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(folderPath, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
|
||||
const hasCover = entries.some(
|
||||
@@ -38,16 +38,17 @@ export function scanLibraries(db: Database.Database): void {
|
||||
);
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO games (slug, title, library, folder_path, has_cover, has_wide)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`INSERT INTO games (slug, title, library, folder_path, has_cover, has_wide, series_id)
|
||||
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,
|
||||
series_id = excluded.series_id,
|
||||
last_scanned_at = datetime('now')`
|
||||
).run(slug, title, library, folderPath, hasCover ? 1 : 0, hasWide ? 1 : 0);
|
||||
).run(slug, title, library, folderPath, hasCover ? 1 : 0, hasWide ? 1 : 0, seriesId);
|
||||
|
||||
const game = db.prepare('SELECT id FROM games WHERE slug = ?').get(slug) as { id: number };
|
||||
|
||||
@@ -83,16 +84,89 @@ export function scanLibraries(db: Database.Database): void {
|
||||
).run(game.id, filename, `screenshots/${filename}`, i);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 topFolders: fs.Dirent[];
|
||||
try {
|
||||
topFolders = fs
|
||||
.readdirSync(libPath, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory());
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const folder of topFolders) {
|
||||
const title = folder.name;
|
||||
const folderPath = path.join(libPath, folder.name);
|
||||
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(folderPath, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isSeriesFolder(entries)) {
|
||||
const seriesSlug = toSlug(title);
|
||||
const hasCover = entries.some(
|
||||
(e) => e.isFile() && /^cover\.(png|jpg|jpeg|webp)$/i.test(e.name)
|
||||
);
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO series (slug, title, library, folder_path, has_cover)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(slug) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
library = excluded.library,
|
||||
folder_path = excluded.folder_path,
|
||||
has_cover = excluded.has_cover,
|
||||
last_scanned_at = datetime('now')`
|
||||
).run(seriesSlug, title, library, folderPath, hasCover ? 1 : 0);
|
||||
|
||||
const seriesRow = db
|
||||
.prepare('SELECT id FROM series WHERE slug = ?')
|
||||
.get(seriesSlug) as { id: number };
|
||||
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
!entry.isDirectory() ||
|
||||
entry.name.toLowerCase().endsWith('.app') ||
|
||||
entry.name.toLowerCase() === 'screenshots'
|
||||
)
|
||||
continue;
|
||||
|
||||
const gameFolderPath = path.join(folderPath, entry.name);
|
||||
scanGameFolder(db, gameFolderPath, entry.name, library, seriesRow.id);
|
||||
}
|
||||
} else {
|
||||
scanGameFolder(db, folderPath, title, library, null);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove DB rows for game folders that no longer exist on disk
|
||||
const existing = db
|
||||
const existingGames = db
|
||||
.prepare('SELECT id, folder_path FROM games WHERE library = ?')
|
||||
.all(library) as Array<{ id: number; folder_path: string }>;
|
||||
for (const row of existing) {
|
||||
for (const row of existingGames) {
|
||||
if (!fs.existsSync(row.folder_path)) {
|
||||
db.prepare('DELETE FROM games WHERE id = ?').run(row.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove DB rows for series folders that no longer exist on disk
|
||||
const existingSeries = db
|
||||
.prepare('SELECT id, folder_path FROM series WHERE library = ?')
|
||||
.all(library) as Array<{ id: number; folder_path: string }>;
|
||||
for (const row of existingSeries) {
|
||||
if (!fs.existsSync(row.folder_path)) {
|
||||
db.prepare('DELETE FROM series WHERE id = ?').run(row.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
export interface Series {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
library: 'public' | 'private';
|
||||
folder_path: string;
|
||||
has_cover: number;
|
||||
last_scanned_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Game {
|
||||
id: number;
|
||||
slug: string;
|
||||
@@ -8,6 +19,7 @@ export interface Game {
|
||||
genre: string;
|
||||
has_cover: number;
|
||||
has_wide: number;
|
||||
series_id: number | null;
|
||||
last_scanned_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -7,20 +7,27 @@ export const load: PageServerLoad = async ({ parent }) => {
|
||||
const db = getDb();
|
||||
scanLibraries(db);
|
||||
|
||||
const games =
|
||||
loggedIn
|
||||
const games = loggedIn
|
||||
? db
|
||||
.prepare(
|
||||
`SELECT id, slug, title, library, has_cover, has_wide, genre
|
||||
FROM games ORDER BY title ASC`
|
||||
FROM games WHERE series_id IS NULL 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`
|
||||
FROM games WHERE series_id IS NULL AND library = 'public' ORDER BY title ASC`
|
||||
)
|
||||
.all();
|
||||
|
||||
return { games };
|
||||
const series = loggedIn
|
||||
? db.prepare(`SELECT id, slug, title, library, has_cover FROM series ORDER BY title ASC`).all()
|
||||
: db
|
||||
.prepare(
|
||||
`SELECT id, slug, title, library, has_cover FROM series WHERE library = 'public' ORDER BY title ASC`
|
||||
)
|
||||
.all();
|
||||
|
||||
return { games, series };
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import GameGrid from '$lib/components/GameGrid.svelte';
|
||||
import type { Game } from '$lib/types';
|
||||
import type { Game, Series } from '$lib/types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
let filter = $state('');
|
||||
@@ -20,5 +20,10 @@
|
||||
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} />
|
||||
<GameGrid
|
||||
games={data.games as Game[]}
|
||||
series={data.series as Series[]}
|
||||
loggedIn={data.loggedIn}
|
||||
{filter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
34
src/routes/api/cover/series/[slug]/+server.ts
Normal file
34
src/routes/api/cover/series/[slug]/+server.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
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 { Series } from '$lib/types';
|
||||
|
||||
export const GET: RequestHandler = ({ params, cookies }) => {
|
||||
const db = getDb();
|
||||
const series = db
|
||||
.prepare('SELECT * FROM series WHERE slug = ?')
|
||||
.get(params.slug) as Series | undefined;
|
||||
|
||||
if (!series) return new Response('Not found', { status: 404 });
|
||||
if (series.library === 'private' && !getSession(cookies)) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const coverPath = findCoverFile(series.folder_path);
|
||||
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'
|
||||
}
|
||||
});
|
||||
};
|
||||
25
src/routes/series/[slug]/+page.server.ts
Normal file
25
src/routes/series/[slug]/+page.server.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { getSession } from '$lib/server/auth';
|
||||
import type { Series } from '$lib/types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, cookies, parent }) => {
|
||||
await parent();
|
||||
const db = getDb();
|
||||
const series = db
|
||||
.prepare('SELECT * FROM series WHERE slug = ?')
|
||||
.get(params.slug) as Series | undefined;
|
||||
|
||||
if (!series) error(404, 'Series not found');
|
||||
if (series.library === 'private' && !getSession(cookies)) error(401, 'Unauthorized');
|
||||
|
||||
const games = db
|
||||
.prepare(
|
||||
`SELECT id, slug, title, library, has_cover, has_wide, genre
|
||||
FROM games WHERE series_id = ? ORDER BY title ASC`
|
||||
)
|
||||
.all(series.id);
|
||||
|
||||
return { series, games };
|
||||
};
|
||||
30
src/routes/series/[slug]/+page.svelte
Normal file
30
src/routes/series/[slug]/+page.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<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>{data.series.title} — Game Grid</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-screen-2xl mx-auto px-4 py-6">
|
||||
<div class="mb-6">
|
||||
<a href="/" class="text-gray-400 hover:text-white text-sm transition-colors">← Back</a>
|
||||
<h1 class="mt-3 text-2xl font-bold text-white">{data.series.title}</h1>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
Reference in New Issue
Block a user