add pglite database layer: schema, migrations, hooks
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
12
src/shared/db/client.ts
Normal file
12
src/shared/db/client.ts
Normal 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
208
src/shared/db/hooks.ts
Normal 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 }
|
||||
}
|
||||
46
src/shared/db/migrations/001-initial.sql
Normal file
46
src/shared/db/migrations/001-initial.sql
Normal 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
53
src/shared/db/schema.ts
Normal 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
6
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.sql?raw" {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
Reference in New Issue
Block a user