feat: game library sorting

This commit is contained in:
Vinicius Rangel
2025-05-12 16:18:34 -03:00
parent 2d066153c2
commit a318261323
5 changed files with 125 additions and 62 deletions

View File

@@ -2,9 +2,20 @@ import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { useVirtualizer } from "@tanstack/react-virtual";
import { exists, mkdir } from "@tauri-apps/plugin-fs";
import { useAtomValue, useStore } from "jotai";
import { Fragment, Suspense, useEffect, useRef, useState } from "react";
import {
Fragment,
Suspense,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { openPath } from "@/lib/native/common";
import { atomGameLibrary } from "@/store/game-library";
import {
atomGameLibrary,
atomGameLibrarySorting,
SortType,
} from "@/store/game-library";
import { atomGamesPath } from "@/store/paths";
import {
EmptyGameBox,
@@ -44,8 +55,24 @@ function Grid() {
const parentRef = useRef<HTMLDivElement | null>(null);
const { games } = useAtomValue(atomGameLibrary);
const sortType = useAtomValue(atomGameLibrarySorting);
const [itemPerRow, setItemPerRow] = useState(1);
const sortedGames = useMemo(() => {
switch (sortType) {
case SortType.NONE:
return games;
case SortType.TITLE:
return games.toSorted((a, b) => a.title.localeCompare(b.title));
case SortType.CUSA:
return games.toSorted((a, b) => a.cusa.localeCompare(b.cusa));
default: {
const _ret: never = sortType;
return _ret;
}
}
}, [games, sortType]);
const rowCount = Math.ceil(games.length / itemPerRow);
const virtualizer = useVirtualizer({
@@ -99,7 +126,7 @@ function Grid() {
{items.map((row) => {
const firstIdx = row.index * itemPerRow;
const lastIdx = firstIdx + itemPerRow;
const entries = games.slice(firstIdx, lastIdx);
const entries = sortedGames.slice(firstIdx, lastIdx);
return (
<div
className="flex gap-4 pb-4"
@@ -109,7 +136,7 @@ function Grid() {
>
{entries.map((game) => (
<Fragment key={game.path}>
{"error" in game ? (
{"error" in game && game.error ? (
<GameBoxError err={game.error} />
) : (
<GameBox game={game} />

View File

@@ -1,3 +1,4 @@
import * as SelectPrimitive from "@radix-ui/react-select";
import { TooltipTrigger } from "@radix-ui/react-tooltip";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import {
@@ -11,7 +12,11 @@ import { openEmuConfigWindow } from "@/handlers/window";
import { GamepadNavField } from "@/lib/context/gamepad-nav-field";
import { cn } from "@/lib/utils/ui";
import { atomFolderConfigModalIsOpen, oficialRepo } from "@/store/common";
import { atomGameLibrary } from "@/store/game-library";
import {
atomGameLibrary,
atomGameLibrarySorting,
SortType,
} from "@/store/game-library";
import {
atomInstalledVersions,
atomModalVersionManagerIsOpen,
@@ -33,6 +38,7 @@ import {
} from "./ui/select";
import { Spinner } from "./ui/spinner";
import { Tooltip, TooltipContent } from "./ui/tooltip";
import { FilterIcon } from "lucide-react";
function VersionSelector() {
const [isOpen, setIsOpen] = useState(false);
@@ -119,20 +125,42 @@ function ToolbarButton({
export function Toolbar() {
const setFolderConfigModalOpen = useSetAtom(atomFolderConfigModalIsOpen);
const [sort, setSort] = useAtom(atomGameLibrarySorting);
const { indexing } = useAtomValue(atomGameLibrary);
return (
<div className="sticky top-0 z-30 flex justify-between border-b-2 p-3 px-10">
<div className="flex items-center gap-4">
<div className="relative">
<div className="relative flex">
<SearchIcon className="absolute top-2.5 left-2 size-4 text-muted-foreground" />
<Navigable anchor="CENTER_LEFT">
<Input
className="w-full pl-8"
className="w-full rounded-r-none pl-8"
placeholder="Search..."
type="search"
/>
</Navigable>
<Select
value={sort}
onValueChange={(e) => setSort(e as SortType)}
>
<SelectPrimitive.Trigger asChild>
<Button
size="icon"
variant="link"
className="rounded-l-none border-1 border-l-0"
>
<FilterIcon />
</Button>
</SelectPrimitive.Trigger>
<SelectContent>
<SelectItem value={SortType.NONE}>None</SelectItem>
<SelectItem value={SortType.TITLE}>
Title
</SelectItem>
<SelectItem value={SortType.CUSA}>CUSA</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<ToolbarButton

View File

@@ -13,21 +13,25 @@ function getStore(path: string): Promise<Store> {
return s;
}
export function atomWithTauriStore<T>(
/**
* This uses config store underlying. This is not safe to read directly from store,
* as the value prob will not be available in the first read
*/
export function atomWithTauriStore<T, Nullable extends boolean = true>(
path: string,
key: string,
{
initialValue,
onMount = initialValue,
mergeInitial = true,
}:
| {
}: Nullable extends false
? {
initialValue: T;
onMount?: T | (() => Promise<T> | T);
mergeInitial?: boolean;
}
| {
initialValue?: T;
: {
initialValue?: T | undefined;
onMount: T | (() => Promise<T> | T);
mergeInitial?: boolean;
},
@@ -43,7 +47,7 @@ export function atomWithTauriStore<T>(
const initial: T = await Promise.resolve(initialProm);
try {
const store = await getStore(path);
const value = await store.get<T>(key);
const value = (await store.get<T>(key)) ?? null;
if (mergeInitial) {
if (Array.isArray(initial)) {
return [...initial, ...((value as T[]) || [])] as T;
@@ -61,20 +65,24 @@ export function atomWithTauriStore<T>(
}
};
const baseAtom = atom<T | null>(initialValue ?? null);
const baseAtom = atom(
(initialValue ?? null) as Nullable extends true ? T | null : T,
);
baseAtom.onMount = (setAtom) => {
void Promise.resolve(getInitialValue()).then(setAtom);
void Promise.resolve(getInitialValue()).then((e) =>
setAtom(e as Nullable extends true ? T | null : T),
);
};
return atom<T | null, [T], void>(
return atom<Nullable extends true ? T | null : T, [T], void>(
(get) => get(baseAtom),
(get, set, update: SetStateAction<T | null>) => {
const newValue =
typeof update === "function"
? (update as (prev: T | null) => T)(get(baseAtom))
? (update as (prev: T | null) => T)(get(baseAtom) ?? null)
: update;
set(baseAtom, newValue);
set(baseAtom, newValue as Nullable extends true ? T | null : T);
void getStore(path).then((store) => void store.set(key, newValue));
},
);

View File

@@ -4,13 +4,26 @@ import { atom } from "jotai";
import { toast } from "sonner";
import { readPsf } from "@/lib/native/psf";
import { stringifyError } from "@/lib/utils/error";
import { atomWithTauriStore } from "@/lib/utils/jotai/tauri-store";
import { defaultStore } from ".";
import { db, type GameRow } from "./db";
import { atomGamesPath } from "./paths";
export enum SortType {
NONE = "None",
TITLE = "Title",
CUSA = "CUSA",
}
export const atomGameLibrarySorting = atomWithTauriStore<SortType, false>(
"config.json",
"game_library_sort",
{ initialValue: SortType.NONE },
);
export const atomGameLibrary = atom<{
indexing: boolean;
games: (GameRow | { path: string; error: Error })[];
games: (GameRow & { error?: Error })[];
}>({
indexing: false,
games: [],
@@ -18,7 +31,7 @@ export const atomGameLibrary = atom<{
async function loadGameData(
path: string,
): Promise<GameRow | { path: string; error: Error }> {
): Promise<GameRow & { error?: Error }> {
try {
const base = await basename(path);
@@ -38,13 +51,6 @@ async function loadGameData(
const sfo = await readPsf(paramSfo);
const e = sfo.entries;
/* let cover: string | null = await join(path, "sce_sys", "icon0.png");
if (!(await exists(cover))) {
cover = null;
} else {
cover = convertFileSrc(cover);
} */
let fw_version = e.SYSTEM_VER?.Integer?.toString(16)
.padStart(8, "0")
.slice(0, 4);
@@ -67,6 +73,11 @@ async function loadGameData(
console.error(`could not read game info at: "${path}"`, e);
return {
path: path,
cusa: "N/A",
title: "N/A",
version: "N/A",
fw_version: "N/A",
sfo: null,
error: new Error(`game read info. ${stringifyError(e)}`, {
cause: e,
}),
@@ -118,13 +129,15 @@ async function scanDirectory(
signal: AbortSignal,
recursionLevel: number,
) {
if (recursionLevel > 3 || signal.aborted) {
return;
}
if (knownPaths.has(path)) {
return;
}
try {
indexingCount++;
if (recursionLevel > 3 || signal.aborted) {
return;
}
if (knownPaths.has(path)) {
return;
}
if (path.endsWith("-UPDATE") || path.endsWith("-patch")) {
return;
}
@@ -136,26 +149,24 @@ async function scanDirectory(
for (const c of children) {
if (c.isDirectory) {
const childPath = await join(path, c.name);
indexingCount++;
setTimeout(() => {
scanDirectory(
childPath,
knownPaths,
signal,
recursionLevel + 1,
);
indexingCount--;
if (indexingCount === 0) {
defaultStore.set(atomGameLibrary, (prev) => ({
...prev,
indexing: false,
}));
}
}, 1);
scanDirectory(
childPath,
knownPaths,
signal,
recursionLevel + 1,
);
}
}
} catch (e: unknown) {
console.error(`Error discovering game at "${path}"`, e);
} finally {
indexingCount--;
if (indexingCount === 0) {
defaultStore.set(atomGameLibrary, (prev) => ({
...prev,
indexing: false,
}));
}
}
}
@@ -211,23 +222,12 @@ async function scanDirectory(
return;
}
}
indexingCount++;
scanDirectory(
newPath,
knownPaths,
abortController.signal,
1,
);
indexingCount--;
if (indexingCount === 0) {
defaultStore.set(
atomGameLibrary,
(prev) => ({
...prev,
indexing: false,
}),
);
}
}
} else if ("remove" in e.type) {
const newPath = e.paths[0];

View File

@@ -8,8 +8,8 @@
},
/* LANGUAGE COMPILATION OPTIONS */
"target": "ES2022",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"target": "ES2023",
"lib": ["DOM", "DOM.Iterable", "ES2023"],
"module": "ESNext",
"moduleResolution": "bundler",