add library feature: search, sort, deduplication, progressive rendering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 14:32:18 +01:00
parent 9577087930
commit e51a01123e
5 changed files with 235 additions and 1 deletions

View File

@@ -0,0 +1,25 @@
import { formatPlaytime } from "@/features/games/schema"
import { t } from "@/shared/i18n"
interface LibraryHeaderProps {
totalCount: number
totalPlaytime: number
}
export function LibraryHeader({ totalCount, totalPlaytime }: LibraryHeaderProps) {
return (
<div className="mb-4">
<h1 className="text-2xl font-bold">{t("library.title")}</h1>
<div className="mt-1 flex gap-4 text-sm text-muted-foreground">
<span>
{totalCount} {t("library.games")}
</span>
{totalPlaytime > 0 && (
<span>
{formatPlaytime(totalPlaytime)} {t("library.hours")}
</span>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,42 @@
import { GameCard } from "@/features/games/components/game-card"
import type { Game } from "@/shared/db/schema"
import { t } from "@/shared/i18n"
import { useEffect, useRef } from "react"
interface LibraryListProps {
games: Game[]
hasMore: boolean
loadMore: () => void
onUpdate: () => void
}
export function LibraryList({ games, hasMore, loadMore, onUpdate }: LibraryListProps) {
const sentinelRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!hasMore || !sentinelRef.current) return
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) loadMore()
},
{ threshold: 0.1 },
)
observer.observe(sentinelRef.current)
return () => observer.disconnect()
}, [hasMore, loadMore])
if (games.length === 0) {
return <p className="py-8 text-center text-muted-foreground">{t("library.empty")}</p>
}
return (
<div className="space-y-3">
{games.map((game) => (
<GameCard key={game.id} game={game} onUpdate={onUpdate} />
))}
{hasMore && <div ref={sentinelRef} className="h-8" />}
</div>
)
}

View File

@@ -0,0 +1,48 @@
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { t } from "@/shared/i18n"
import { useUiStore } from "@/shared/stores/ui-store"
import { ArrowDownAZ, ArrowUpAZ } from "lucide-react"
export function LibrarySearch() {
const { searchText, setSearchText, sortBy, setSortBy, sortDirection, toggleSortDirection } =
useUiStore()
return (
<div className="mb-4 flex gap-2">
<Input
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder={t("library.search")}
className="flex-1"
/>
<Select
value={sortBy}
onValueChange={(v) => setSortBy(v as "title" | "playtime" | "lastPlayed")}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="title">{t("library.sort.title")}</SelectItem>
<SelectItem value="playtime">{t("library.sort.playtime")}</SelectItem>
<SelectItem value="lastPlayed">{t("library.sort.lastPlayed")}</SelectItem>
</SelectContent>
</Select>
<Button variant="ghost" size="icon" onClick={toggleSortDirection}>
{sortDirection === "asc" ? (
<ArrowDownAZ className="h-4 w-4" />
) : (
<ArrowUpAZ className="h-4 w-4" />
)}
</Button>
</div>
)
}

View File

@@ -0,0 +1,98 @@
import { useGames } from "@/shared/db/hooks"
import type { Game } from "@/shared/db/schema"
import { useUiStore } from "@/shared/stores/ui-store"
import { useCallback, useEffect, useMemo, useState } from "react"
function normalizeTitle(title: string): string {
return title.toLowerCase().replace(/[^a-z0-9]/g, "")
}
function mergeGames(games: Game[]): Game[] {
const merged = new Map<string, Game>()
for (const game of games) {
const key = game.canonical_id ?? `title:${normalizeTitle(game.title)}`
const existing = merged.get(key)
if (existing) {
merged.set(key, {
...existing,
playtime_hours: existing.playtime_hours + game.playtime_hours,
last_played:
existing.last_played && game.last_played
? existing.last_played > game.last_played
? existing.last_played
: game.last_played
: existing.last_played || game.last_played,
rating: existing.rating >= 0 ? existing.rating : game.rating,
game_state: existing.game_state !== "not_set" ? existing.game_state : game.game_state,
is_favorite: existing.is_favorite || game.is_favorite,
})
} else {
merged.set(key, { ...game })
}
}
return Array.from(merged.values())
}
const INITIAL_BATCH = 20
const BATCH_SIZE = 50
export function useLibrary() {
const { games: allGames, loading, reload } = useGames()
const { searchText, sortBy, sortDirection } = useUiStore()
const [visibleCount, setVisibleCount] = useState(INITIAL_BATCH)
const merged = useMemo(() => mergeGames(allGames), [allGames])
const filtered = useMemo(() => {
let result = merged
if (searchText) {
const q = searchText.toLowerCase()
result = result.filter((g) => g.title.toLowerCase().includes(q))
}
result.sort((a, b) => {
const dir = sortDirection === "asc" ? 1 : -1
switch (sortBy) {
case "playtime":
return (b.playtime_hours - a.playtime_hours) * dir
case "lastPlayed": {
const aDate = a.last_played ?? ""
const bDate = b.last_played ?? ""
return bDate.localeCompare(aDate) * dir
}
default:
return a.title.localeCompare(b.title) * dir
}
})
return result
}, [merged, searchText, sortBy, sortDirection])
const visible = useMemo(() => filtered.slice(0, visibleCount), [filtered, visibleCount])
const loadMore = useCallback(() => {
setVisibleCount((c) => Math.min(c + BATCH_SIZE, filtered.length))
}, [filtered.length])
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional reset when filters change
useEffect(() => {
setVisibleCount(INITIAL_BATCH)
}, [searchText, sortBy, sortDirection])
const totalPlaytime = useMemo(
() => merged.reduce((sum, g) => sum + g.playtime_hours, 0),
[merged],
)
return {
games: visible,
totalCount: merged.length,
filteredCount: filtered.length,
totalPlaytime,
hasMore: visibleCount < filtered.length,
loadMore,
loading,
reload,
}
}

View File

@@ -1,5 +1,26 @@
import { LibraryHeader } from "@/features/library/components/library-header"
import { LibraryList } from "@/features/library/components/library-list"
import { LibrarySearch } from "@/features/library/components/library-search"
import { useLibrary } from "@/features/library/hooks/use-library"
import { t } from "@/shared/i18n"
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/library/")({
component: () => <div>Library</div>,
component: LibraryPage,
})
function LibraryPage() {
const { games, totalCount, totalPlaytime, hasMore, loadMore, loading, reload } = useLibrary()
if (loading) {
return <div className="p-4 text-center text-muted-foreground">{t("general.loading")}</div>
}
return (
<div className="mx-auto max-w-lg p-4">
<LibraryHeader totalCount={totalCount} totalPlaytime={totalPlaytime} />
<LibrarySearch />
<LibraryList games={games} hasMore={hasMore} loadMore={loadMore} onUpdate={reload} />
</div>
)
}