handle series
This commit is contained in:
@@ -1,21 +1,34 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Game } from '$lib/types';
|
import type { Game, Series } from '$lib/types';
|
||||||
import GameCard from './GameCard.svelte';
|
import GameCard from './GameCard.svelte';
|
||||||
|
import SeriesCard from './SeriesCard.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
games,
|
games,
|
||||||
|
series = [],
|
||||||
loggedIn,
|
loggedIn,
|
||||||
filter = ''
|
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(
|
const filtered = $derived(
|
||||||
filter.trim()
|
filter.trim()
|
||||||
? games.filter(
|
? allItems.filter(
|
||||||
(g) =>
|
(item) =>
|
||||||
g.title.toLowerCase().includes(filter.toLowerCase()) ||
|
item.title.toLowerCase().includes(filter.toLowerCase()) ||
|
||||||
g.genre.toLowerCase().includes(filter.toLowerCase())
|
(item.kind === 'game' && item.genre.toLowerCase().includes(filter.toLowerCase()))
|
||||||
)
|
)
|
||||||
: games
|
: allItems
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -23,8 +36,12 @@
|
|||||||
<p class="text-gray-500 text-center py-16">No games found.</p>
|
<p class="text-gray-500 text-center py-16">No games found.</p>
|
||||||
{:else}
|
{: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">
|
<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)}
|
{#each filtered as item (item.kind + item.id)}
|
||||||
<GameCard {game} {loggedIn} />
|
{#if item.kind === 'game'}
|
||||||
|
<GameCard game={item} {loggedIn} />
|
||||||
|
{:else}
|
||||||
|
<SeriesCard series={item} {loggedIn} />
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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 {
|
function initSchema(db: Database.Database): void {
|
||||||
db.exec(`
|
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 (
|
CREATE TABLE IF NOT EXISTS games (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
slug TEXT NOT NULL UNIQUE,
|
slug TEXT NOT NULL UNIQUE,
|
||||||
@@ -66,4 +77,11 @@ function initSchema(db: Database.Database): void {
|
|||||||
PRIMARY KEY (game_id, tag_id)
|
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,22 +5,104 @@ import { detectPlatform } from '$lib/platform';
|
|||||||
import { toSlug, isImageFile } from '$lib/utils';
|
import { toSlug, isImageFile } from '$lib/utils';
|
||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
|
function isSeriesFolder(entries: fs.Dirent[]): boolean {
|
||||||
|
return entries.some(
|
||||||
|
(e) =>
|
||||||
|
e.isDirectory() &&
|
||||||
|
!e.name.toLowerCase().endsWith('.app') &&
|
||||||
|
e.name.toLowerCase() !== 'screenshots'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanGameFolder(
|
||||||
|
db: Database.Database,
|
||||||
|
folderPath: string,
|
||||||
|
title: string,
|
||||||
|
library: 'public' | 'private',
|
||||||
|
seriesId: number | null
|
||||||
|
): void {
|
||||||
|
const slug = toSlug(title);
|
||||||
|
|
||||||
|
let entries: fs.Dirent[];
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(folderPath, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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, 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, seriesId);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function scanLibraries(db: Database.Database): void {
|
export function scanLibraries(db: Database.Database): void {
|
||||||
const GAMES_PATH = env.GAMES_PATH ?? path.join(process.cwd(), 'games');
|
const GAMES_PATH = env.GAMES_PATH ?? path.join(process.cwd(), 'games');
|
||||||
for (const library of ['public', 'private'] as const) {
|
for (const library of ['public', 'private'] as const) {
|
||||||
const libPath = path.join(GAMES_PATH, library);
|
const libPath = path.join(GAMES_PATH, library);
|
||||||
if (!fs.existsSync(libPath)) continue;
|
if (!fs.existsSync(libPath)) continue;
|
||||||
|
|
||||||
let gameFolders: fs.Dirent[];
|
let topFolders: fs.Dirent[];
|
||||||
try {
|
try {
|
||||||
gameFolders = fs.readdirSync(libPath, { withFileTypes: true }).filter((d) => d.isDirectory());
|
topFolders = fs
|
||||||
|
.readdirSync(libPath, { withFileTypes: true })
|
||||||
|
.filter((d) => d.isDirectory());
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const folder of gameFolders) {
|
for (const folder of topFolders) {
|
||||||
const title = folder.name;
|
const title = folder.name;
|
||||||
const slug = toSlug(title);
|
|
||||||
const folderPath = path.join(libPath, folder.name);
|
const folderPath = path.join(libPath, folder.name);
|
||||||
|
|
||||||
let entries: fs.Dirent[];
|
let entries: fs.Dirent[];
|
||||||
@@ -30,69 +112,61 @@ export function scanLibraries(db: Database.Database): void {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasCover = entries.some(
|
if (isSeriesFolder(entries)) {
|
||||||
(e) => e.isFile() && /^cover\.(png|jpg|jpeg|webp)$/i.test(e.name)
|
const seriesSlug = toSlug(title);
|
||||||
);
|
const hasCover = entries.some(
|
||||||
const hasWide = entries.some(
|
(e) => e.isFile() && /^cover\.(png|jpg|jpeg|webp)$/i.test(e.name)
|
||||||
(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(
|
db.prepare(
|
||||||
`INSERT INTO game_files (game_id, filename, rel_path, platform, is_dir, file_size)
|
`INSERT INTO series (slug, title, library, folder_path, has_cover)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?)
|
||||||
).run(game.id, entry.name, entry.name, platform, isDir, fileSize);
|
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);
|
||||||
|
|
||||||
db.prepare('DELETE FROM screenshots WHERE game_id = ?').run(game.id);
|
const seriesRow = db
|
||||||
const ssDir = path.join(folderPath, 'screenshots');
|
.prepare('SELECT id FROM series WHERE slug = ?')
|
||||||
if (fs.existsSync(ssDir)) {
|
.get(seriesSlug) as { id: number };
|
||||||
const ssFiles = fs.readdirSync(ssDir).filter(isImageFile).sort();
|
|
||||||
ssFiles.forEach((filename, i) => {
|
for (const entry of entries) {
|
||||||
db.prepare(
|
if (
|
||||||
`INSERT INTO screenshots (game_id, filename, rel_path, sort_order)
|
!entry.isDirectory() ||
|
||||||
VALUES (?, ?, ?, ?)`
|
entry.name.toLowerCase().endsWith('.app') ||
|
||||||
).run(game.id, filename, `screenshots/${filename}`, i);
|
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
|
// 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 = ?')
|
.prepare('SELECT id, folder_path FROM games WHERE library = ?')
|
||||||
.all(library) as Array<{ id: number; folder_path: string }>;
|
.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)) {
|
if (!fs.existsSync(row.folder_path)) {
|
||||||
db.prepare('DELETE FROM games WHERE id = ?').run(row.id);
|
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 {
|
export interface Game {
|
||||||
id: number;
|
id: number;
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -8,6 +19,7 @@ export interface Game {
|
|||||||
genre: string;
|
genre: string;
|
||||||
has_cover: number;
|
has_cover: number;
|
||||||
has_wide: number;
|
has_wide: number;
|
||||||
|
series_id: number | null;
|
||||||
last_scanned_at: string;
|
last_scanned_at: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,20 +7,27 @@ export const load: PageServerLoad = async ({ parent }) => {
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
scanLibraries(db);
|
scanLibraries(db);
|
||||||
|
|
||||||
const games =
|
const games = loggedIn
|
||||||
loggedIn
|
? db
|
||||||
? db
|
.prepare(
|
||||||
.prepare(
|
`SELECT id, slug, title, library, has_cover, has_wide, genre
|
||||||
`SELECT id, slug, title, library, has_cover, has_wide, genre
|
FROM games WHERE series_id IS NULL ORDER BY title ASC`
|
||||||
FROM games ORDER BY title ASC`
|
)
|
||||||
)
|
.all()
|
||||||
.all()
|
: db
|
||||||
: db
|
.prepare(
|
||||||
.prepare(
|
`SELECT id, slug, title, library, has_cover, has_wide, genre
|
||||||
`SELECT id, slug, title, library, has_cover, has_wide, genre
|
FROM games WHERE series_id IS NULL AND library = 'public' ORDER BY title ASC`
|
||||||
FROM games WHERE library = 'public' ORDER BY title ASC`
|
)
|
||||||
)
|
.all();
|
||||||
.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">
|
<script lang="ts">
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import GameGrid from '$lib/components/GameGrid.svelte';
|
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 { data }: { data: PageData } = $props();
|
||||||
let filter = $state('');
|
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"
|
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>
|
</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>
|
</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