diff --git a/src/features/library/components/library-header.tsx b/src/features/library/components/library-header.tsx
new file mode 100644
index 0000000..8bfa6f5
--- /dev/null
+++ b/src/features/library/components/library-header.tsx
@@ -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 (
+
+
{t("library.title")}
+
+
+ {totalCount} {t("library.games")}
+
+ {totalPlaytime > 0 && (
+
+ {formatPlaytime(totalPlaytime)} {t("library.hours")}
+
+ )}
+
+
+ )
+}
diff --git a/src/features/library/components/library-list.tsx b/src/features/library/components/library-list.tsx
new file mode 100644
index 0000000..9dd7e43
--- /dev/null
+++ b/src/features/library/components/library-list.tsx
@@ -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(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 {t("library.empty")}
+ }
+
+ return (
+
+ {games.map((game) => (
+
+ ))}
+ {hasMore &&
}
+
+ )
+}
diff --git a/src/features/library/components/library-search.tsx b/src/features/library/components/library-search.tsx
new file mode 100644
index 0000000..1810b0a
--- /dev/null
+++ b/src/features/library/components/library-search.tsx
@@ -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 (
+
+
setSearchText(e.target.value)}
+ placeholder={t("library.search")}
+ className="flex-1"
+ />
+
+
+
+ )
+}
diff --git a/src/features/library/hooks/use-library.ts b/src/features/library/hooks/use-library.ts
new file mode 100644
index 0000000..b91b3ea
--- /dev/null
+++ b/src/features/library/hooks/use-library.ts
@@ -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()
+
+ 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,
+ }
+}
diff --git a/src/routes/library/index.tsx b/src/routes/library/index.tsx
index 680f2a5..0e590b8 100644
--- a/src/routes/library/index.tsx
+++ b/src/routes/library/index.tsx
@@ -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: () => Library
,
+ component: LibraryPage,
})
+
+function LibraryPage() {
+ const { games, totalCount, totalPlaytime, hasMore, loadMore, loading, reload } = useLibrary()
+
+ if (loading) {
+ return {t("general.loading")}
+ }
+
+ return (
+
+
+
+
+
+ )
+}