diff --git a/.gitignore b/.gitignore index d9e11e1..2487c8f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,9 @@ coverage/ *.sqlite3 data/ apps/server/downloads +apps/server/donwloads apps/server/library +library/ .crocdesk/ test-results playwright-report diff --git a/apps/web/src/pages/LibraryPage.tsx b/apps/web/src/pages/LibraryPage.tsx index 1e517ed..d2df7b8 100644 --- a/apps/web/src/pages/LibraryPage.tsx +++ b/apps/web/src/pages/LibraryPage.tsx @@ -1,13 +1,20 @@ import { useEffect, useState, useMemo } from "react"; -import { Link, useLocation } from "react-router-dom"; +import { Link, useLocation, useSearchParams } from "react-router-dom"; import { apiGet, apiPost } from "../lib/api"; -import type { LibraryItem, Manifest, JobEvent } from "@crocdesk/shared"; +import type { LibraryItem, Manifest, JobEvent, CrocdbApiResponse, CrocdbPlatformsResponseData } from "@crocdesk/shared"; import GameCard from "../components/GameCard"; import PaginationBar from "../components/PaginationBar"; import { DownloadingGhostCard } from "../components/DownloadingGhostCard"; import { ErrorAlert } from "../components/ErrorAlert"; import { useDownloadProgressStore, useSSEStore } from "../store"; import { useSSE } from "../store/hooks/useSSE"; +import { Input, Select, Button } from "../components/ui"; +import { spacing } from "../lib/design-tokens"; +import { useQuery } from "@tanstack/react-query"; + +type SortOption = "name" | "platform" | "date"; +type GroupOption = "none" | "platform" | "status"; +type StatusFilter = "all" | "recognized" | "unknown"; export default function LibraryPage() { const [items, setItems] = useState([]); @@ -21,9 +28,23 @@ export default function LibraryPage() { const [scanMessage, setScanMessage] = useState(""); const [isScanning, setIsScanning] = useState(false); + // Search, filter, and sort state from URL params + const [searchParams, setSearchParams] = useSearchParams(); + const searchQuery = searchParams.get("q") || ""; + const platformFilter = searchParams.get("pf") || ""; + const statusFilter = (searchParams.get("sf") || "all") as StatusFilter; + const sortBy = (searchParams.get("sort") || "name") as SortOption; + const groupBy = (searchParams.get("group") || "none") as GroupOption; + // Ensure SSE connection is active useSSE(); + // Fetch platforms data for display + const platformsQuery = useQuery({ + queryKey: ["platforms"], + queryFn: () => apiGet>("/crocdb/platforms") + }); + // Get downloading slugs from store const downloadingSlugs = useDownloadProgressStore((state) => state.downloadingSlugs); const lastEvent = useSSEStore((state) => state.lastEvent); @@ -68,6 +89,91 @@ export default function LibraryPage() { }).filter(Boolean)); return slugs.filter(slug => !ownedSlugs.has(slug)); }, [downloadingSlugs, items, manifests]); + + // Filtered and sorted items with enriched data + const processedItems = useMemo(() => { + // Enrich items with manifest data + const enriched = items.map(item => { + const manifest = manifests[item.path]; + return { + item, + manifest, + title: manifest?.crocdb?.title || "", + platform: manifest?.crocdb?.platform || "", + isRecognized: !!manifest + }; + }).filter(({ manifest }) => manifest !== null); + + // Apply search filter + let filtered = enriched; + if (searchQuery) { + const query = searchQuery.toLowerCase(); + filtered = filtered.filter(({ title }) => + title.toLowerCase().includes(query) + ); + } + + // Apply platform filter + if (platformFilter) { + filtered = filtered.filter(({ platform }) => platform === platformFilter); + } + + // Apply status filter + if (statusFilter === "recognized") { + filtered = filtered.filter(({ isRecognized }) => isRecognized); + } else if (statusFilter === "unknown") { + filtered = filtered.filter(({ isRecognized }) => !isRecognized); + } + + // Apply sorting + const sorted = [...filtered].sort((a, b) => { + if (sortBy === "name") { + return a.title.localeCompare(b.title); + } else if (sortBy === "platform") { + return a.platform.localeCompare(b.platform) || a.title.localeCompare(b.title); + } else if (sortBy === "date") { + return (b.item.mtime || 0) - (a.item.mtime || 0); + } + return 0; + }); + + return sorted; + }, [items, manifests, searchQuery, platformFilter, statusFilter, sortBy]); + + // Group items if needed + const groupedItems = useMemo(() => { + if (groupBy === "none") { + return { "": processedItems }; + } + + const groups: Record = {}; + for (const item of processedItems) { + let key = ""; + if (groupBy === "platform") { + key = item.platform || "Unknown"; + } else if (groupBy === "status") { + key = item.isRecognized ? "Recognized" : "Unknown"; + } + if (!groups[key]) { + groups[key] = []; + } + groups[key].push(item); + } + + return groups; + }, [processedItems, groupBy]); + + // Extract unique platforms from library items + const availablePlatforms = useMemo(() => { + const platforms = new Set(); + items.forEach(item => { + const manifest = manifests[item.path]; + if (manifest?.crocdb?.platform) { + platforms.add(manifest.crocdb.platform); + } + }); + return Array.from(platforms).sort(); + }, [items, manifests]); useEffect(() => { reloadLibrary(); @@ -132,6 +238,47 @@ export default function LibraryPage() { } } + function handleSearchChange(e: React.ChangeEvent) { + const value = e.target.value; + updateSearchParams({ q: value || undefined }); + } + + function handlePlatformChange(e: React.ChangeEvent) { + const value = e.target.value; + updateSearchParams({ pf: value || undefined }); + } + + function handleStatusChange(e: React.ChangeEvent) { + const value = e.target.value as StatusFilter; + updateSearchParams({ sf: value === "all" ? undefined : value }); + } + + function handleSortChange(e: React.ChangeEvent) { + const value = e.target.value as SortOption; + updateSearchParams({ sort: value === "name" ? undefined : value }); + } + + function handleGroupChange(e: React.ChangeEvent) { + const value = e.target.value as GroupOption; + updateSearchParams({ group: value === "none" ? undefined : value }); + } + + function updateSearchParams(updates: Record) { + const next = new URLSearchParams(searchParams); + Object.entries(updates).forEach(([key, value]) => { + if (value) { + next.set(key, value); + } else { + next.delete(key); + } + }); + setSearchParams(next); + } + + function handleClearFilters() { + setSearchParams({}); + } + const location = useLocation(); if (loading) { @@ -151,6 +298,10 @@ export default function LibraryPage() { if (items.length === 0 && downloadingSlugsArray.length === 0) { return (
+
+

Local Library

+

Your game library is empty.

+

Library

@@ -177,14 +328,86 @@ export default function LibraryPage() { ); } + const hasActiveFilters = searchQuery || platformFilter || statusFilter !== "all"; + const totalItems = items.length; + const filteredCount = processedItems.length; + return (

Local Library

-

Manage your locally downloaded games and their manifests.

+

Manage your locally downloaded games. {totalItems} {totalItems === 1 ? "game" : "games"} in library.

+ + {/* Search and Filter Controls */}
-
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {hasActiveFilters && ( +
+ Clear + +
+ )} +
+ +
+
+ {hasActiveFilters ? `Showing ${filteredCount} of ${totalItems} games` : `${totalItems} games`} +
+ {isScanning && (
@@ -203,62 +427,82 @@ export default function LibraryPage() {
)}
-
- {/* Ghost cards for downloading items */} - {downloadingSlugsArray.map((slug) => ( - - ))} - - {/* Actual library items */} - {items.map((item) => { - const manifest = manifests[item.path]; - if (!manifest) return null; - const artifact = manifest.artifacts[0]; - const artifactPath = artifact ? joinPath(dirname(item.path), artifact.path) : item.path; - return ( - { - if (window.crocdesk?.revealInFolder) { - window.crocdesk.revealInFolder(item.path); - } - }} - actions={ -
- - Details - - -
- } - /> - ); - })} -
+ + {/* Game Grid with Groups */} + {Object.entries(groupedItems).map(([groupName, groupItems]) => ( +
+ {groupName && ( +
+

+ {groupName} ({groupItems.length}) +

+
+ )} + +
+ {/* Ghost cards for downloading items - only in first group */} + {groupName === Object.keys(groupedItems)[0] && downloadingSlugsArray.map((slug) => ( + + ))} + + {/* Library items */} + {groupItems.map(({ item, manifest }) => { + if (!manifest) return null; + const artifact = manifest.artifacts[0]; + const artifactPath = artifact ? joinPath(dirname(item.path), artifact.path) : item.path; + return ( + { + if (window.crocdesk?.revealInFolder) { + window.crocdesk.revealInFolder(item.path); + } + }} + actions={ +
+ + Details + + +
+ } + /> + ); + })} +
+
+ ))} + + {filteredCount === 0 && hasActiveFilters && ( +
+

No games match your filters. Try adjusting your search criteria.

+
+ )}