add library feature: search, sort, deduplication, progressive rendering
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
25
src/features/library/components/library-header.tsx
Normal file
25
src/features/library/components/library-header.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
src/features/library/components/library-list.tsx
Normal file
42
src/features/library/components/library-list.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
48
src/features/library/components/library-search.tsx
Normal file
48
src/features/library/components/library-search.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
98
src/features/library/hooks/use-library.ts
Normal file
98
src/features/library/hooks/use-library.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
|
||||||
export const Route = createFileRoute("/library/")({
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user