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"
|
||||
|
||||
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