From 2fdaf870b66e8d0bc15d1c996b76d520abd0ecf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Sun, 1 Mar 2026 13:48:42 +0100 Subject: [PATCH] add pglite database layer: schema, migrations, hooks Co-Authored-By: Claude Opus 4.6 --- src/shared/db/client.ts | 12 ++ src/shared/db/hooks.ts | 208 +++++++++++++++++++++++ src/shared/db/migrations/001-initial.sql | 46 +++++ src/shared/db/schema.ts | 53 ++++++ src/vite-env.d.ts | 6 + 5 files changed, 325 insertions(+) create mode 100644 src/shared/db/client.ts create mode 100644 src/shared/db/hooks.ts create mode 100644 src/shared/db/migrations/001-initial.sql create mode 100644 src/shared/db/schema.ts create mode 100644 src/vite-env.d.ts diff --git a/src/shared/db/client.ts b/src/shared/db/client.ts new file mode 100644 index 0000000..4d169a4 --- /dev/null +++ b/src/shared/db/client.ts @@ -0,0 +1,12 @@ +import { PGlite } from "@electric-sql/pglite" +import migrationSql from "./migrations/001-initial.sql?raw" + +let db: PGlite | null = null + +export async function getDb(): Promise { + if (db) return db + + db = new PGlite("idb://whattoplay") + await db.exec(migrationSql) + return db +} diff --git a/src/shared/db/hooks.ts b/src/shared/db/hooks.ts new file mode 100644 index 0000000..3e30aaa --- /dev/null +++ b/src/shared/db/hooks.ts @@ -0,0 +1,208 @@ +import { useCallback, useEffect, useState } from "react" +import { getDb } from "./client" +import type { Game, Playlist } from "./schema" + +export function useGames() { + const [games, setGames] = useState([]) + const [loading, setLoading] = useState(true) + + const reload = useCallback(async () => { + const db = await getDb() + const result = await db.query("SELECT * FROM games ORDER BY title") + setGames(result.rows) + setLoading(false) + }, []) + + useEffect(() => { + reload() + }, [reload]) + + return { games, loading, reload } +} + +export function useGame(id: string) { + const [game, setGame] = useState(null) + + useEffect(() => { + async function load() { + const db = await getDb() + const result = await db.query("SELECT * FROM games WHERE id = $1", [id]) + setGame(result.rows[0] ?? null) + } + load() + }, [id]) + + return game +} + +export function usePlaylists() { + const [playlists, setPlaylists] = useState<(Playlist & { game_count: number })[]>([]) + const [loading, setLoading] = useState(true) + + const reload = useCallback(async () => { + const db = await getDb() + const result = await db.query(` + SELECT p.*, COALESCE(cnt.game_count, 0)::int AS game_count + FROM playlists p + LEFT JOIN ( + SELECT playlist_id, COUNT(*)::int AS game_count + FROM playlist_games + GROUP BY playlist_id + ) cnt ON cnt.playlist_id = p.id + ORDER BY p.is_static DESC, p.created_at ASC + `) + setPlaylists(result.rows) + setLoading(false) + }, []) + + useEffect(() => { + reload() + }, [reload]) + + return { playlists, loading, reload } +} + +export function usePlaylist(id: string) { + const [playlist, setPlaylist] = useState(null) + const [games, setGames] = useState([]) + const [loading, setLoading] = useState(true) + + const reload = useCallback(async () => { + const db = await getDb() + const pResult = await db.query("SELECT * FROM playlists WHERE id = $1", [id]) + setPlaylist(pResult.rows[0] ?? null) + + const gResult = await db.query( + ` + SELECT g.* FROM games g + JOIN playlist_games pg ON pg.game_id = g.id + WHERE pg.playlist_id = $1 + ORDER BY pg.added_at DESC + `, + [id], + ) + setGames(gResult.rows) + setLoading(false) + }, [id]) + + useEffect(() => { + reload() + }, [reload]) + + return { playlist, games, loading, reload } +} + +export function useConfig(key: string) { + const [value, setValue] = useState(null) + + useEffect(() => { + async function load() { + const db = await getDb() + const result = await db.query<{ value: T }>("SELECT value FROM config WHERE key = $1", [key]) + setValue(result.rows[0]?.value ?? null) + } + load() + }, [key]) + + return value +} + +export function useSaveConfig() { + return useCallback(async (key: string, value: unknown) => { + const db = await getDb() + await db.query( + `INSERT INTO config (key, value, updated_at) VALUES ($1, $2, NOW()) + ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`, + [key, JSON.stringify(value)], + ) + }, []) +} + +export function useSaveGamesBySource() { + return useCallback( + async (source: string, games: Omit[]) => { + const db = await getDb() + for (const game of games) { + await db.query( + `INSERT INTO games (id, title, source, source_id, platform, last_played, playtime_hours, url, canonical_id, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW()) + ON CONFLICT (id) DO UPDATE SET + title = $2, last_played = $6, playtime_hours = $7, url = $8, canonical_id = $9, updated_at = NOW()`, + [ + game.id, + game.title, + game.source, + game.source_id, + game.platform, + game.last_played, + game.playtime_hours, + game.url, + game.canonical_id, + ], + ) + } + }, + [], + ) +} + +export function useUpdateGame() { + return useCallback(async (id: string, updates: Partial) => { + const db = await getDb() + const fields: string[] = [] + const values: unknown[] = [] + let idx = 1 + + for (const [key, value] of Object.entries(updates)) { + if (key === "id") continue + fields.push(`${key} = $${idx}`) + values.push(value) + idx++ + } + + if (fields.length === 0) return + + fields.push("updated_at = NOW()") + values.push(id) + + await db.query(`UPDATE games SET ${fields.join(", ")} WHERE id = $${idx}`, values) + }, []) +} + +export function usePlaylistMutations() { + const addGame = useCallback(async (playlistId: string, gameId: string) => { + const db = await getDb() + await db.query( + `INSERT INTO playlist_games (playlist_id, game_id) VALUES ($1, $2) + ON CONFLICT DO NOTHING`, + [playlistId, gameId], + ) + }, []) + + const removeGame = useCallback(async (playlistId: string, gameId: string) => { + const db = await getDb() + await db.query("DELETE FROM playlist_games WHERE playlist_id = $1 AND game_id = $2", [ + playlistId, + gameId, + ]) + }, []) + + const createPlaylist = useCallback(async (name: string): Promise => { + const db = await getDb() + const id = `custom-${Date.now()}` + await db.query("INSERT INTO playlists (id, name, is_static) VALUES ($1, $2, FALSE)", [id, name]) + return id + }, []) + + const renamePlaylist = useCallback(async (id: string, name: string) => { + const db = await getDb() + await db.query("UPDATE playlists SET name = $1 WHERE id = $2 AND is_static = FALSE", [name, id]) + }, []) + + const deletePlaylist = useCallback(async (id: string) => { + const db = await getDb() + await db.query("DELETE FROM playlists WHERE id = $1 AND is_static = FALSE", [id]) + }, []) + + return { addGame, removeGame, createPlaylist, renamePlaylist, deletePlaylist } +} diff --git a/src/shared/db/migrations/001-initial.sql b/src/shared/db/migrations/001-initial.sql new file mode 100644 index 0000000..61b493d --- /dev/null +++ b/src/shared/db/migrations/001-initial.sql @@ -0,0 +1,46 @@ +CREATE TABLE IF NOT EXISTS games ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + source TEXT NOT NULL, + source_id TEXT NOT NULL, + platform TEXT NOT NULL DEFAULT 'PC', + last_played TEXT, + playtime_hours REAL DEFAULT 0, + url TEXT, + canonical_id TEXT, + rating INTEGER DEFAULT -1, + game_state TEXT DEFAULT 'not_set', + is_favorite BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS playlists ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + is_static BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS playlist_games ( + playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, + game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + added_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (playlist_id, game_id) +); + +CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value JSONB, + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_games_source ON games(source); +CREATE INDEX IF NOT EXISTS idx_games_canonical_id ON games(canonical_id); +CREATE INDEX IF NOT EXISTS idx_games_game_state ON games(game_state); + +INSERT INTO playlists (id, name, is_static) VALUES + ('favorites', 'Favorites', TRUE), + ('want-to-play', 'Want to Play', TRUE), + ('not-interesting', 'Not Interesting', TRUE) +ON CONFLICT (id) DO NOTHING; diff --git a/src/shared/db/schema.ts b/src/shared/db/schema.ts new file mode 100644 index 0000000..45bc468 --- /dev/null +++ b/src/shared/db/schema.ts @@ -0,0 +1,53 @@ +import { z } from "zod" + +export const gameStateEnum = z.enum([ + "not_set", + "wishlisted", + "playlisted", + "playing", + "finished", + "perfected", + "abandoned", + "bad_game", +]) +export type GameState = z.infer + +export const gameSchema = z.object({ + id: z.string(), + title: z.string(), + source: z.string(), + source_id: z.string(), + platform: z.string().default("PC"), + last_played: z.string().nullable().default(null), + playtime_hours: z.number().default(0), + url: z.string().nullable().default(null), + canonical_id: z.string().nullable().default(null), + rating: z.number().min(-1).max(10).default(-1), + game_state: gameStateEnum.default("not_set"), + is_favorite: z.boolean().default(false), + created_at: z.string().optional(), + updated_at: z.string().optional(), +}) +export type Game = z.infer + +export const playlistSchema = z.object({ + id: z.string(), + name: z.string(), + is_static: z.boolean().default(false), + created_at: z.string().optional(), +}) +export type Playlist = z.infer + +export const playlistGameSchema = z.object({ + playlist_id: z.string(), + game_id: z.string(), + added_at: z.string().optional(), +}) +export type PlaylistGame = z.infer + +export const configSchema = z.object({ + key: z.string(), + value: z.unknown(), + updated_at: z.string().optional(), +}) +export type Config = z.infer diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..97abc3a --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,6 @@ +/// + +declare module "*.sql?raw" { + const content: string + export default content +}