add pglite database layer: schema, migrations, hooks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 13:48:42 +01:00
parent 5ebd9dba16
commit 2fdaf870b6
5 changed files with 325 additions and 0 deletions

12
src/shared/db/client.ts Normal file
View File

@@ -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<PGlite> {
if (db) return db
db = new PGlite("idb://whattoplay")
await db.exec(migrationSql)
return db
}

208
src/shared/db/hooks.ts Normal file
View File

@@ -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<Game[]>([])
const [loading, setLoading] = useState(true)
const reload = useCallback(async () => {
const db = await getDb()
const result = await db.query<Game>("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<Game | null>(null)
useEffect(() => {
async function load() {
const db = await getDb()
const result = await db.query<Game>("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<Playlist & { game_count: number }>(`
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<Playlist | null>(null)
const [games, setGames] = useState<Game[]>([])
const [loading, setLoading] = useState(true)
const reload = useCallback(async () => {
const db = await getDb()
const pResult = await db.query<Playlist>("SELECT * FROM playlists WHERE id = $1", [id])
setPlaylist(pResult.rows[0] ?? null)
const gResult = await db.query<Game>(
`
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<T = unknown>(key: string) {
const [value, setValue] = useState<T | null>(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<Game, "rating" | "game_state" | "is_favorite">[]) => {
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<Game>) => {
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<string> => {
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 }
}

View File

@@ -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;

53
src/shared/db/schema.ts Normal file
View File

@@ -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<typeof gameStateEnum>
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<typeof gameSchema>
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<typeof playlistSchema>
export const playlistGameSchema = z.object({
playlist_id: z.string(),
game_id: z.string(),
added_at: z.string().optional(),
})
export type PlaylistGame = z.infer<typeof playlistGameSchema>
export const configSchema = z.object({
key: z.string(),
value: z.unknown(),
updated_at: z.string().optional(),
})
export type Config = z.infer<typeof configSchema>

6
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module "*.sql?raw" {
const content: string
export default content
}