From e51a01123ec8c7d2fa870705281f477ec811e59e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Sun, 1 Mar 2026 14:32:18 +0100 Subject: [PATCH] add library feature: search, sort, deduplication, progressive rendering Co-Authored-By: Claude Opus 4.6 --- .../library/components/library-header.tsx | 25 +++++ .../library/components/library-list.tsx | 42 ++++++++ .../library/components/library-search.tsx | 48 +++++++++ src/features/library/hooks/use-library.ts | 98 +++++++++++++++++++ src/routes/library/index.tsx | 23 ++++- 5 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 src/features/library/components/library-header.tsx create mode 100644 src/features/library/components/library-list.tsx create mode 100644 src/features/library/components/library-search.tsx create mode 100644 src/features/library/hooks/use-library.ts 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 ( +
+ + + +
+ ) +}